zeek/scripts/base/protocols/dns/main.zeek
2025-09-30 12:30:52 -07:00

668 lines
19 KiB
Text

##! Base DNS analysis script which tracks and logs DNS queries along with
##! their responses.
@load base/utils/queue
@load ./consts
@load base/protocols/conn/removal-hooks
module DNS;
export {
## The DNS logging stream identifier.
redef enum Log::ID += { LOG };
## A default logging policy hook for the stream.
global log_policy: Log::PolicyHook;
## The record type which contains the column fields of the DNS log.
type Info: record {
## The earliest time at which a DNS protocol message over the
## associated connection is observed.
ts: time &log;
## A unique identifier of the connection over which DNS messages
## are being transferred.
uid: string &log;
## The connection's 4-tuple of endpoint addresses/ports.
id: conn_id &log;
## The transport layer protocol of the connection.
proto: transport_proto &log;
## A 16-bit identifier assigned by the program that generated
## the DNS query. Also used in responses to match up replies to
## outstanding queries.
trans_id: count &log &optional;
## Round trip time for the query and response. This indicates
## the delay between when the request was seen until the
## answer started.
rtt: interval &log &optional;
## The domain name that is the subject of the DNS query.
query: string &log &optional;
## The QCLASS value specifying the class of the query.
qclass: count &log &optional;
## A descriptive name for the class of the query.
qclass_name: string &log &optional;
## A QTYPE value specifying the type of the query.
qtype: count &log &optional;
## A descriptive name for the type of the query.
qtype_name: string &log &optional;
## The response code value in DNS response messages.
rcode: count &log &optional;
## A descriptive name for the response code value.
rcode_name: string &log &optional;
## The Authoritative Answer bit for response messages specifies
## that the responding name server is an authority for the
## domain name in the question section.
AA: bool &log &default=F;
## The Truncation bit specifies that the message was truncated.
TC: bool &log &default=F;
## The Recursion Desired bit in a request message indicates that
## the client wants recursive service for this query.
RD: bool &log &default=F;
## The Recursion Available bit in a response message indicates
## that the name server supports recursive queries.
RA: bool &log &default=F;
## A reserved field that is zero in queries and responses unless
## using DNSSEC. This field represents the 3-bit Z field using
## the specification from RFC 1035.
Z: count &log &default=0;
## The set of resource descriptions in the query answer.
answers: vector of string &log &optional;
## The caching intervals of the associated RRs described by the
## *answers* field.
TTLs: vector of interval &log &optional;
## The DNS query was rejected by the server.
rejected: bool &log &default=F;
## The opcode value of the DNS request/response.
opcode: count &log &optional;
## A descriptive string for the opcode.
opcode_name: string &log &optional;
## The total number of resource records in a reply message's
## answer section.
total_answers: count &optional;
## The total number of resource records in a reply message's
## answer, authority, and additional sections.
total_replies: count &optional;
## Whether the full DNS query has been seen.
saw_query: bool &default=F;
## Whether the full DNS reply has been seen.
saw_reply: bool &default=F;
};
## An event that can be handled to access the :zeek:type:`DNS::Info`
## record as it is sent to the logging framework.
global log_dns: event(rec: Info);
## This is called by the specific dns_*_reply events with a "reply"
## which may not represent the full data available from the resource
## record, but it's generally considered a summarization of the
## responses.
##
## c: The connection record for which to fill in DNS reply data.
##
## msg: The DNS message header information for the response.
##
## ans: The general information of a RR response.
##
## reply: The specific response information according to RR type/class.
global do_reply: hook(c: connection, msg: dns_msg, ans: dns_answer, reply: string);
## A hook that is called whenever a session is being set.
## This can be used if additional initialization logic needs to happen
## when creating a new session value.
##
## c: The connection involved in the new session.
##
## msg: The DNS message header information.
##
## is_query: Indicator for if this is being called for a query or a response.
global set_session: hook(c: connection, msg: dns_msg, is_query: bool);
## Yields a queue of :zeek:see:`DNS::Info` objects for a given
## DNS message query/transaction ID.
type PendingMessages: table[count] of Queue::Queue;
## Give up trying to match pending DNS queries or replies for a given
## query/transaction ID once this number of unmatched queries or replies
## is reached (this shouldn't happen unless either the DNS server/resolver
## is broken, Zeek is not seeing all the DNS traffic, or an AXFR query
## response is ongoing).
option max_pending_msgs = 50;
## Give up trying to match pending DNS queries or replies across all
## query/transaction IDs once there is at least one unmatched query or
## reply across this number of different query IDs.
option max_pending_query_ids = 50;
## A record type which tracks the status of DNS queries for a given
## :zeek:type:`connection`.
type State: record {
## A single query that hasn't been matched with a response yet.
## Note this is maintained separate from the *pending_queries*
## field solely for performance reasons -- it's possible that
## *pending_queries* contains further queries for which a response
## has not yet been seen, even for the same transaction ID.
pending_query: Info &optional;
## Indexed by query id, returns Info record corresponding to
## queries that haven't been matched with a response yet.
pending_queries: PendingMessages &optional;
## Indexed by query id, returns Info record corresponding to
## replies that haven't been matched with a query yet.
pending_replies: PendingMessages &optional;
};
## DNS finalization hook. Remaining DNS info may get logged when it's called.
global finalize_dns: Conn::RemovalHook;
}
redef record connection += {
dns: Info &optional;
dns_state: State &optional;
};
const ports = { 53/udp, 53/tcp, 137/udp, 5353/udp, 5355/udp };
redef likely_server_ports += { ports };
event zeek_init() &priority=5
{
Log::create_stream(DNS::LOG, Log::Stream($columns=Info, $ev=log_dns, $path="dns", $policy=log_policy));
Analyzer::register_for_ports(Analyzer::ANALYZER_DNS, ports);
}
function new_session(c: connection, trans_id: count): Info
{
local info: Info;
info$ts = network_time();
info$id = c$id;
info$uid = c$uid;
info$proto = get_port_transport_proto(c$id$resp_p);
info$trans_id = trans_id;
return info;
}
function log_unmatched_msgs_queue(q: Queue::Queue)
{
local infos: vector of Info;
Queue::get_vector(q, infos);
for ( i in infos )
{
Log::write(DNS::LOG, infos[i]);
}
}
function log_unmatched_msgs(msgs: PendingMessages)
{
for ( _, q in msgs )
{
log_unmatched_msgs_queue(q);
}
clear_table(msgs);
}
function enqueue_new_msg(msgs: PendingMessages, id: count, msg: Info)
{
if ( id !in msgs )
{
if ( |msgs| > max_pending_query_ids )
{
# Throw away all unmatched on assumption they'll never be matched.
log_unmatched_msgs(msgs);
}
msgs[id] = Queue::init();
}
else
{
if ( Queue::len(msgs[id]) > max_pending_msgs )
{
log_unmatched_msgs_queue(msgs[id]);
# Throw away all unmatched on assumption they'll never be matched.
msgs[id] = Queue::init();
}
}
Queue::put(msgs[id], msg);
}
function pop_msg(msgs: PendingMessages, id: count): Info
{
local rval: Info = Queue::get(msgs[id]);
if ( Queue::len(msgs[id]) == 0 )
delete msgs[id];
return rval;
}
hook set_session(c: connection, msg: dns_msg, is_query: bool) &priority=5
{
if ( ! c?$dns_state )
{
local state: State;
c$dns_state = state;
Conn::register_removal_hook(c, finalize_dns);
}
if ( is_query )
{
if ( c$dns_state?$pending_replies && msg$id in c$dns_state$pending_replies &&
Queue::len(c$dns_state$pending_replies[msg$id]) > 0 )
{
# Match this DNS query w/ what's at head of pending reply queue.
c$dns = pop_msg(c$dns_state$pending_replies, msg$id);
}
else
{
# Create a new DNS session and put it in the query queue so
# we can wait for a matching reply.
c$dns = new_session(c, msg$id);
if( ! c$dns_state?$pending_query )
c$dns_state$pending_query = c$dns;
else
{
if( !c$dns_state?$pending_queries )
c$dns_state$pending_queries = table();
enqueue_new_msg(c$dns_state$pending_queries, msg$id, c$dns);
}
}
}
else
{
if ( c$dns_state?$pending_query && c$dns_state$pending_query$trans_id == msg$id )
{
c$dns = c$dns_state$pending_query;
delete c$dns_state$pending_query;
if ( c$dns_state?$pending_queries )
{
# Popping off an arbitrary, unpaired query to set as the
# new fastpath is necessary in order to preserve the overall
# queuing order of any pending queries that may share a
# transaction ID. If we didn't fill c$dns_state$pending_query
# back in, then it's possible a new query would jump ahead in
# the queue of some other pending query since
# c$dns_state$pending_query is filled first if available.
if ( msg$id in c$dns_state$pending_queries &&
Queue::len(c$dns_state$pending_queries[msg$id]) > 0 )
# Prioritize any pending query with matching ID to the one
# that just got paired with a response.
c$dns_state$pending_query = pop_msg(c$dns_state$pending_queries, msg$id);
else
{
# Just pick an arbitrary, unpaired query.
local tid: count &is_assigned;
local found_one = F;
for ( trans_id, q in c$dns_state$pending_queries )
if ( Queue::len(q) > 0 )
{
tid = trans_id;
found_one = T;
break;
}
if ( found_one )
c$dns_state$pending_query = pop_msg(c$dns_state$pending_queries, tid);
}
}
}
else if ( c$dns_state?$pending_queries && msg$id in c$dns_state$pending_queries &&
Queue::len(c$dns_state$pending_queries[msg$id]) > 0 )
{
# Match this DNS reply w/ what's at head of pending query queue.
c$dns = pop_msg(c$dns_state$pending_queries, msg$id);
}
else
{
# Create a new DNS session and put it in the reply queue so
# we can wait for a matching query.
c$dns = new_session(c, msg$id);
if( ! c$dns_state?$pending_replies )
c$dns_state$pending_replies = table();
enqueue_new_msg(c$dns_state$pending_replies, msg$id, c$dns);
}
}
if ( ! is_query )
{
c$dns$rcode = msg$rcode;
c$dns$rcode_name = base_errors[msg$rcode];
if ( ! c$dns?$total_answers )
c$dns$total_answers = msg$num_answers;
if ( ! c$dns?$total_replies )
c$dns$total_replies = msg$num_answers + msg$num_addl + msg$num_auth;
if ( msg$rcode != 0 && msg$num_queries == 0 )
c$dns$rejected = T;
}
c$dns$opcode = msg$opcode;
if ( msg$is_netbios )
c$dns$opcode_name = netbios_opcodes[msg$opcode];
else
c$dns$opcode_name = opcodes[msg$opcode];
}
event dns_message(c: connection, is_orig: bool, msg: dns_msg, len: count) &priority=5
{
if ( msg$opcode != 0 && msg$opcode != 5 )
# Currently only standard queries are tracked.
return;
hook set_session(c, msg, ! msg$QR);
}
hook DNS::do_reply(c: connection, msg: dns_msg, ans: dns_answer, reply: string) &priority=5
{
if ( msg$opcode != 0 )
# Currently only standard queries are tracked.
return;
if ( ! msg$QR )
# This is weird: the inquirer must also be providing answers in
# the request, which is not what we want to track.
return;
if ( ans$answer_type == DNS_ANS )
{
if ( ! c$dns?$query )
c$dns$query = ans$query;
c$dns$AA = msg$AA;
c$dns$RA = msg$RA;
if ( ! c$dns?$rtt )
{
c$dns$rtt = network_time() - c$dns$ts;
# This could mean that only a reply was seen since
# we assume there must be some passage of time between
# request and response.
if ( c$dns$rtt == 0secs )
delete c$dns$rtt;
}
if ( reply != "" )
{
if ( ! c$dns?$answers )
c$dns$answers = vector();
c$dns$answers += reply;
if ( ! c$dns?$TTLs )
c$dns$TTLs = vector();
c$dns$TTLs += ans$TTL;
}
}
}
event dns_end(c: connection, msg: dns_msg) &priority=5
{
if ( ! c?$dns )
return;
if ( msg$QR )
c$dns$saw_reply = T;
else
c$dns$saw_query = T;
}
event dns_end(c: connection, msg: dns_msg) &priority=-5
{
if ( c?$dns && c$dns$saw_reply && c$dns$saw_query )
{
Log::write(DNS::LOG, c$dns);
delete c$dns;
}
}
event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) &priority=5
{
if ( msg$opcode != 0 )
# Currently only standard queries are tracked.
return;
c$dns$RD = msg$RD;
c$dns$TC = msg$TC;
c$dns$qclass = qclass;
c$dns$qclass_name = classes[qclass];
c$dns$qtype = qtype;
c$dns$qtype_name = query_types[qtype];
c$dns$Z = msg$Z;
# Decode netbios name queries
# Note: I'm ignoring the name type for now. Not sure if this should be
# worked into the query/response in some fashion.
if ( c$id$resp_p == 137/udp )
{
local decoded_query = decode_netbios_name(query);
if ( |decoded_query| != 0 )
query = decoded_query;
if ( c$dns$qtype_name == "SRV" )
{
# The SRV RFC used the ID used for NetBios Status RRs.
# So if this is NetBios Name Service we name it correctly.
c$dns$qtype_name = "NBSTAT";
}
}
c$dns$query = query;
}
event dns_unknown_reply(c: connection, msg: dns_msg, ans: dns_answer) &priority=5
{
hook DNS::do_reply(c, msg, ans, fmt("<unknown type=%s>", ans$qtype));
}
event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
{
hook DNS::do_reply(c, msg, ans, fmt("%s", a));
}
event dns_TXT_reply(c: connection, msg: dns_msg, ans: dns_answer, strs: string_vec) &priority=5
{
local txt_strings: string = "";
for ( i in strs )
{
if ( i > 0 )
txt_strings += " ";
txt_strings += fmt("TXT %d %s", |strs[i]|, strs[i]);
}
hook DNS::do_reply(c, msg, ans, txt_strings);
}
event dns_SPF_reply(c: connection, msg: dns_msg, ans: dns_answer, strs: string_vec) &priority=5
{
local spf_strings: string = "";
for ( i in strs )
{
if ( i > 0 )
spf_strings += " ";
spf_strings += fmt("SPF %d %s", |strs[i]|, strs[i]);
}
hook DNS::do_reply(c, msg, ans, spf_strings);
}
event dns_AAAA_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
{
hook DNS::do_reply(c, msg, ans, fmt("%s", a));
}
event dns_A6_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
{
hook DNS::do_reply(c, msg, ans, fmt("%s", a));
}
event dns_NS_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
{
hook DNS::do_reply(c, msg, ans, name);
}
event dns_CNAME_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
{
hook DNS::do_reply(c, msg, ans, name);
}
event dns_MX_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string,
preference: count) &priority=5
{
hook DNS::do_reply(c, msg, ans, name);
}
event dns_PTR_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
{
hook DNS::do_reply(c, msg, ans, name);
}
event dns_SOA_reply(c: connection, msg: dns_msg, ans: dns_answer, soa: dns_soa) &priority=5
{
hook DNS::do_reply(c, msg, ans, soa$mname);
}
event dns_WKS_reply(c: connection, msg: dns_msg, ans: dns_answer) &priority=5
{
hook DNS::do_reply(c, msg, ans, "");
}
event dns_SRV_reply(c: connection, msg: dns_msg, ans: dns_answer, target: string, priority: count, weight: count, p: count) &priority=5
{
hook DNS::do_reply(c, msg, ans, target);
}
event dns_NAPTR_reply(c: connection, msg: dns_msg, ans: dns_answer, naptr: dns_naptr_rr) &priority=5
{
# Just encode all the fields for NAPTR RR in the reply string.
local tmp = "";
if ( |naptr$regexp| > 0 )
tmp += naptr$regexp;
if ( |naptr$replacement| > 0 )
{
if ( |tmp| > 0 )
tmp += " ";
tmp += naptr$replacement;
}
local r = fmt("NAPTR %s %s %s %s %s", naptr$order, naptr$preference, naptr$flags, naptr$service, tmp);
hook DNS::do_reply(c, msg, ans, r);
}
# TODO: figure out how to handle these
#event dns_EDNS(c: connection, msg: dns_msg, ans: dns_answer)
# {
#
# }
#
#event dns_EDNS_addl(c: connection, msg: dns_msg, ans: dns_edns_additional)
# {
#
# }
# event dns_EDNS_ecs(c: connection, msg: dns_msg, opt: dns_edns_ecs)
# {
#
# }
#
#event dns_TSIG_addl(c: connection, msg: dns_msg, ans: dns_tsig_additional)
# {
#
# }
event dns_RRSIG(c: connection, msg: dns_msg, ans: dns_answer, rrsig: dns_rrsig_rr) &priority=5
{
local s: string;
s = fmt("RRSIG %s %s", rrsig$type_covered,
rrsig$signer_name == "" ? "<Root>" : rrsig$signer_name);
hook DNS::do_reply(c, msg, ans, s);
}
event dns_DNSKEY(c: connection, msg: dns_msg, ans: dns_answer, dnskey: dns_dnskey_rr) &priority=5
{
local s: string;
s = fmt("DNSKEY %s", dnskey$algorithm);
hook DNS::do_reply(c, msg, ans, s);
}
event dns_NSEC(c: connection, msg: dns_msg, ans: dns_answer, next_name: string, bitmaps: string_vec) &priority=5
{
hook DNS::do_reply(c, msg, ans, fmt("NSEC %s %s", ans$query, next_name));
}
event dns_NSEC3(c: connection, msg: dns_msg, ans: dns_answer, nsec3: dns_nsec3_rr) &priority=5
{
hook DNS::do_reply(c, msg, ans, "NSEC3");
}
event dns_NSEC3PARAM(c: connection, msg: dns_msg, ans: dns_answer, nsec3param: dns_nsec3param_rr) &priority=5
{
hook DNS::do_reply(c, msg, ans, "NSEC3PARAM");
}
event dns_DS(c: connection, msg: dns_msg, ans: dns_answer, ds: dns_ds_rr) &priority=5
{
local s: string;
s = fmt("DS %s %s", ds$algorithm, ds$digest_type);
hook DNS::do_reply(c, msg, ans, s);
}
event dns_BINDS(c: connection, msg: dns_msg, ans: dns_answer, binds: dns_binds_rr) &priority=5
{
hook DNS::do_reply(c, msg, ans, "BIND9 signing signal");
}
event dns_SSHFP(c: connection, msg: dns_msg, ans: dns_answer, algo: count, fptype: count, fingerprint: string) &priority=5
{
local s: string;
s = fmt("SSHFP: %s", bytestring_to_hexstr(fingerprint));
hook DNS::do_reply(c, msg, ans, s);
}
event dns_LOC(c: connection, msg: dns_msg, ans: dns_answer, loc: dns_loc_rr) &priority=5
{
local s: string;
s = fmt("LOC: %d %d %d", loc$size, loc$horiz_pre, loc$vert_pre);
hook DNS::do_reply(c, msg, ans, s);
}
event dns_rejected(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) &priority=5
{
if ( c?$dns )
c$dns$rejected = T;
}
hook finalize_dns(c: connection)
{
if ( ! c?$dns_state )
return;
# If Zeek is expiring state, we should go ahead and log all unmatched
# queries and replies now.
if( c$dns_state?$pending_query )
Log::write(DNS::LOG, c$dns_state$pending_query);
if( c$dns_state?$pending_queries )
log_unmatched_msgs(c$dns_state$pending_queries);
if( c$dns_state?$pending_replies )
log_unmatched_msgs(c$dns_state$pending_replies);
}