# An initial cut at a simple detector for DNS anomalies which would # result due to a cache poisoning attempt. # Author: Nicholas Weaver # Copyright: ICSI, same liscence as the rest of Bro # The first detection is "Invalid ID": On an established DNS # "connection", is a transaction ID returned from the server which # does not correspond to any transaction ID sent by the client. MOST # DNS poisoning attempts will generate such packets, unless they can # perfectly predict the next transaction ID from the client. # Unfortunatly, on a day's trace of ICSI, there do seem to be a # nontrivial number of DNS servers which are triggering this notice. # Thus for this version, we use a TRW count which only attempts to # detect this alert when above a threshold. This is designed to # detect FAILED attempts at cache poisoning. # The second detection is "DNS Response Changed". For this # transaction ID, are there multiple responses with a different number # of answers? Multiple responses with the same # of answers but # different values? # The goal of this anomaly is to detect SUCCESFUL cache poisoning # attempts. This anomaly MUST appear if the attacker can't squelch # the legitimate response. We can't decide which response is valid, # however. # The only real complication is that each answer field generates a # separate event. Thus for each transaction ID, we need to keep track # of the individual responses and iterate over them to find if the # value has been seen or not, and if its a new value, whether it # represents a changed value or just another portion of the stated # response. @load notice module DNS; const dns_weirds_log = open_log_file("dns_weirds") &redef; const dns_changes_log = open_log_file("dns_changes") &redef; # Make sure the DPD analyzer is loaded so you don't need to # load the full DNS policy, but can run it on its own. # Taken/duplicate from dns.bro, so this analyzer doesn't require # dns.bro to be loaded @ifndef ( dns_ports ) redef capture_filters += { ["dns"] = "port 53", ["netbios-ns"] = "udp port 137" }; global dns_ports = { 53/udp, 53/tcp, 137/udp } &redef; redef dpd_config += { [ANALYZER_DNS] = [$ports = dns_ports] }; global dns_udp_ports = { 53/udp, 137/udp } &redef; global dns_tcp_ports = { 53/tcp } &redef; redef dpd_config += { [ANALYZER_DNS_UDP_BINPAC] = [$ports = dns_udp_ports] }; redef dpd_config += { [ANALYZER_DNS_TCP_BINPAC] = [$ports = dns_tcp_ports] }; @endif # need to examine the auth and addl fields! redef dns_skip_all_addl = F; redef dns_skip_all_auth = F; redef enum Notice += { # A DNS response where we never see the request # Simply an unsolicited response is insuffiecnt to # trigger this alert, because both a monitor drop of # the request and broken DNS servers which send # unsolicited responses can trigger this alert. # Thus, per Vern's suggetion, the alert is validated # by performing a lookup and insuring that the unsolicited # response is correct. Thus both monitor drops and # buggy unsolicited correct responses should be ignored # However, there is still one false positive observed in ~1 # week worth of ICSI traffic, where the DNS server sends an # unsolicited response (which may just be a monitor drop, but # I see 3 incidents of it). This DNS server is odd because # when a name lookup is performed, sometimes it gives 3 addresses # as answers, and sometimes one. Thus if the unsolicited # response has 3 answers, but the validating lookup only # has one answer, the test fails and it generates an alert. # If there are multiple ones of these for the same requested # name, however, it is highly suspicious. In a weeks worth # of traffic at ICSI, I only see one host that gives this # alert. DNSInvalidResponseID, # A DNS response where we never see the request. # This response is not validated, so it could be a # false positive due to a drop, or a false-positive # due to a bad server which sends unsolicited responses. # Currently, it is moderately rare, a few per day on # a trace of ICSI, but it is not unheard of. Where # the monitor is experiencing packet drops (eg, Seth's # installation), the rate can be considerably higher. # Since currently known cache poisoning attacks consist # of dozens or more of attempted packets, this is probably # a false positive unless you get a LOT of alerts for a # particular name server. DNSInvalidResponseID_ProbableFP, # A DNS response for a transaction ID where we already # received another response, and the responses are different. # This should appear only if a DNS cache poisoning attempt is # succesful, but should detect ANY cache poisoning success as # long as the original request and authoritative respones are # not able to be blocked by the attacker. # In a week+ of ICSI data, we saw NO alerts for this, so # any positives are probably of significant interest. DNSChangedResponse, }; # To make comparing answers easier, we just include ALL possible # answer types type dns_poison_answer : record { query: string; a: addr &default=0.0.0.0; cname: string &default=""; ns: string &default=""; ptr: string &default=""; mx: string &default=""; soa: string &default=""; }; type dns_poison_info : record { # indexed by transaction ID. The read-expire is # because transactions are nonsense if too old, # so they should be treated as invalid anyway id_sent: table[count] of bool &read_expire=5min; # Has there been a query received for this transaction ID query_recv: table[count] of bool; # and, if so, how many answers. query_recv_cnt: table[count] of count; # and what is the DNS msg query_recv_msg: table[count] of dns_msg; # Has the original been printed printed_duplicate: table[count] of bool; # And the answers themselves query_value: table[count] of table[count] of dns_poison_answer; # Has an unsolicited reply been printed printed_unsolicited: table[count] of bool &read_expire=5min; # Is a duplicate is_duplicate: table[count] of bool; # Count of inserts, eliminates an iteration insert_at: table[count] of count; }; function print_dns_changed_record(c:connection, query: dns_poison_answer, id:count, report:string){ local message:string; if(query$a != 0.0.0.0){ message = fmt("A Record %s -> %s", query$query, query$a); } else if (query$cname != ""){ message = fmt ("CNAME Record %s -> %s", query$query, query$cname); } else if (query$ns != ""){ message = fmt ("NS Record %s -> %s", query$query, query$ns); } else if (query$ptr != ""){ message = fmt ("PTR Record %s -> %s", query$query, query$ptr); } else if (query$mx != ""){ message = fmt ("MX Record %s -> %s", query$query, query$mx); } else if (query$soa != ""){ message = fmt ("SOA Record %s -> mname: %s", query$query, query$soa); } print dns_changes_log, fmt("%.6f %s/%x %s %s", network_time(), c$id$resp_h, id, report, message); } function print_dns_changes(c:connection, msg : dns_msg, poison_record: dns_poison_info){ if((msg$id in poison_record$printed_duplicate) && (poison_record$printed_duplicate[msg$id])){ return; } poison_record$printed_duplicate[msg$id] = T; print dns_changes_log, fmt("%.6f %s/%x Changed response seen for ID %4x from DNS Server %s", network_time(), c$id$resp_h, msg$id, msg$id, c$id$resp_h); local orig_msg: dns_msg = poison_record$query_recv_msg[msg$id]; print dns_changes_log, fmt("%.6f %s/%x Original responses: Answer %d, Auth %d, Addl %d", network_time(), c$id$resp_h, msg$id, orig_msg$num_answers, orig_msg$num_auth, orig_msg$num_addl); print dns_changes_log, fmt("%.6f %s/%x Changed responses: Answer %d, Auth %d, Addl %d", network_time(), c$id$resp_h, msg$id, msg$num_answers, msg$num_auth, msg$num_addl); local i:count = 0; for(i in poison_record$query_value[msg$id]) { print_dns_changed_record(c, poison_record$query_value[msg$id][i], msg$id, "Original:"); } } # Indexed by sip, sport, dip, dport global dns_poison_tracking: table[addr, port, addr, port] of dns_poison_info &read_expire=1min; # Since we track by SIP/SPORT/DIP/DPORT rather than SIP/DIP, we won't # catch poison attempts that get the port wrong. This is acceptable, # since proper DNS SRC port randomization is reportedly succesful at # preventing all known cache poisoning techniques, and I'm not # interested in detecting failed attacks when the attack probably # can't work at all. # Read-expire won't add false-positives, because we ignore responses when # there is no request at all. # Read-expire will add a false negative for the Invalid ID warning if # you have multiple low rate attempts on the same pair, but the cache # is reinitialized when the next request comes in anyway. # Read-expire will NOT add a false negative for the Changed Respones # warning, as if the legitimate response is squelched for >1 minute, # something is seriously wrong anyway that we wouldn't detect. # To supress unsolicited queries, we will double-check the results So # we first need to record what the unsolicited query is... event dns_query_reply(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) { # Can only cache poison on UDP, so we only bother with UDP if(get_port_transport_proto(c$id$orig_p) != udp){ return; } local poison_record: dns_poison_info; if(!([c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p] in dns_poison_tracking)){ print dns_weirds_log, fmt("%6f Unsolicited DNS reply %04x for %s from server %s. Since there has been no previous DNS traffic on this 4-tuple, we are ignoring it", network_time(), msg$id, query, c$id$resp_h); return; } poison_record = dns_poison_tracking[c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p]; if(!(msg$id in poison_record$id_sent)){ print dns_weirds_log, fmt("%.6f Unsolicited DNS reply %04x for %s from server %s", network_time(), msg$id, query, c$id$resp_h); return; } if((msg$id in poison_record$query_recv) && poison_record$query_recv[msg$id]){ poison_record$is_duplicate[msg$id] = T; print dns_weirds_log, fmt("%.6f Duplicate DNS reply %x for %s from server %s", network_time(), msg$id, query, c$id$resp_h); } else { poison_record$is_duplicate[msg$id] = F; poison_record$insert_at[msg$id] = 0; poison_record$query_recv_msg[msg$id] = msg; poison_record$printed_duplicate[msg$id] = F; } if((msg$id in poison_record$query_recv) && poison_record$query_recv[msg$id] && (poison_record$query_recv_cnt[msg$id] != (msg$num_answers + msg$num_auth + msg$num_addl))){ NOTICE([$note=DNSChangedResponse, $msg=fmt("DNS Response %4x from server %s for query %s changed (different number of answers)", msg$id, c$id$resp_h, query), $conn=c]); print_dns_changes(c, msg, poison_record); } poison_record$query_recv[msg$id] = T; poison_record$query_recv_cnt[msg$id] = msg$num_answers + msg$num_auth + msg$num_addl; } # Record each transaction ID. event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) { # Can only cache poison on UDP, so we only bother with UDP if(get_port_transport_proto(c$id$orig_p) != udp){ return; } local poison_record : dns_poison_info; if([c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p] in dns_poison_tracking){ poison_record = dns_poison_tracking[c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p] ; } else { dns_poison_tracking[c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p] = poison_record; } poison_record$id_sent[msg$id] = T; poison_record$query_recv[msg$id] = F; local empty_table: table[count] of dns_poison_answer; poison_record$query_value[msg$id] = empty_table; } # Returns true IFF this is an unsolicited reply function dns_poison_check(c: connection, msg: dns_msg, ans: dns_answer, value : dns_poison_answer):bool { # Can only cache poison on UDP, so we only bother with UDP if(get_port_transport_proto(c$id$orig_p) != udp){ return F; } if(!([c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p] in dns_poison_tracking)){ # Can't find a DNS request that corresponds to # this response, so just ignore it. # An interesting side effect is that IF the # DNS client (caching server) implements src port # randomization, we won't detect attempts to poison # it. This could be changed by having the # index be just the host-pair rather than the full # 4-tuple. return F; } local poison_record = dns_poison_tracking[c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p]; # This is an UNSOLICITED reply, something is wrong here if(!(msg$id in poison_record$id_sent)){ return T; } # needed because for failures in some cases, # the DNS-reply event doesn't get called but the # records may be still processed if(!(msg$id in poison_record$is_duplicate)){ poison_record$is_duplicate[msg$id] = F; poison_record$insert_at[msg$id] = 0; poison_record$query_recv[msg$id] = T; poison_record$query_recv_cnt[msg$id] = msg$num_answers + msg$num_auth + msg$num_addl; poison_record$query_recv_msg[msg$id] = msg; poison_record$printed_duplicate[msg$id] = F; } if(poison_record$is_duplicate[msg$id]){ local i:count = 0; for(i in poison_record$query_value[msg$id]) { if((poison_record$query_value[msg$id][i]$query == value$query) && (poison_record$query_value[msg$id][i]$a == value$a) && (poison_record$query_value[msg$id][i]$cname == value$cname) && (poison_record$query_value[msg$id][i]$ns == value$ns) && (poison_record$query_value[msg$id][i]$ptr == value$ptr) && (poison_record$query_value[msg$id][i]$mx == value$mx) && (poison_record$query_value[msg$id][i]$soa == value$soa)){ return F; } } NOTICE([$note=DNSChangedResponse, $msg=fmt("DNS Response %4x from server %s for query %s changed", msg$id, c$id$resp_h, ans$query), $conn=c]); print_dns_changes(c, msg, poison_record); print_dns_changed_record(c, value, msg$id, "Changed/Additional:"); } else { poison_record$query_value[msg$id][poison_record$insert_at[msg$id]] = value; poison_record$insert_at[msg$id] += 1; } return F; } event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) { if(dns_poison_check(c, msg, ans, [$a = a, $query = ans$query])){ local query:string = ans$query; NOTICE([$note=DNSInvalidResponseID_ProbableFP, $msg=fmt("Unsolicited DNS A/AAAA Reply %4x (reply type: %x) from %s for %s of %s", msg$id, ans$answer_type, c$id$resp_h, query, a), $conn=c]); # Currently there is a heisenbug which causes the when statement # to rarely crash. Seth reported this. Temporarily changing the notification # mechanism to be notify always without verification # when(local local_addr = lookup_hostname(query)) { # local check_addr:addr; # for(check_addr in local_addr){ # if(check_addr == a){ # return; # } # } # if(ans$answer_type == 3){ # # AAAA records (IPv6) may have a false positive # # because the reporting from the name lookup is different # # from the DNS analyzer. # NOTICE([$note=DNSInvalidResponseID_ProbableFP, # $msg=fmt("Unsolicited DNS AAAA Reply %4x (%x) from %s for %s of %s", # msg$id, ans$answer_type, c$id$resp_h, query, a), # $conn=c]); # } else { # NOTICE([$note=DNSInvalidResponseID, # $msg=fmt("Unsolicited and Incorrect DNS A Reply %4x (%x) from %s for %s of %s", # msg$id, ans$answer_type, c$id$resp_h, query, a), # $conn=c]); # } # # } } } # Actually, realize we don't need to alert on this, as this is specifying authoritative # nameservers, not their actual address, if unsolicited, but the binding to an address # will take place in the dns_A_reply. event dns_NS_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) { # still need to do the checking for changes however. if(dns_poison_check(c, msg, ans, [$ns = name, $query = ans$query])){ } } event dns_CNAME_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) { if(dns_poison_check(c, msg, ans, [$cname = name, $query = ans$query])){ NOTICE([$note=DNSInvalidResponseID_ProbableFP, $msg=fmt("Unsolicited DNS CNAME Reply %4x from %s for %s of %s", msg$id, c$id$resp_h, ans$query, name), $conn=c]); } } event dns_PTR_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) { if(dns_poison_check(c, msg, ans, [$ptr = name, $query = ans$query])){ NOTICE([$note=DNSInvalidResponseID_ProbableFP, $msg=fmt("Unsolicited DNS PTR Reply %4x from %s for %s of %s", msg$id, c$id$resp_h, ans$query, name), $conn=c]); } } event dns_MX_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string, preference: count) { if(dns_poison_check(c, msg, ans, [$ptr = name, $query = ans$query])){ NOTICE([$note=DNSInvalidResponseID_ProbableFP, $msg=fmt("Unsolicited DNS MX Reply %4x from %s for %s of %s", msg$id, c$id$resp_h, ans$query, name), $conn=c]); } } event dns_SOA_reply(c: connection, msg: dns_msg, ans: dns_answer, soa: dns_soa) { if(dns_poison_check(c, msg, ans, [$soa = soa$mname, $query = ans$query])){ NOTICE([$note=DNSInvalidResponseID_ProbableFP, $msg=fmt("Unsolicited DNS SOA Reply %4x from %s for %s (mname = %s, rname = %s)", msg$id, c$id$resp_h, ans$query, soa$mname, soa$rname), $conn=c]); } } # Extended DNS, don't know what to do, so ignore event dns_EDNS(c: connection, msg: dns_msg, ans: dns_answer) { }