├── scripts ├── fast_flux-whitelist.zeek ├── __load__.zeek ├── domain-whitelist.zeek ├── an-dns-connection.zeek ├── an-dns-query_type.zeek ├── an-dns-oversized.zeek ├── main.zeek ├── an-dns-fast_flux.zeek └── an-dns-domain.zeek ├── README.rst ├── zkg.meta ├── LICENSE └── CHANGES.rst /scripts/fast_flux-whitelist.zeek: -------------------------------------------------------------------------------- 1 | module AnomalousDNS; 2 | 3 | redef ff_whitelist = /\.(pool\.ntp\.org)$|^(chat\.freenode\.net)$/; 4 | -------------------------------------------------------------------------------- /scripts/__load__.zeek: -------------------------------------------------------------------------------- 1 | @load ./main 2 | @load ./an-dns-connection 3 | @load packages/domain-tld 4 | @load ./an-dns-domain 5 | @load ./domain-whitelist 6 | #@load ./recursive-whitelist 7 | @load ./an-dns-oversized 8 | @load ./an-dns-query_type 9 | #@load ./an-dns-fast_flux 10 | #@load ./fast_flux-whitelist 11 | -------------------------------------------------------------------------------- /scripts/domain-whitelist.zeek: -------------------------------------------------------------------------------- 1 | ##! Sample domain whitelist. 2 | ##! The domain query limit script is more effective with a sparse list, 3 | ##! longer patterns, and specific servers listed when possible. 4 | 5 | module AnomalousDNS; 6 | redef domain_whitelist = /\.(in-addr\.arpa|ip6\.arpa|ls\.apple\.com|itunes\.apple\.com|push\.apple\.com)$|^(d-[0-9]{20}\.ampproject\.net|itunes\.apple\.com|time-ios\.apple\.com|configuration\.apple\.com|pancake\.apple\.com|xp\.apple\.com|ocsp\.apple\.com|mesu\.apple\.com|apple\.com)$/; 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Anomalous-DNS 2 | ============= 3 | A set of zeek scripts providing a module for tracking and correlating abnormal DNS behavior. Detection of tunneling and C&C through connection duration and volume, request and answer size, DNS request type, and unique queries per domain. Statistical classification of fast flux networks based on A records and ASNs. 4 | 5 | Requirements 6 | ____________ 7 | 8 | domain-tld: https://github.com/sethhall/domain-tld 9 | (automatically installed with package) 10 | 11 | Installation 12 | ____________ 13 | 14 | ``zkg install jbaggs/anomalous-dns`` 15 | 16 | Documentation 17 | _____________ 18 | 19 | Current documentation consists of inline comments. 20 | -------------------------------------------------------------------------------- /zkg.meta: -------------------------------------------------------------------------------- 1 | [package] 2 | description = A module for tracking and correlating abnormal DNS behavior. Detection of tunneling and C&C through connection duration and volume, request and answer size, DNS request type, and unique queries per domain. Statistical classification of fast flux networks based on A records and ASNs. 3 | tags = zeek scripting, dns, domain, notices 4 | script_dir = scripts 5 | depends = 6 | zeek >=5.0.8 7 | https://github.com/sethhall/domain-tld >=1.2.2 8 | 9 | config_files = domain-whitelist.zeek, fast_flux-whitelist.zeek, recursive-whitelist.zeek, scripts/__load__.zeek, scripts/domain-whitelist.zeek, scripts/fast_flux-whitelist.zeek, scripts/recursive-whitelist.zeek 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018-2025, Jeremy Baggs 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /scripts/an-dns-connection.zeek: -------------------------------------------------------------------------------- 1 | ##! AnomalousDNS: connection submodule. 2 | ##! 3 | ##! DNS "connections" are typically short and consist of few packets. 4 | ##! These events firing are a good indication something is not right. 5 | ##! 6 | ##! Author: Jeremy Baggs 7 | 8 | module AnomalousDNS; 9 | 10 | export { 11 | redef enum Notice::Type += { 12 | Conn_Duration, 13 | Conn_Packets, 14 | }; 15 | ## Connection duration limit 16 | const conn_duration_limit = 45secs &redef; 17 | 18 | ## Connection packets limit, measured on origin 19 | const conn_pkts_limit = 12 &redef; 20 | } 21 | 22 | event dns_message(c: connection, is_orig: bool, msg: dns_msg, len: count) 23 | { 24 | if ( c$duration > conn_duration_limit ) 25 | { 26 | event AnomalousDNS::conn_duration_exceeded(c); 27 | if( conn_notice ) 28 | { 29 | NOTICE([$note=Conn_Duration, 30 | $conn=c, 31 | $msg=fmt("Connection duration (%ss) exceeded limit.", c$duration), 32 | $identifier=cat(c$id$orig_h,c$id$resp_h), 33 | $suppress_for=30min 34 | ]); 35 | } 36 | } 37 | 38 | if ( c$orig?$num_pkts && c$orig$num_pkts > conn_pkts_limit ) 39 | { 40 | event AnomalousDNS::conn_packets_exceeded(c); 41 | if ( conn_notice ) 42 | { 43 | NOTICE([$note=Conn_Packets, 44 | $conn=c, 45 | $msg=fmt("Connection packets (%s) exceeded limit.", c$orig$num_pkts), 46 | $identifier=cat(c$id$orig_h,c$id$resp_h), 47 | $suppress_for=30min 48 | ]); 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | Changes in version 2.0.3: 4 | ------------------------- 5 | * replace deprecated lookup_asn function (Removing in Zeek 6.1) 6 | 7 | Changes in version 2.0.0: 8 | ------------------------- 9 | 10 | * Added cluster support. 11 | 12 | In cluster operation, tables for tracking anomalous DNS queries, 13 | and fast-flux candidates, will be spread across proxy nodes. 14 | 15 | Changes in version 1.2.3: 16 | _________________________ 17 | 18 | * Added an-dns-fast_flux for the detection of fast flux DNS requests. 19 | 20 | This script requires the MaxMind GeoLite2-ASN database, and is disabled by default. 21 | Follow the instructions in the module's comments to configure and enable. 22 | 23 | Changes in version 1.2.2: 24 | _________________________ 25 | 26 | * Set default recursive-whitelist pattern to match queries related to QNAME Minimisation. 27 | 28 | `(SEE: RFC 7816 section 3) 29 | `_ 30 | 31 | Changes in version 1.2.0: 32 | _________________________ 33 | 34 | * The "tunneling" event has been removed, as it was seldom used and perhaps overly ambitious. 35 | * Package converted to zeek / zkg. 36 | 37 | Changes in version 1.0.2: 38 | _________________________ 39 | 40 | * Added ability to track recursive resolvers seperately in an-dns-domain.zeek. 41 | * Added tracking of hosts that query a specific domain. 42 | * Added "domain_untracked" constant, for defining hosts to ignore in an-dns-domain module. 43 | * Increased default oversize_response to 544 bytes, and server_oversized_response to 3584 bytes. 44 | * Added PTR (type 12) to server_ignore_qtypes. 45 | * Changed the default suppression time for an-dns-domain notices to 30 min, to be a bit less noisy. 46 | 47 | -------------------------------------------------------------------------------- /scripts/an-dns-query_type.zeek: -------------------------------------------------------------------------------- 1 | ##! AnomalousDNS: query type submodule. 2 | ##! 3 | ##! This submodule is intended for blacklisting query types that should not be 4 | ##! seen in normal DNS communication. There is also an optional whitelist, 5 | ##! that can be used to aid in reporting / discovery. 6 | ##! 7 | ##! Author: Jeremy Baggs 8 | 9 | module AnomalousDNS; 10 | 11 | export { 12 | redef enum Notice::Type += { 13 | Blacklisted_Query_Type, 14 | Unusual_Query_Type, 15 | }; 16 | ## Query type blacklist (type 10 "NULL" is obsolete (rfc883) used in iodine and possibly other tunnels) 17 | const query_type_blacklist = [10,65399] &redef; 18 | 19 | ## Whitelisted query types. If active, all unlisted types will generate Unusual_Query_Type notices / events. 20 | const query_type_whitelist = [1,6,12,16,28,32,33] &redef; 21 | 22 | ## Default is to not whitelist 23 | global query_type_use_whitelist = F &redef; 24 | } 25 | 26 | function trust_anchor_telemetry(c: connection, query: string, qtype: count): bool 27 | # https://kb.isc.org/article/AA-01528/0/BIND-Trust-Anchor-Telemetry-in-BIND-9.9.10-9.10.5-and-9.11.0.html 28 | { 29 | if ( qtype != 10 ) 30 | return F; 31 | 32 | else if ( c$id$orig_h ! in local_dns_servers && c$id$orig_h ! in recursive_resolvers ) 33 | return F; 34 | 35 | else if ( /^_ta(-[0-9a-f]{4})+$/ ! in query ) 36 | return F; 37 | 38 | else 39 | return T; 40 | } 41 | 42 | event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) 43 | { 44 | if (qtype in query_type_blacklist && ! trust_anchor_telemetry(c, query, qtype) ) 45 | { 46 | event AnomalousDNS::blacklisted_qtype(c, query, qtype); 47 | if ( qtype_notice ) 48 | { 49 | NOTICE([$note=Blacklisted_Query_Type, 50 | $conn=c, 51 | $msg=fmt("Query: %s", query), 52 | $sub=fmt("Query type: %s \"%s\"", qtype,DNS::query_types[qtype]), 53 | $identifier=cat(c$id$orig_h,c$id$resp_h), 54 | $suppress_for=30min 55 | ]); 56 | } 57 | } 58 | 59 | if ( query_type_use_whitelist == T && qtype !in query_type_whitelist ) 60 | { 61 | event AnomalousDNS::unusual_qtype(c, query, qtype); 62 | if ( qtype_notice ) 63 | { 64 | NOTICE([$note=Unusual_Query_Type, 65 | $conn=c, 66 | $msg=fmt("Query: %s", query), 67 | $sub=fmt("Query type: %s \"%s\"", qtype,DNS::query_types[qtype]), 68 | $identifier=cat(c$id$orig_h,c$id$resp_h), 69 | $suppress_for=30min 70 | ]); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scripts/an-dns-oversized.zeek: -------------------------------------------------------------------------------- 1 | ##! AnomalousDNS: oversized submodule. 2 | ##! 3 | ##! This submodule measures the size of DNS queries and responses. Tunneled 4 | ##! connections more concerned with moving data than stealth (e.g. iodine) 5 | ##! can set impressive numbers here. 6 | ##! 7 | ##! Author: Jeremy Baggs 8 | ##! 9 | ##! oversized logic based on work by Brian Kellogg 10 | 11 | module AnomalousDNS; 12 | 13 | export { 14 | redef enum Notice::Type += { 15 | Oversized_Query, 16 | Oversized_Answer, 17 | }; 18 | ## Oversize query threshold (characters) 19 | const oversize_query = 90 &redef; 20 | 21 | ## Oversize response threshold (bytes) 22 | const oversize_response = 544 &redef; 23 | 24 | ## Ignore PTR and NB record types in requests 25 | const oversize_ignore_qtypes = [12,32] &redef; 26 | 27 | ## Ignore NetBios port 28 | const oversize_ignore_ports: set[port] = {137/udp, 137/tcp} &redef; 29 | 30 | ##Name patterns to ignore in queries 31 | const oversize_ignore_names = /wpad|isatap|autodiscover|gstatic\.com$|domains\._msdcs|mcafee\.com$/ &redef; 32 | 33 | ## Oversize response threshold for local servers (bytes) 34 | const server_oversize_response = 3584 &redef; 35 | } 36 | 37 | event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) 38 | { 39 | if ( |query| > oversize_query && oversize_ignore_names ! in query 40 | && qtype ! in oversize_ignore_qtypes && c$id$orig_p ! in oversize_ignore_ports ) 41 | { 42 | local domain = DomainTLD::effective_domain(query); 43 | event AnomalousDNS::oversized_query(c,domain,|query|); 44 | if ( os_notice ) 45 | { 46 | NOTICE([$note=Oversized_Query, 47 | $conn=c, 48 | $msg=fmt("Query: %s", query), 49 | $sub=fmt("Query type: %s \"%s\"", qtype,DNS::query_types[qtype]), 50 | $identifier=cat(c$id$orig_h,c$id$resp_h), 51 | $suppress_for=30min 52 | ]); 53 | } 54 | } 55 | } 56 | 57 | event dns_message(c: connection, is_orig: bool, msg: dns_msg, len: count) 58 | { 59 | local o_resp = oversize_response; 60 | local local_server = F; 61 | if ( c$id$orig_h in local_dns_servers || c$id$orig_h in recursive_resolvers ) 62 | { 63 | o_resp = server_oversize_response; 64 | local_server = T; 65 | } 66 | 67 | if ( len > o_resp && ! (local_server && c$dns$qtype in server_ignore_qtypes ) 68 | && c$id$orig_p ! in oversize_ignore_ports && c$id$resp_p ! in oversize_ignore_ports) 69 | { 70 | event AnomalousDNS::oversized_answer(c,len); 71 | if ( os_notice ) 72 | { 73 | NOTICE([$note=Oversized_Answer, 74 | $conn=c, 75 | $msg=fmt("Message length: %sB", len), 76 | $identifier=cat(c$id$orig_h,c$id$resp_h), 77 | $suppress_for=30min 78 | ]); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /scripts/main.zeek: -------------------------------------------------------------------------------- 1 | ##! AnomalousDNS is a module for tracking and correlating abnormal DNS behaviour. 2 | ##! 3 | ##! This file contains global settings. 4 | ##! Logic for individual events is grouped by type in separate files. 5 | ##! 6 | ##! Requires https://github.com/sethhall/domain-tld 7 | ##! 8 | ##! Author Jeremy Baggs 9 | 10 | @load base/frameworks/cluster 11 | @load base/frameworks/notice 12 | 13 | module AnomalousDNS; 14 | 15 | export { 16 | ## Event generated when connection duration limit is exceeded 17 | global conn_duration_exceeded: event(c: connection); 18 | 19 | ## Event generated when connection packet limit is exceeded 20 | global conn_packets_exceeded: event(c: connection); 21 | 22 | ## Event generated when unique query per domain limit is exceeded 23 | global domain_query_exceeded: event(c: connection, domain: string); 24 | 25 | ## Event generated when DNS query size threshold is passed 26 | global oversized_query: event(c: connection, domain: string, qsize: count); 27 | 28 | ## Event generated when DNS answer size threshold is passed 29 | global oversized_answer: event(c: connection, len: count); 30 | 31 | ## Event generated when a blacklisted query type is detected 32 | global blacklisted_qtype: event(c: connection, query: string, qtype: count); 33 | 34 | ## Event generated when a query type not in the whitelist is detected 35 | global unusual_qtype: event(c: connection, query: string, qtype: count); 36 | 37 | ## Event generated when a "fast-flux" domain is detected 38 | global fast_flux_detected: event(c: connection, query: string, score: double); 39 | 40 | ## Event generated when a query is made to a previously detected fast-flux domain 41 | global fast_flux_query: event(c:connection, query: string); 42 | 43 | ## Generate connection notices 44 | global conn_notice: bool = T &redef; 45 | 46 | ## Generate unique domain query notices 47 | global dquery_notice: bool = T &redef; 48 | 49 | ## Generate oversized notices 50 | global os_notice: bool = T &redef; 51 | 52 | ## Generate query type notices 53 | global qtype_notice: bool = T &redef; 54 | 55 | ## Generate fast-flux notices 56 | global ff_notice: bool = T &redef; 57 | 58 | ## Local servers that receive exceptions for DNSSEC in Oversized_Answer and Domain_Query_Limit, 59 | ## and query type 10 (if Trust Anchor Telemetry). 60 | const local_dns_servers: set[addr] &redef; 61 | 62 | ## Recursive resolvers receive the same treatment as local dns servers, but are tracked seperately 63 | ## in an-dns-domain.zeek. This allows for a higher query limit than forwarding resolvers, 64 | ## and / or additional whitelisting. 65 | const recursive_resolvers: set[addr] &redef; 66 | 67 | ## Hosts not to track in an-dns-domain.zeek. 68 | const domain_untracked: set[addr] &redef; 69 | 70 | ## DNSSEC query types, and PTR 71 | const server_ignore_qtypes = [12,43,48] &redef; 72 | } 73 | -------------------------------------------------------------------------------- /scripts/an-dns-fast_flux.zeek: -------------------------------------------------------------------------------- 1 | ##! Original detection code: Seth Hall 2 | ##! Updated by: Brian Kellogg for Bro 2.3 - 12/9/2014 (removed log and added notices) 3 | ##! Updated for zeek and adapted for AnomalousDNS framework 20200903 Jeremy Baggs 4 | ##! 5 | ##! Description: Detect Fast Flux DNS requests. 6 | ##! 7 | ##! This script requires the MaxMind GeoLite2-ASN database. 8 | ##! Follow the instructions in: https://docs.zeek.org/en/lts/frameworks/geoip.html 9 | ##! replacing "GeoLite2-City" with "GeoLite2-ASN". 10 | ##! 11 | ##! Uncomment "@load ./an-dns-fast_flux" and "@load ./fast_flux-whitelist" 12 | ##! in "__load__.zeek" to enable. 13 | 14 | module AnomalousDNS; 15 | 16 | export { 17 | redef enum Notice::Type += { 18 | Fast_Flux_Detected, 19 | Fast_Flux_Query, 20 | }; 21 | 22 | type fluxer_candidate: record { 23 | A_hosts: set[addr]; # set of all hosts returned in A replies 24 | ASNs: set[count]; # set of ASNs from A lookups 25 | }; 26 | 27 | type query_info: record { 28 | query: string; # the candidate query 29 | candidate: fluxer_candidate; 30 | }; 31 | 32 | ## TTL value over which we ignore DNS responses 33 | const TTL_threshold = 30min &redef; 34 | 35 | ## Tracked fluxer candidates. 36 | ## 37 | ## In cluster operation, this table is uniformly distributed across 38 | ## proxy nodes. 39 | global detect_fast_fluxers: table[string] of fluxer_candidate &write_expire=TTL_threshold + 1min; 40 | 41 | ## The set of detected fluxers. 42 | ## 43 | ## In cluster operation, this set is uniformly distributed across 44 | ## proxy nodes. 45 | global fast_fluxers: set[string] &write_expire=1day; 46 | 47 | ## Constants for flux score ("fluxiness") calculation 48 | ## from "Measuring and Detecting Fast-Flux Service Networks" 49 | ## See: http://user.informatik.uni-goettingen.de/~krieck/docs/2008-ndss.pdf 50 | const flux_host_count_weight = 1.32 &redef; 51 | const flux_ASN_count_weight = 18.54 &redef; 52 | const flux_threshold = 142.38 &redef; 53 | 54 | ## asn_disparity value below which fluxer_candidate records are removed (Default disabled) 55 | ## If enabled, care should be taken to not make this value too large, as it could 56 | ## allow evasion of detection through grouping DNS replies by ASN. 57 | const ASN_disparity_floor_enable = F &redef; 58 | const ASN_disparity_floor = 0.001 &redef; 59 | 60 | } 61 | 62 | const ff_whitelist: pattern = /PATTERN_LOADED_FROM_FILE/ &redef; 63 | 64 | event track_fluxer(query: string) 65 | { 66 | add fast_fluxers[query]; 67 | } 68 | 69 | function check_dns_fluxiness(c: connection, ans: dns_answer, fluxer: fluxer_candidate): bool 70 | { 71 | # Track the candidate so long as it remains a candidate 72 | local tracking = T; 73 | # +0.0 is to "cast" values to doubles 74 | local ASN_disparity = (|fluxer$ASNs|+0.0) / (|fluxer$A_hosts|+0.0); 75 | local score = ASN_disparity * ((flux_host_count_weight * |fluxer$A_hosts|) + (flux_ASN_count_weight * |fluxer$ASNs|)); 76 | if ( score > flux_threshold ) 77 | { 78 | event AnomalousDNS::fast_flux_detected(c, ans$query, score); 79 | Cluster::publish_hrw(Cluster::proxy_pool, ans$query, track_fluxer, ans$query); 80 | event track_fluxer(ans$query); 81 | # Candidate promoted to confirmed fluxer 82 | tracking = F; 83 | if ( ff_notice ) 84 | { 85 | NOTICE([$note=Fast_Flux_Detected, 86 | $msg=fmt("Flux score for %s is %f (%d hosts in %d distinct ASNs %f asns/ips)", 87 | ans$query, score, |fluxer$A_hosts|, |fluxer$ASNs|, ASN_disparity), 88 | $sub=fmt("hosts: %s ASNs: %s TTL:%s",cat(fluxer$A_hosts),cat(fluxer$ASNs), ans$TTL), 89 | $conn=c, $suppress_for=30min, $identifier=cat(ans$query,c$id$orig_h)]); 90 | } 91 | } 92 | 93 | else if ( ASN_disparity_floor_enable == T && ASN_disparity < ASN_disparity_floor ) 94 | # Candidate is not looking promising 95 | tracking = F; 96 | 97 | return tracking; 98 | } 99 | 100 | event Cluster::node_up(name: string, id: string) 101 | { 102 | if ( Cluster::local_node_type() != Cluster::WORKER ) 103 | return; 104 | 105 | # Drop local suppression cache on workers to force HRW key repartitioning. 106 | AnomalousDNS::detect_fast_fluxers = table(); 107 | AnomalousDNS::fast_fluxers = set(); 108 | } 109 | 110 | event Cluster::node_down(name: string, id: string) 111 | { 112 | if ( Cluster::local_node_type() != Cluster::WORKER ) 113 | return; 114 | 115 | # Drop local suppression cache on workers to force HRW key repartitioning. 116 | AnomalousDNS::detect_fast_fluxers = table(); 117 | AnomalousDNS::fast_fluxers = set(); 118 | } 119 | 120 | event track_ff_candidate(info: query_info) 121 | { 122 | detect_fast_fluxers[info$query] = info$candidate; 123 | } 124 | 125 | event remove_ff_candidate(query: string) 126 | { 127 | delete detect_fast_fluxers[query]; 128 | } 129 | 130 | event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) 131 | { 132 | if ( ans$TTL > TTL_threshold ) 133 | return; 134 | 135 | # Don't keep any extra state about false positives 136 | if ( ff_whitelist in ans$query ) 137 | return; 138 | 139 | local candidate: fluxer_candidate; 140 | local check_flux = F; 141 | if ( ans$query in detect_fast_fluxers ) 142 | { 143 | candidate = detect_fast_fluxers[ans$query]; 144 | check_flux = T; 145 | } 146 | 147 | add candidate$A_hosts[a]; 148 | local autonomous_system = lookup_autonomous_system(a); 149 | if ( autonomous_system?$number ) 150 | add candidate$ASNs[autonomous_system$number]; 151 | if ( check_flux ) 152 | { 153 | local tracking = check_dns_fluxiness(c, ans, candidate); 154 | if ( tracking ) 155 | { 156 | Cluster::publish_hrw(Cluster::proxy_pool, ans$query, track_ff_candidate, query_info($query = ans$query, $candidate = candidate)); 157 | event track_ff_candidate(query_info($query = ans$query, $candidate = candidate)); 158 | } 159 | 160 | else 161 | { 162 | Cluster::publish_hrw(Cluster::proxy_pool, ans$query, remove_ff_candidate, ans$query); 163 | event remove_ff_candidate(ans$query); 164 | } 165 | } 166 | 167 | else 168 | { 169 | Cluster::publish_hrw(Cluster::proxy_pool, ans$query, track_ff_candidate, query_info($query = ans$query, $candidate = candidate)); 170 | event track_ff_candidate(query_info($query = ans$query, $candidate = candidate)); 171 | } 172 | } 173 | 174 | event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) 175 | { 176 | if (query in fast_fluxers) 177 | { 178 | event AnomalousDNS::fast_flux_query(c, query); 179 | if ( ff_notice ) 180 | { 181 | NOTICE([$note=Fast_Flux_Query, 182 | $msg=fmt("Query for previously detected fast flux DNS record: %s from: %s", query,cat(c$id$orig_h)), 183 | $conn=c, $suppress_for=30min, $identifier=cat(query,c$id$orig_h)]); 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /scripts/an-dns-domain.zeek: -------------------------------------------------------------------------------- 1 | ##! AnomalousDNS: domain submodule. 2 | ##! 3 | ##! This submodule tracks unique DNS queries to domains. It is intended to help 4 | ##! identify low throughput exfiltration and DNS command and control traffic. 5 | ##! It is important to note that it is a domain centered metric. Attributing 6 | ##! the traffic to a specific endpoint requires further analysis. 7 | ##! 8 | ##! Care should be taken in tuning this submodule. 9 | ##! Various content delivery networks, and possibly your own network, 10 | ##! will set it off until properly tuned. A well crafted whitelist is key. 11 | ##! 12 | ##! Author: Jeremy Baggs 13 | 14 | module AnomalousDNS; 15 | 16 | export { 17 | redef enum Notice::Type += { 18 | Domain_Query_Limit, 19 | }; 20 | 21 | ## Threshold for unique queries to a domain per query period 22 | const domain_query_limit = 8 &redef; 23 | 24 | ## Domain query threshold for recursive resolvers 25 | const recursive_domain_query_limit = 12 &redef; 26 | 27 | ## Time until queries expire from tracking. 28 | const query_period = 60min; 29 | 30 | ## Data structures for tracking unique queries to domains 31 | ## In cluster operation, these tables are distributed uniformly across 32 | ## proxy nodes. 33 | global domain_query: table[string] of set[string] &read_expire=query_period+1min; 34 | global domain_query_hosts: table[string] of set[addr] &read_expire=query_period+1min; 35 | 36 | global recursive_domain_query: table[string] of set[string] &read_expire=query_period+1min; 37 | global recursive_domain_query_hosts: table[string] of set[addr] &read_expire=query_period+1min; 38 | 39 | } 40 | 41 | # Record type containing the fields used for query tracking 42 | type QueryInfo: record { 43 | # The query to track 44 | query: string; 45 | # ETLD of the query 46 | domain: string &optional; 47 | # The host that made the query 48 | host: addr; 49 | }; 50 | 51 | # Whitelist from domain-whitelist.zeek replaces the pattern below 52 | # when set to load in __load__.zeek 53 | const domain_whitelist: pattern = /\.(in-addr\.arpa|ip6\.arpa)$/ &redef; 54 | 55 | # Additional whitelisting for recursive resolvers. 56 | # Whitelist from recursive-whitelist.zeek replaces the pattern below 57 | # when set to load in __load__.zeek 58 | # 59 | # The default pattern below is for exempting queries of the form: "_.foo.bar", 60 | # for nameservers that are implementing QNAME minimisation. 61 | # See: https://tools.ietf.org/html/rfc7816.html#section-3 62 | const recursive_whitelist: pattern = /^(_\..*)$/ &redef; 63 | 64 | function notify(c: connection, domain: string, queries: count, hosts: set[addr]) 65 | { 66 | local hostlist = "hosts:"; 67 | for (h in hosts) 68 | hostlist = cat(hostlist," ",h); 69 | NOTICE([$note=Domain_Query_Limit, 70 | $conn=c, 71 | $msg=fmt("Unique queries (%sq, < %s) to domain: %s exceeded threshold.", 72 | queries,cat(query_period),domain), 73 | $sub=hostlist, 74 | $identifier= cat(domain,c$id$orig_h), 75 | $suppress_for=30min 76 | ]); 77 | } 78 | 79 | event add_recursive_domain_query(info: QueryInfo) 80 | { 81 | if ( info$domain ! in recursive_domain_query ) 82 | { 83 | recursive_domain_query[info$domain]=set(info$query) &write_expire=query_period; 84 | recursive_domain_query_hosts[info$domain]=set(info$host) &write_expire=query_period; 85 | } 86 | 87 | else 88 | { 89 | add recursive_domain_query[info$domain][info$query]; 90 | add recursive_domain_query_hosts[info$domain][info$host]; 91 | } 92 | } 93 | 94 | event add_domain_query(info: QueryInfo) 95 | { 96 | if ( info$domain ! in domain_query ) 97 | { 98 | domain_query[info$domain]=set(info$query) &write_expire=query_period; 99 | domain_query_hosts[info$domain]=set(info$host) &write_expire=query_period; 100 | } 101 | 102 | else 103 | { 104 | add domain_query[info$domain][info$query]; 105 | add domain_query_hosts[info$domain][info$host]; 106 | } 107 | } 108 | 109 | function track_query(c: connection, query: string) 110 | { 111 | local info = QueryInfo($query = query, $host = c$id$orig_h); 112 | local hosts: set[addr] &redef; 113 | local queries: set[string] &redef; 114 | info$domain = DomainTLD::effective_domain(query); 115 | if ( info$host in recursive_resolvers ) 116 | { 117 | if ( info$domain in recursive_domain_query ) 118 | { 119 | queries = recursive_domain_query[info$domain]; 120 | hosts = recursive_domain_query_hosts[info$domain]; 121 | add hosts[info$host]; 122 | add queries[query]; 123 | if ( |queries| > recursive_domain_query_limit ) 124 | { 125 | event domain_query_exceeded(c, info$domain); 126 | if ( dquery_notice ) 127 | notify(c, info$domain, |queries|, hosts); 128 | } 129 | } 130 | Cluster::publish_hrw(Cluster::proxy_pool, info$query, add_recursive_domain_query, info); 131 | event add_recursive_domain_query(info); 132 | } 133 | 134 | else 135 | { 136 | if ( info$domain in domain_query ) 137 | { 138 | queries = domain_query[info$domain]; 139 | hosts = domain_query_hosts[info$domain]; 140 | add hosts[info$host]; 141 | add queries[query]; 142 | if ( |queries| > domain_query_limit ) 143 | { 144 | event domain_query_exceeded(c, info$domain); 145 | if ( dquery_notice ) 146 | notify(c, info$domain, |queries|, hosts); 147 | } 148 | } 149 | Cluster::publish_hrw(Cluster::proxy_pool, info$query, add_domain_query, info); 150 | event add_domain_query(info); 151 | } 152 | } 153 | 154 | event Cluster::node_up(name: string, id: string) 155 | { 156 | if ( Cluster::local_node_type() != Cluster::WORKER ) 157 | return; 158 | 159 | # Drop local suppression cache on workers to force HRW key repartitioning. 160 | AnomalousDNS::domain_query = table(); 161 | AnomalousDNS::domain_query_hosts = table(); 162 | AnomalousDNS::recursive_domain_query = table(); 163 | AnomalousDNS::recursive_domain_query_hosts = table(); 164 | } 165 | 166 | event Cluster::node_down(name: string, id: string) 167 | { 168 | if ( Cluster::local_node_type() != Cluster::WORKER ) 169 | return; 170 | 171 | # Drop local suppression cache on workers to force HRW key repartitioning. 172 | AnomalousDNS::domain_query = table(); 173 | AnomalousDNS::domain_query_hosts = table(); 174 | AnomalousDNS::recursive_domain_query = table(); 175 | AnomalousDNS::recursive_domain_query_hosts = table(); 176 | } 177 | 178 | event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) 179 | { 180 | if ( c$id$orig_h in recursive_resolvers ) 181 | { 182 | if ( qtype ! in server_ignore_qtypes && recursive_whitelist ! in query && domain_whitelist ! in query ) 183 | track_query(c, query); 184 | } 185 | 186 | else if ( c$id$orig_h in local_dns_servers ) 187 | { 188 | if ( qtype ! in server_ignore_qtypes && domain_whitelist ! in query ) 189 | track_query(c, query); 190 | } 191 | 192 | else if ( c$id$orig_h ! in domain_untracked && domain_whitelist ! in query ) 193 | track_query(c, query); 194 | } 195 | --------------------------------------------------------------------------------