-
Notifications
You must be signed in to change notification settings - Fork 203
Expand file tree
/
Copy pathflowalerts.py
More file actions
2171 lines (1919 loc) · 78.2 KB
/
Copy pathflowalerts.py
File metadata and controls
2171 lines (1919 loc) · 78.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import contextlib
from slips_files.common.abstracts import Module
from slips_files.core.database.database import __database__
from slips_files.common.slips_utils import utils
from slips_files.common.config_parser import ConfigParser
from .TimerThread import TimerThread
from .set_evidence import Helper
from slips_files.core.whitelist import Whitelist
import multiprocessing
import json
import threading
import ipaddress
import datetime
import sys
import validators
import collections
import traceback
import math
import time
class Module(Module, multiprocessing.Process):
name = 'Flow Alerts'
description = (
'Alerts about flows: long connection, successful ssh, '
'password guessing, self-signed certificate, data exfiltration, etc.'
)
authors = ['Kamila Babayeva', 'Sebastian Garcia', 'Alya Gomaa']
def __init__(self, outputqueue, redis_port):
multiprocessing.Process.__init__(self)
# All the printing output should be sent to the outputqueue.
# The outputqueue is connected to another process called OutputProcess
self.outputqueue = outputqueue
__database__.start(redis_port)
# Read the configuration
self.read_configuration()
# Retrieve the labels
self.normal_label = __database__.normal_label
self.malicious_label = __database__.malicious_label
self.c1 = __database__.subscribe('new_flow')
self.c2 = __database__.subscribe('new_ssh')
self.c3 = __database__.subscribe('new_notice')
self.c4 = __database__.subscribe('new_ssl')
self.c5 = __database__.subscribe('tw_closed')
self.c6 = __database__.subscribe('new_dns_flow')
self.c7 = __database__.subscribe('new_downloaded_file')
self.c8 = __database__.subscribe('new_smtp')
self.c9 = __database__.subscribe('new_software')
self.c10 = __database__.subscribe('new_weird')
self.c11 = __database__.subscribe('new_tunnel')
self.whitelist = Whitelist(outputqueue, redis_port)
# helper contains all functions used to set evidence
self.helper = Helper()
self.p2p_daddrs = {}
# get the default gateway
self.gateway = __database__.get_gateway_ip()
# Cache list of connections that we already checked in the timer
# thread (we waited for the connection of these dns resolutions)
self.connections_checked_in_dns_conn_timer_thread = []
# Cache list of connections that we already checked in the timer
# thread (we waited for the dns resolution for these connections)
self.connections_checked_in_conn_dns_timer_thread = []
# Cache list of connections that we already checked in the timer thread for ssh check
self.connections_checked_in_ssh_timer_thread = []
# Threshold how much time to wait when capturing in an interface, to start reporting connections without DNS
# Usually the computer resolved DNS already, so we need to wait a little to report
# In mins
self.conn_without_dns_interface_wait_time = 30
# this dict will contain the number of nxdomains found in every profile
self.nxdomains = {}
# if nxdomains are >= this threshold, it's probably DGA
self.nxdomains_threshold = 10
# when the ctr reaches the threshold in 10 seconds,
# we detect an smtp bruteforce
self.smtp_bruteforce_threshold = 3
# dict to keep track of bad smtp logins to check for bruteforce later
# format {profileid: [ts,ts,...]}
self.smtp_bruteforce_cache = {}
# dict to keep track of arpa queries to check for DNS arpa scans later
# format {profileid: [ts,ts,...]}
self.dns_arpa_queries = {}
# after this number of arpa queries, slips will detect an arpa scan
self.arpa_scan_threshold = 10
# If 1 flow uploaded this amount of MBs or more, slips will alert data upload
self.flow_upload_threshold = 100
# after this number of failed ssh logins, we alert pw guessing
self.pw_guessing_threshold = 20
self.password_guessing_cache = {}
# in pastebin download detection, we wait for each conn.log flow of the seen ssl flow to appear
# this is the dict of ssl flows we're waiting for
self.pending_ssl_flows = multiprocessing.Queue()
# thread that waits for ssl flows to appear in conn.log
self.ssl_waiting_thread = threading.Thread(
target=self.wait_for_ssl_flows_to_appear_in_connlog, daemon=True
)
def read_configuration(self):
conf = ConfigParser()
self.long_connection_threshold = conf.long_connection_threshold()
self.ssh_succesful_detection_threshold = conf.ssh_succesful_detection_threshold()
self.data_exfiltration_threshold = conf.data_exfiltration_threshold()
self.pastebin_downloads_threshold = conf.get_pastebin_download_threshold()
self.our_ips = utils.get_own_IPs()
self.shannon_entropy_threshold = conf.get_entropy_threshold()
def check_connection_to_local_ip(
self,
daddr,
dport,
proto,
saddr,
profileid,
twid,
uid,
timestamp,
):
"""
Alerts when there's a connection from a private IP to another private IP
except for DNS connections to the gateway
"""
with contextlib.suppress(ValueError):
dport = int(dport)
if dport == 53 and proto.lower() == 'udp' and daddr == __database__.get_gateway_ip():
# skip DNS conns to the gw to avoid having tons of this evidence
return
# make sure the 2 ips are private
if not (
ipaddress.ip_address(saddr).is_private
and ipaddress.ip_address(daddr).is_private
):
return
self.helper.set_evidence_conn_to_private_ip(
daddr,
dport,
saddr,
profileid,
twid,
uid,
timestamp,
)
def check_long_connection(
self, dur, daddr, saddr, profileid, twid, uid, timestamp
):
"""
Check if a duration of the connection is
above the threshold (more than 25 minutes by default).
:param dur: duration of the flow in seconds
"""
if (
ipaddress.ip_address(daddr).is_multicast
or ipaddress.ip_address(saddr).is_multicast
):
# Do not check the duration of the flow
return
if type(dur) == str:
dur = float(dur)
module_name = 'flowalerts-long-connection'
# If duration is above threshold, we should set an evidence
if dur > self.long_connection_threshold:
# set "flowalerts-long-connection:malicious" label in the flow (needed for Ensembling module)
module_label = self.malicious_label
self.helper.set_evidence_long_connection(
daddr, dur, profileid, twid, uid, timestamp, ip_state='dstip'
)
else:
# set "flowalerts-long-connection:normal" label in the flow (needed for Ensembling module)
module_label = self.normal_label
__database__.set_module_label_to_flow(
profileid, twid, uid, module_name, module_label
)
def is_p2p(self, dport, proto, daddr):
"""
P2P is defined as following : proto is udp, port numbers are higher than 30000 at least 5 connections to different daddrs
OR trying to connct to 1 ip on more than 5 unkown 30000+/udp ports
"""
if proto.lower() == 'udp' and int(dport) > 30000:
try:
# trying to connct to 1 ip on more than 5 unknown ports
if self.p2p_daddrs[daddr] >= 6:
return True
self.p2p_daddrs[daddr] = self.p2p_daddrs[daddr] + 1
# now check if we have more than 4 different dst ips
except KeyError:
# first time seeing this daddr
self.p2p_daddrs[daddr] = 1
if len(self.p2p_daddrs) == 5:
# this is another connection on port 3000+/udp and we already have 5 of them
# probably p2p
return True
return False
def port_belongs_to_an_org(self, daddr, portproto, profileid):
"""
Checks wehether a port is known to be used by a specific
organization or not, and returns true if the daddr belongs to the
same org as the port
"""
organization_info = __database__.get_organization_of_port(
portproto
)
if not organization_info:
# consider this port as unknown, it doesn't belong to any org
return False
# there's an organization that's known to use this port,
# check if the daddr belongs to the range of this org
organization_info = json.loads(organization_info)
# get the organization ip or range
org_ip = organization_info['ip']
# org_name = organization_info['org_name']
if daddr in org_ip:
# it's an ip and it belongs to this org, consider the port as known
return True
# is it a range?
with contextlib.suppress(ValueError):
# we have the org range in our database, check if the daddr belongs to this range
if ipaddress.ip_address(daddr) in ipaddress.ip_network(org_ip):
# it does, consider the port as known
return True
# not a range either since nothing is specified, e.g. ip is set to ""
# check the source and dst mac address vendors
src_mac_vendor = str(
__database__.get_mac_vendor_from_profile(profileid)
)
dst_mac_vendor = str(
__database__.get_mac_vendor_from_profile(
f'profile_{daddr}'
)
)
org_name = organization_info['org_name'].lower()
if (
org_name in src_mac_vendor.lower()
or org_name in dst_mac_vendor.lower()
):
return True
# check if the SNI, hostname, rDNS of this ip belong to org_name
ip_identification = __database__.getIPIdentification(daddr)
if org_name in ip_identification.lower():
return True
# if it's an org that slips has info about (apple, fb, google,etc.),
# check if the daddr belongs to it
return bool(self.whitelist.is_ip_in_org(daddr, org_name))
def is_ignored_ip_data_upload(self, ip):
"""
Ignore the IPs that we shouldn't alert about
"""
ip_obj = ipaddress.ip_address(ip)
if (
ip == self.gateway
or ip_obj.is_multicast
or ip_obj.is_link_local
or ip_obj.is_reserved
):
return True
def check_data_upload(self, sbytes, daddr, uid, profileid, twid):
"""
Set evidence when 1 flow is sending >= the flow_upload_threshold bytes
"""
if (
self.is_ignored_ip_data_upload(daddr)
or not sbytes
):
return False
src_mbs = utils.convert_to_mb(int(sbytes))
if src_mbs >= self.flow_upload_threshold:
self.helper.set_evidence_data_exfiltration(
daddr,
src_mbs,
profileid,
twid,
uid,
)
return True
def wait_for_ssl_flows_to_appear_in_connlog(self):
"""
thread that waits forever for ssl flows to appear in conn.log
whenever the conn.log of an ssl flow is found, thread calls check_pastebin_download
ssl flows to wait for are stored in pending_ssl_flows
"""
# this is the time we give ssl flows to appear in conn.log,
# when this time is over, we check, then wait again, etc.
wait_time = 60*2
# this thread shouldn't run on interface only because in zeek dirs we
# we should wait for the conn.log to be read too
while True:
size = self.pending_ssl_flows.qsize()
if size == 0:
# nothing in queue
time.sleep(30)
continue
# try to get the conn of each pending flow only once
# this is to ensure that re-added flows to the queue aren't checked twice
for ssl_flow in range(size):
try:
ssl_flow: dict = self.pending_ssl_flows.get(timeout=0.5)
except Exception:
continue
# unpack the flow
daddr, server_name, uid, ts, profileid, twid = ssl_flow
# get the conn.log with the same uid,
# returns {uid: {actual flow..}}
# always returns a dict, never returns None
flow: dict = __database__.get_flow(profileid, twid, uid)
if flow := flow.get(uid):
flow = json.loads(flow)
if 'ts' in flow:
# this means the flow is found in conn.log
self.check_pastebin_download(*ssl_flow, flow)
else:
# flow not found in conn.log yet, re-add it to the queue to check it later
self.pending_ssl_flows.put(ssl_flow)
# give the ssl flows remaining in self.pending_ssl_flows 2 more mins to appear
time.sleep(wait_time)
def check_pastebin_download(
self, daddr, server_name, uid, ts, profileid, twid, flow
):
"""
Alerts on downloads from pastebin.com with more than 12000 bytes
This function waits for the ssl.log flow to appear in conn.log before alerting
:param wait_time: the time we wait for the ssl conn to appear in conn.log in seconds
every time the timer is over, we wait extra 2 min and call the function again
: param flow: this is the conn.log of the ssl flow we're currently checking
"""
if 'pastebin' not in server_name:
return False
# orig_bytes is number of payload bytes downloaded
downloaded_bytes = flow.get('allbytes', 0) - flow.get('sbytes',0)
if downloaded_bytes >= self.pastebin_downloads_threshold:
self.helper.set_evidence_pastebin_download(daddr, downloaded_bytes, ts, profileid, twid, uid)
return True
else:
# reaching this point means that the conn to pastebin did appear
# in conn.log, but the downloaded bytes didnt reach the threshold.
# maybe an empty file is downloaded
return False
def detect_data_upload_in_twid(self, profileid, twid):
"""
For each contacted ip in this twid,
check if the total bytes sent to this ip is >= data_exfiltration_threshold
"""
def get_sent_bytes(all_flows):
"""Returns a dict of sent bytes to all ips {contacted_ip: (mbs_sent, [uids])}"""
bytes_sent = {}
for flow in all_flows:
uid = next(iter(flow))
flow = flow[uid]
daddr = flow['daddr']
sbytes: int = flow.get('sbytes', 0)
if self.is_ignored_ip_data_upload(daddr) or not sbytes:
continue
if daddr in bytes_sent:
mbs_sent, uids = bytes_sent[daddr]
mbs_sent += sbytes
uids.append(uid)
bytes_sent[daddr] = (mbs_sent, uids)
else:
bytes_sent[daddr] = (sbytes, [uid])
return bytes_sent
all_flows = __database__.get_all_flows_in_profileid(
profileid
)
if not all_flows:
return
bytes_sent: dict = get_sent_bytes(all_flows)
for ip, ip_info in bytes_sent.items():
# ip_info is a tuple (bytes_sent, [uids])
uids = ip_info[1]
bytes_uploaded = ip_info[0]
mbs_uploaded = utils.convert_to_mb(bytes_uploaded)
if mbs_uploaded < self.data_exfiltration_threshold:
continue
self.helper.set_evidence_data_exfiltration(
ip,
mbs_uploaded,
profileid,
twid,
uids,
)
def check_unknown_port(
self, dport, proto, daddr,
profileid, twid, uid, timestamp, state
):
"""
Checks dports that are not in our
slips_files/ports_info/services.csv
"""
if not dport:
return
if state != 'Established':
# detect unknown ports on established conns only
return False
portproto = f'{dport}/{proto}'
if port_info := __database__.get_port_info(portproto):
# it's a known port
return False
# we don't have port info in our database
# is it a port that is known to be used by
# a specific organization?
if self.port_belongs_to_an_org(daddr, portproto, profileid):
return False
if (
'icmp' not in proto
and not self.is_p2p(dport, proto, daddr)
and not __database__.is_ftp_port(dport)
):
# we don't have info about this port
self.helper.set_evidence_unknown_port(
daddr, dport, proto, timestamp, profileid, twid, uid
)
return True
def check_if_resolution_was_made_by_different_version(
self, profileid, daddr
):
"""
Sometimes the same computer makes dns requests using its ipv4 and ipv6 address, check if this is the case
"""
# get the other ip version of this computer
other_ip = __database__.get_the_other_ip_version(profileid)
if other_ip:
other_ip = json.loads(other_ip)
# get the domain of this ip
dns_resolution = __database__.get_dns_resolution(daddr)
try:
if other_ip and other_ip in dns_resolution.get('resolved-by', []):
return True
except AttributeError:
# It can be that the dns_resolution sometimes gives back a list and gets this error
return False
def is_connection_made_by_different_version(
self, profileid, twid, daddr
):
"""
:param daddr: the ip this connection is made to (destination ip)
"""
# get the other ip version of this computer
other_ip = __database__.get_the_other_ip_version(profileid)
if not other_ip:
return False
# get the ips contacted by the other_ip
contacted_ips = __database__.get_all_contacted_ips_in_profileid_twid(
f'profile_{other_ip}', twid
)
if not contacted_ips:
return False
if daddr in contacted_ips:
# now we're sure that the connection was made
# by this computer but using a different ip version
return True
def check_dns_arpa_scan(self, domain, stime, profileid, twid, uid):
"""
Detect and ARPA scan if an ip performed 10(arpa_scan_threshold) or more arpa queries within 2 seconds
"""
if not domain:
return False
if not domain.endswith('.in-addr.arpa'):
return False
try:
# format of this dict is {profileid: [stime of first arpa query, stime eof second, etc..]}
timestamps, uids, domains_scanned = self.dns_arpa_queries[profileid]
timestamps.append(stime)
uids.append(uid)
uids.append(uid)
domains_scanned.add(domain)
self.dns_arpa_queries[profileid] = (timestamps, uids, domains_scanned)
except KeyError:
# first time for this profileid to perform an arpa query
self.dns_arpa_queries[profileid] = (
[stime], [uid], {domain}
)
return False
if len(domains_scanned) < self.arpa_scan_threshold:
# didn't reach the threshold yet
return False
# reached the threshold, did the 10 queries happen within 2 seconds?
diff = utils.get_time_diff(
timestamps[0],
timestamps[-1]
)
if diff > 2:
# happened within more than 2 seconds
return False
self.helper.set_evidence_dns_arpa_scan(
self.arpa_scan_threshold, stime, profileid, twid, uids
)
# empty the list of arpa queries for this profile, we don't need them anymore
self.dns_arpa_queries.pop(profileid)
return True
def is_well_known_org(self, ip):
"""get the SNI, ASN, and rDNS of the IP to check if it belongs
to a well-known org"""
ip_data = __database__.getIPData(ip)
try:
SNI = ip_data['SNI']
if type(SNI) == list:
# SNI is a list of dicts, each dict contains the 'server_name' and 'port'
SNI = SNI[0]
if SNI in (None, ''):
SNI = False
elif type(SNI) == dict:
SNI = SNI.get('server_name', False)
except (KeyError, TypeError):
# No SNI data for this ip
SNI = False
try:
rdns = ip_data['reverse_dns']
except (KeyError, TypeError):
# No SNI data for this ip
rdns = False
flow_domain = rdns or SNI
for org in utils.supported_orgs:
if self.whitelist.is_ip_asn_in_org_asn(ip, org):
return True
# we have the rdns or sni of this flow , now check
if flow_domain and self.whitelist.is_domain_in_org(flow_domain, org):
return True
# check if the ip belongs to the range of a well known org
# (fb, twitter, microsoft, etc.)
if self.whitelist.is_ip_in_org(ip, org):
return True
def check_connection_without_dns_resolution(
self, flow_type, appproto, daddr, twid, profileid, timestamp, uid
):
"""
Checks if there's a flow to a dstip that has no cached DNS answer
"""
# The exceptions are:
# 1- Do not check for DNS requests
# 2- Ignore some IPs like private IPs, multicast, and broadcast
if (
flow_type != 'conn'
or appproto == 'dns'
or utils.is_ignored_ip(daddr)
):
return
# disable this alert when running on a zeek conn.log file
# because there's no dns.log to know if the dns was made
if __database__.get_input_type() == 'zeek_log_file':
return False
# Ignore some IP
## - All dhcp servers. Since is ok to connect to them without a DNS request.
# We dont have yet the dhcp in the redis, when is there check it
# if __database__.get_dhcp_servers(daddr):
# continue
# To avoid false positives in case of an interface don't alert ConnectionWithoutDNS
# until 30 minutes has passed
# after starting slips because the dns may have happened before starting slips
if '-i' in sys.argv or __database__.is_growing_zeek_dir():
# connection without dns in case of an interface,
# should only be detected from the srcip of this device,
# not all ips, to avoid so many alerts of this type when port scanning
saddr = profileid.split("_")[-1]
if saddr not in self.our_ips:
return False
start_time = __database__.get_slips_start_time()
now = datetime.datetime.now()
diff = utils.get_time_diff(start_time, now, return_type='minutes')
if diff < self.conn_without_dns_interface_wait_time:
# less than 30 minutes have passed
return False
# search 24hs back for a dns resolution
if __database__.is_ip_resolved(daddr, 24):
return False
# self.print(f'No DNS resolution in {answers_dict}')
# There is no DNS resolution, but it can be that Slips is
# still reading it from the files.
# To give time to Slips to read all the files and get all the flows
# don't alert a Connection Without DNS until 5 seconds has passed
# in real time from the time of this checking.
# Create a timer thread that will wait 15 seconds for the dns to arrive and then check again
# self.print(f'Cache of conns not to check: {self.conn_checked_dns}')
if uid not in self.connections_checked_in_conn_dns_timer_thread:
# comes here if we haven't started the timer thread for this connection before
# mark this connection as checked
self.connections_checked_in_conn_dns_timer_thread.append(uid)
params = [flow_type, appproto, daddr, twid, profileid, timestamp, uid]
# self.print(f'Starting the timer to check on {daddr}, uid {uid}.
# time {datetime.datetime.now()}')
timer = TimerThread(
15, self.check_connection_without_dns_resolution, params
)
timer.start()
else:
# It means we already checked this conn with the Timer process
# (we waited 15 seconds for the dns to arrive after the connection was made)
# but still no dns resolution for it.
# Sometimes the same computer makes requests using its ipv4 and ipv6 address, check if this is the case
if self.check_if_resolution_was_made_by_different_version(
profileid, daddr
):
return False
if self.is_well_known_org(daddr):
# if the SNI or rDNS of the IP matches a well-known org, then this is a FP
return False
# self.print(f'Alerting after timer conn without dns on {daddr},
self.helper.set_evidence_conn_without_dns(
daddr, timestamp, profileid, twid, uid
)
# This UID will never appear again, so we can remove it and
# free some memory
with contextlib.suppress(ValueError):
self.connections_checked_in_conn_dns_timer_thread.remove(
uid
)
def is_CNAME_contacted(self, answers, contacted_ips) -> bool:
"""
check if any ip of the given CNAMEs is contacted
"""
for CNAME in answers:
if not validators.domain(CNAME):
# it's an ip
continue
ips = __database__.get_domain_resolution(CNAME)
for ip in ips:
if ip in contacted_ips:
return True
return False
def check_dns_without_connection(
self, domain, answers: list, rcode_name: str, timestamp: str, profileid, twid, uid
):
"""
Makes sure all cached DNS answers are used in contacted_ips
:param contacted_ips: dict of ips used in a specific tw {ip: uid}
"""
## - All reverse dns resolutions
## - All .local domains
## - The wildcard domain *
## - Subdomains of cymru.com, since it is used by the ipwhois library in Slips to get the ASN
# of an IP and its range. This DNS is meant not to have a connection later
## - Domains check from Chrome, like xrvwsrklpqrw
## - The WPAD domain of windows
# - When there is an NXDOMAIN as answer, it means
# the domain isn't resolved, so we should not expect any connection later
if (
'arpa' in domain
or '.local' in domain
or '*' in domain
or '.cymru.com' in domain[-10:]
or len(domain.split('.')) == 1
or domain == 'WPAD'
or rcode_name != 'NOERROR'
):
return False
# One DNS query may not be answered exactly by UID, but the computer can re-ask the domain,
# and the next DNS resolution can be
# answered. So dont check the UID, check if the domain has an IP
# self.print(f'The DNS query to {domain} had as answers {answers} ')
# It can happen that this domain was already resolved previously, but with other IPs
# So we get from the DB all the IPs for this domain first and append them to the answers
# This happens, for example, when there is 1 DNS resolution with A, then 1 DNS resolution
# with AAAA, and the computer chooses the A address. Therefore, the 2nd DNS resolution
# would be treated as 'without connection', but this is false.
if prev_domain_resolutions := __database__.getDomainData(domain):
prev_domain_resolutions = prev_domain_resolutions.get('IPs',[])
# if there's a domain in the cache (prev_domain_resolutions) that is not in the
# current answers given to this function, append it to the answers list
answers.extend([ans for ans in prev_domain_resolutions if ans not in answers])
if answers == ['-']:
# If no IPs are in the answer, we can not expect the computer to connect to anything
# self.print(f'No ips in the answer, so ignoring')
return False
# self.print(f'The extended DNS query to {domain} had as answers {answers} ')
contacted_ips = __database__.get_all_contacted_ips_in_profileid_twid(
profileid, twid
)
# If contacted_ips is empty it can be because we didnt read yet all the flows.
# This is automatically captured later in the for loop and we start a Timer
# every dns answer is a list of ips that correspond to 1 query,
# one of these ips should be present in the contacted ips
# check each one of the resolutions of this domain
for ip in answers:
# self.print(f'Checking if we have a connection to ip {ip}')
if (
ip in contacted_ips
or
self.is_connection_made_by_different_version(
profileid, twid, ip)
):
# this dns resolution has a connection. We can exit
return False
# Check if there was a connection to any of the CNAMEs
if self.is_CNAME_contacted(answers, contacted_ips):
# this is not a DNS without resolution
return False
# self.print(f'It seems that none of the IPs were contacted')
# Found a DNS query which none of its IPs was contacted
# It can be that Slips is still reading it from the files. Lets check back in some time
# Create a timer thread that will wait some seconds for the connection to arrive and then check again
if uid not in self.connections_checked_in_dns_conn_timer_thread:
# comes here if we haven't started the timer thread for this dns before
# mark this dns as checked
self.connections_checked_in_dns_conn_timer_thread.append(uid)
params = [domain, answers, rcode_name, timestamp, profileid, twid, uid]
# self.print(f'Starting the timer to check on {domain}, uid {uid}.
# time {datetime.datetime.now()}')
timer = TimerThread(
40, self.check_dns_without_connection, params
)
timer.start()
else:
# self.print(f'Alerting on {domain}, uid {uid}. time {datetime.datetime.now()}')
# It means we already checked this dns with the Timer process
# but still no connection for it.
self.helper.set_evidence_DNS_without_conn(
domain, timestamp, profileid, twid, uid
)
# This UID will never appear again, so we can remove it and
# free some memory
with contextlib.suppress(ValueError):
self.connections_checked_in_dns_conn_timer_thread.remove(uid)
def detect_successful_ssh_by_zeek(self, uid, timestamp, profileid, twid):
"""
Check for auth_success: true in the given zeek flow
"""
original_ssh_flow = __database__.search_tws_for_flow(profileid, twid, uid)
original_flow_uid = next(iter(original_ssh_flow))
if original_ssh_flow[original_flow_uid]:
ssh_flow_dict = json.loads(
original_ssh_flow[original_flow_uid]
)
daddr = ssh_flow_dict['daddr']
saddr = ssh_flow_dict['saddr']
size = ssh_flow_dict['allbytes']
self.helper.set_evidence_ssh_successful(
profileid,
twid,
saddr,
daddr,
size,
uid,
timestamp,
by='Zeek',
)
with contextlib.suppress(ValueError):
self.connections_checked_in_ssh_timer_thread.remove(
uid
)
return True
elif uid not in self.connections_checked_in_ssh_timer_thread:
# It can happen that the original SSH flow is not in the DB yet
# comes here if we haven't started the timer thread for this connection before
# mark this connection as checked
# self.print(f'Starting the timer to check on {flow_dict}, uid {uid}. time {datetime.datetime.now()}')
self.connections_checked_in_ssh_timer_thread.append(
uid
)
params = [uid, timestamp, profileid, twid]
timer = TimerThread(
15, self.detect_successful_ssh_by_zeek, params
)
timer.start()
def detect_successful_ssh_by_slips(self, uid, timestamp, profileid, twid, auth_success):
"""
Try Slips method to detect if SSH was successful by
comparing all bytes sent and received to our threshold
"""
original_ssh_flow = __database__.get_flow(profileid, twid, uid)
original_flow_uid = next(iter(original_ssh_flow))
if original_ssh_flow[original_flow_uid]:
ssh_flow_dict = json.loads(
original_ssh_flow[original_flow_uid]
)
size = ssh_flow_dict['allbytes']
if size > self.ssh_succesful_detection_threshold:
daddr = ssh_flow_dict['daddr']
saddr = ssh_flow_dict['saddr']
# Set the evidence because there is no
# easier way to show how Slips detected
# the successful ssh and not Zeek
self.helper.set_evidence_ssh_successful(
profileid,
twid,
saddr,
daddr,
size,
uid,
timestamp,
by='Slips',
)
with contextlib.suppress(ValueError):
self.connections_checked_in_ssh_timer_thread.remove(
uid
)
return True
elif uid not in self.connections_checked_in_ssh_timer_thread:
# It can happen that the original SSH flow is not in the DB yet
# comes here if we haven't started the timer thread for this connection before
# mark this connection as checked
# self.print(f'Starting the timer to check on {flow_dict}, uid {uid}.
# time {datetime.datetime.now()}')
self.connections_checked_in_ssh_timer_thread.append(
uid
)
params = [uid, timestamp, profileid, twid, auth_success]
timer = TimerThread(
15, self.check_successful_ssh, params
)
timer.start()
def check_successful_ssh(self, uid, timestamp, profileid, twid, auth_success):
"""
Function to check if an SSH connection logged in successfully
"""
# it's true in zeek json files, T in zeke tab files
if auth_success in ['true', 'T']:
self.detect_successful_ssh_by_zeek(uid, timestamp, profileid, twid)
else:
self.detect_successful_ssh_by_slips(uid, timestamp, profileid, twid, auth_success)
def detect_incompatible_CN(
self,
daddr,
server_name,
issuer,
profileid,
twid,
uid,
timestamp
):
"""
Detects if a certificate claims that it's CN (common name) belongs
to an org that the domain doesn't belong to
"""
if not issuer:
return False
found_org_in_cn = ''
for org in utils.supported_orgs:
if org not in issuer.lower():
continue
# save the org this domain/ip is claiming to belong to, to use it to set evidence later
found_org_in_cn = org
# check that the domain belongs to that same org
if self.whitelist.is_ip_in_org(daddr, org):
return False
# check that the ip belongs to that same org
if server_name and self.whitelist.is_domain_in_org(server_name, org):
return False
if not found_org_in_cn:
return False
# found one of our supported orgs in the cn but it doesn't belong to any of this org's
# domains or ips
self.helper.set_evidence_incompatible_CN(
found_org_in_cn,
timestamp,
daddr,
profileid,
twid,
uid
)
def check_multiple_ssh_versions(
self,
flow: dict,
twid,
role='SSH::CLIENT'
):
"""
checks if this srcip was detected using a different
ssh client or server versions before
:param role: can be 'SSH::CLIENT' or 'SSH::SERVER' as seen in zeek software.log flows
"""
if role not in flow['software']:
return
profileid = f'profile_{flow["saddr"]}'
# what software was used before for this profile?
# returns a dict with
# software:
# { 'version-major': ,'version-minor': ,'uid': }
cached_used_sw: dict = __database__.get_software_from_profile(
profileid
)
if not cached_used_sw:
# we have no previous software info about this saddr in out db
return False
# these are the versions that this profile once used
cached_ssh_versions = cached_used_sw[flow['software']]
cached_versions = f"{cached_ssh_versions['version-major']}_" \
f"{cached_ssh_versions['version-minor']}"
current_versions = f"{flow['version_major']}_{flow['version_minor']}"
if cached_versions == current_versions:
# they're using the same ssh client version
return False
# get the uid of the cached versions, and the uid of the current used versions
uids = [cached_ssh_versions['uid'], flow['uid']]
self.helper.set_evidence_multiple_ssh_versions(
flow['saddr'], cached_versions, current_versions,
flow['starttime'], twid, uids, role=role
)
return True