# $Id: dns.bro 4758 2007-08-10 06:49:23Z vern $

@load notice
@load weird
@load udp-common
@load dns-info

module DNS;

export {
	# Lookups of hosts in here are flagged ...
	const sensitive_lookup_hosts: set[addr] &redef;

	# ... unless the lookup comes from one of these hosts.
	const okay_to_lookup_sensitive_hosts: set[addr] &redef;

	# Start considering whether we're seeing PTR scanning if we've seen
	# at least this many rejected PTR queries.
	const report_rejected_PTR_thresh = 100 &redef;

	# Generate a PTR_scan event if at any point (once we're above
	# report_rejected_PTR_thresh) we see this many more distinct
	# rejected PTR requests than distinct answered PTR requests.
	const report_rejected_PTR_factor = 2.0 &redef;

	# The following sources are allowed to do PTR scanning.
	const allow_PTR_scans: set[addr] &redef;

	# Annotations that if returned for a PTR lookup actually indicate
	# a rejected query; for example, "illegal-address.lbl.gov".
	const actually_rejected_PTR_anno: set[string] &redef;

	# Hosts allowed to do zone transfers.
	const zone_transfers_okay: set[addr] &redef;

	# Set to false to disable printing to dns.log.
	const logging = T &redef;

	redef enum Notice += {
		SensitiveDNS_Lookup,	# DNS lookup of sensitive hostname/addr
		DNS_PTR_Scan,		# summary of a set of PTR lookups
		ResolverInconsistency,	# DNS answer changed
		ZoneTransfer,		# a DNS zone transfer request was seen

	};

	# This is a list of domains that have a history of providing
	# more RR's in response than they are supposed to.  There is
	# some danger here in that record inconsistancies will not be 
	# identified for these domains...
	const bad_domain_resp: set[string] &redef;

	# Same idea, except that it applies to a list of host names.
	const bad_host_resp: set[string] &redef;

	# Turn resolver consistancy checking on/off.
	const resolver_consist_check = F &redef;

	# Should queries be checked against 'bad' domains?
	const check_domain_list = T;

	# List of 'bad' domains.
	const hostile_domain_list: set[string] &redef;

	# Used for PTR scan detection.  Exported so their timeouts can be
	# adjusted.
	global distinct_PTR_requests:
		table[addr, string] of count &default = 0 &write_expire = 5 min;
	global distinct_rejected_PTR_requests:
		table[addr] of count &default = 0 &write_expire = 5 min;
	global distinct_answered_PTR_requests:
		table[addr] of count &default = 0 &write_expire = 5 min;
}

redef capture_filters += { 
	["dns"] = "port 53",
	["netbios-ns"] = "udp port 137", 
};

# DPM configuration.
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] };

# Default handling for peculiarities in DNS analysis.  You can redef these
# again in your site-specific script if you want different behavior.
redef Weird::weird_action += {
	["DNS_AAAA_neg_length"]	= Weird::WEIRD_FILE,
	["DNS_Conn_count_too_large"]	= Weird::WEIRD_FILE,
	["DNS_NAME_too_long"]	= Weird::WEIRD_FILE,
	["DNS_RR_bad_length"]	= Weird::WEIRD_FILE,
	["DNS_RR_length_mismatch"]	= Weird::WEIRD_FILE,
	["DNS_RR_unknown_type"]	= Weird::WEIRD_FILE,
	["DNS_label_forward_compress_offset"]	= Weird::WEIRD_FILE,
	["DNS_label_len_gt_name_len"]	= Weird::WEIRD_FILE,
	["DNS_label_len_gt_pkt"]	= Weird::WEIRD_FILE,
	["DNS_label_too_long"]	= Weird::WEIRD_FILE,
	["DNS_name_too_long"]	= Weird::WEIRD_FILE,
	["DNS_truncated_RR_rdlength_lt_len"]	= Weird::WEIRD_FILE,
	["DNS_truncated_ans_too_short"]	= Weird::WEIRD_FILE,
	["DNS_truncated_len_lt_hdr_len"]	= Weird::WEIRD_FILE,
	["DNS_truncated_quest_too_short"]	= Weird::WEIRD_FILE,
};

type dns_session_info: record {
	id: count;
	is_zone_transfer: bool;
	last_active: time;	# when we last saw activity

	# Indexed by query id, returns string annotation corresponding to
	# queries for which no answer seen yet.
	pending_queries: table[count] of string;
};

# Indexed by client and server.
global dns_sessions: table[addr, addr, count] of dns_session_info;
global num_dns_sessions = 0;

const PTR_pattern = /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\.in-addr\.arpa/;

# Keeps track of for which addresses we processed a PTR_scan event.
global did_PTR_scan_event: table[addr] of count &default = 0;

# The following definitions relate to tracking when DNS records
# change and whether they do so in a consistent fashion.
type dns_response_record: record {
	dns_name: string;	# domain name in question
	dns_type: count;	# type of query
	num_resp: count;	# number of responses
	resp_count: count;	# how many responses have been registered
	addrs: set[addr];	# addresses in response
};

global dns_history: table[string, count, count] of dns_response_record;

global did_zone_transfer_notice: table[addr] of count &default = 0;

# Sample known irregular domains.
redef bad_domain_resp += { "instacontent.net", "mirror-image.net", };

# Sample hostile domains.
redef hostile_domain_list += { "undernet.org", "afraid.org", };

global dns_log : file;

event bro_init()
	{
	if ( logging )
		dns_log = open_log_file("dns");
	}

event remove_name(name: string, qtype: count, id: count)
	{
	if ( [name, qtype, id] in dns_history )
		{
		# We need to remove the dns_history record and the assosciated
		# dns_consistency_info records.

		local drr = dns_history[name, qtype, id];
		local a: addr;

		for ( a in drr$addrs )
			delete drr$addrs[a];

		delete dns_history[name, qtype, id];
		}
	else if ( logging )
		print dns_log, fmt("ERROR in history session removal: %s/%d doesn't exist", name, qtype);
	}

# Returns the second-level domain, so for example an argument of "a.b.c.d"
# returns "c.d".
function second_level_domain(name: string): string
	{
	local split_on_dots = split(name, /\./);
	local num_dots = length(split_on_dots);

	if ( num_dots <= 1 )
		return name;

	return fmt("%s.%s", split_on_dots[num_dots-1], split_on_dots[num_dots]);
	}

function insert_name(c: connection, msg: dns_msg, ans: dns_answer, a: addr)
	{
	local drr: dns_response_record;

	if ( [ans$query, ans$qtype, msg$id] !in dns_history )
		{ # add record
		drr$dns_name = ans$query;
		drr$dns_type = ans$qtype;

		# Here we modified the expected number of addresses to allow
		# for the number of answer RR's along with the provided
		# additional RR's.
		drr$num_resp = msg$num_answers+msg$num_addl;
		drr$resp_count = 0;
		add drr$addrs[a];

		dns_history[ans$query, ans$qtype, msg$id] = drr;

		if ( ans$TTL < 0 sec )
			# Strangely enough, the spec allows this,
			# though it's hard to see why!  But because
			# of that, we don't generate a Weird, we
			# just change the TTL to 0.
			ans$TTL = 0 sec;

		# Check the TTL, but allow a smidgen of skew to avoid
		# possible race conditions.
		schedule ans$TTL + 1 sec
			{ remove_name(ans$query, ans$qtype, msg$id) };
		}
	else
		{ # extract record and do some counting
		drr = dns_history[ans$query, ans$qtype, msg$id];

		# In some broken records, the number of reported records is 0.
		# This makes the test below fail, to 'fix' set to 1 ...
		if ( drr$num_resp == 0 )
			drr$num_resp = 1;

		# Check if we have filled in the expected number of responses
		# already - it should be > current responder count to allow
		# for resolver timeouts.  Addresses are only added if they
		# are not already prsent.  This comes at a slight performance
		# cost.
		if ( a !in drr$addrs ) 
			{
			add drr$addrs[a];
			++drr$resp_count;
			dns_history[ans$query, ans$qtype, msg$id]=drr;
			}

		if ( drr$num_resp >= drr$resp_count )
			return;

		if ( second_level_domain(ans$query) in bad_domain_resp )
			return;

		if ( ans$query in bad_host_resp )
			return;

		# Too many responses to the request, or some other
		# inconsistency has been introduced.

		NOTICE([$note=ResolverInconsistency, $conn=c,
			$msg=fmt("address inconsistency for %s, %s", ans$query, a),
			$dst=a]);
		}
	}

event expire_DNS_session(orig: addr, resp: addr, trans_id: count)
	{
	if ( [orig, resp, trans_id] in dns_sessions )
		{
		local session = dns_sessions[orig, resp, trans_id];
		local last_active = session$last_active;
		if ( network_time() > last_active + dns_session_timeout ||
		     done_with_network )
			{
			# Flush out any pending requests.
			if ( logging )
				{
				for ( query in session$pending_queries )
					print dns_log, fmt("%0.6f #%d %s",
							network_time(), session$id,
							session$pending_queries[query]);

				print dns_log, fmt("%.06f #%d finish",
						network_time(), session$id);
				}

			delete dns_sessions[orig, resp, trans_id];
			}

		else
			schedule dns_session_timeout {
				expire_DNS_session(orig, resp, trans_id)
			};
		}
	}

function lookup_DNS_session(c: connection, trans_id: count): dns_session_info
	{
	local id = c$id;
	local orig = id$orig_h;
	local resp = id$resp_h;

	if ( [orig, resp, trans_id] !in dns_sessions )
		{
		local session: dns_session_info;
		session$id = ++num_dns_sessions;
		session$last_active = network_time();
		session$is_zone_transfer = F;

		if ( logging )
			print dns_log, fmt("%.06f #%d %s start",
				c$start_time, session$id, id_string(id));

		dns_sessions[orig, resp, trans_id] = session;

		schedule 15 sec { expire_DNS_session(orig, resp, trans_id) };

		append_addl(c, fmt("#%d", session$id));

		return session;
		}

	else
		return dns_sessions[orig, resp, trans_id];
	}

event sensitive_addr_lookup(c: connection, a: addr, is_query: bool)
	{
	local orig = c$id$orig_h;
	local resp = c$id$resp_h;
	local holding = 0;

	if ( orig in okay_to_lookup_sensitive_hosts )
		return;

	local session_id: string;
	if ( [orig, resp, holding] in dns_sessions )
		session_id = fmt("#%d", dns_sessions[orig, resp, holding]$id);
	else
		session_id = "#?";

	local id = fmt("%s > %s (%s)", orig, resp, session_id);

	if ( is_query )
		NOTICE([$note=SensitiveDNS_Lookup, $conn=c,
			$msg=fmt("%s PTR lookup of %s", id, a),
			$sub="PTR lookup"]);
	else
		NOTICE([$note=SensitiveDNS_Lookup, $conn=c,
			$msg=fmt("%s name lookup of %s", id, a),
			$sub="name lookup"]);
	}

function DNS_query_annotation(c: connection, msg: dns_msg, query: string,
				qtype: count, is_zone_xfer: bool): string
	{
	local anno: string;

	if ( (qtype == PTR || qtype == ANY) && query == PTR_pattern )
		{
		# convert PTR text to more readable form.
		local a = ptr_name_to_addr(query);
		if ( a in sensitive_lookup_hosts && ! is_zone_xfer )
			event sensitive_addr_lookup(c, a, T);

		anno = fmt("?%s %As", query_types[qtype], a);
		}
	else
		anno = fmt("%s %s", query_types[qtype], query);

	if ( ! is_zone_xfer &&
	     (msg$num_answers > 0 || msg$num_auth > 0 || msg$num_addl > 0) )
		anno = fmt("%s <query addl = %d/%d/%d>", anno,
			msg$num_answers, msg$num_auth, msg$num_addl);

	return anno;
	}


event dns_zone_transfer_request(c: connection, session: dns_session_info,
				msg: dns_msg, query: string)
	{
	session$is_zone_transfer = T;

	if ( ! is_tcp_port(c$id$orig_p) )
		event conn_weird("UDP_zone_transfer", c);

	local src = c$id$orig_h;
	if ( src !in zone_transfers_okay &&
	     ++did_zone_transfer_notice[src] == 1 )
		{
		NOTICE([$note=ZoneTransfer, $src=src, $conn=c,
			$msg=fmt("transfer of %s requested by %s", query, src)]);
		}
	}

event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count)
	{
	local id = c$id;
	local orig = id$orig_h;
	local resp = id$resp_h;
	local session = lookup_DNS_session(c, msg$id);
	local anno = DNS_query_annotation(c, msg, query, qtype, F);

	local report = fmt("%.06f #%d %s", network_time(), session$id, c$id$orig_h);
	local q: string;

	if ( query_types[qtype] == "AXFR" )
		{
		event dns_zone_transfer_request(c, session, msg, query);

		q = DNS_query_annotation(c, msg, query, qtype, T);
		report = fmt("%s ?%s", report, q);
		}
	else
		report = fmt("%s <query ?%s> %s Trunc:%s Recurs:%s",
			report, query_types[qtype], query, msg$TC, msg$RD);

	if ( logging )
		print dns_log, fmt("%s", report);

	# Check to see if this is a host or MX lookup for a designated
	# hostile domain.
	if ( check_domain_list &&
	     (query_types[qtype] == "A" || query_types[qtype] == "MX") &&
	     second_level_domain(query) in hostile_domain_list )
		{
		NOTICE([$note=SensitiveDNS_Lookup, $conn=c,
			$msg=fmt("%s suspicious domain lookup: %s", id, query)]);
		}

	session$pending_queries[msg$id] = anno;
	session$last_active = network_time();
	}

event dns_rejected(c: connection, msg: dns_msg,
			query: string, qtype: count, qclass: count)
	{
	local session = lookup_DNS_session(c, msg$id);
	local code = DNS_code_types[msg$rcode];
	local id = msg$id;

	if ( id in session$pending_queries )
		{
		if ( logging )
			print dns_log, fmt("%.06f #%d %s %s", network_time(),
						session$id,
						session$pending_queries[id],
						code);

		delete session$pending_queries[id];
		}

	else if ( logging )
		{
		if ( c$start_time == network_time() )
			print dns_log, fmt("%.06f #%d [?%s] %s", network_time(),
						session$id, query, code);
		else
			print dns_log, fmt("%.06f #%d %s", network_time(),
						session$id, code);
		}
	}

event PTR_scan_summary(src: addr)
	{
	NOTICE([$note=DNS_PTR_Scan, $src=src,
		$msg=fmt("%s totaled %d/%d un/successful PTR lookups", src,
			distinct_rejected_PTR_requests[src],
			distinct_answered_PTR_requests[src]),
		$sub="final summary"]);
	}

event PTR_scan(src: addr)
	{
	++did_PTR_scan_event[src];

	if ( src !in allow_PTR_scans && src !in okay_to_lookup_sensitive_hosts )
		{
		NOTICE([$note=DNS_PTR_Scan, $src=src,
			$msg=fmt("%s has made %d/%d un/successful PTR lookups",
				src, distinct_rejected_PTR_requests[src],
				distinct_answered_PTR_requests[src]),
			$sub="scan detected"]);

		schedule 1 day { PTR_scan_summary(src) };
		}
	}

function check_PTR_scan(src: addr)
	{
	if ( src !in did_PTR_scan_event &&
	     distinct_rejected_PTR_requests[src] >=
	     distinct_answered_PTR_requests[src] * report_rejected_PTR_factor )
		event PTR_scan(src);
	}

function DNS_answer(c: connection, msg: dns_msg,
			ans: dns_answer, annotation: string)
	{
	local is_answer = ans$answer_type == DNS_ANS;
	local session = lookup_DNS_session(c, msg$id);
	local report =
		fmt("%.06f #%d %s", network_time(), session$id, c$id$orig_h);
	local id = msg$id;
	local query: string;

	if ( id in session$pending_queries )
		{
		query = fmt("%s = <ans %s>", session$pending_queries[id],
				query_types[ans$qtype]);
		delete session$pending_queries[id];
		report = fmt("%s %s", report, query);
		}

	else if ( session$is_zone_transfer )
		{ # need to provide the query directly.
		query = fmt("<ans %s>", query_types[ans$qtype]);
		report = fmt("%s ?%s", report, query);
		}

	else
		{
		# No corresponding query.  This can happen if it's
		# already been deleted because we've already processed
		# an answer to it; or if the session itself was timed
		# out prior to this answer being generated.  In the
		# first case, we don't want to provide the query again;
		# in the second, we do.  We can determine that we're
		# likely in the second case if either (1) this session
		# was just now created, or (2) we're now processing the
		# sole answer to the original query.
		#
		# However, for now we punt.
		#
		#	if ( c$start_time == network_time() ||
		#	     (is_answer && msg$num_answers == 1) )
		#		{
		#		query = DNS_query_annotation(c, msg, ans$query, ans$qtype, F);
		#		report = fmt("%s [?%s]", report, query);
		#		}
		#	else
		#		query = "";

		query = fmt("<ans %s>", query_types[ans$qtype]);
		report = fmt("%s %s", report, query);
		}

	# Append a bunch of additional annotation.
	report = fmt("%s %s RCode:%s AA=%s TR=%s %s/%s/%s/%s",
		report, annotation, base_error[msg$rcode], msg$AA, msg$TC,
		msg$num_queries, msg$num_answers, msg$num_auth, msg$num_addl );

	local src = c$id$orig_h;

	if ( msg$rcode != 0 )
		{
		if ( /\?(PTR|\*.*in-addr).*/ in query )
			##### should check for private address
			{
			if ( ++distinct_PTR_requests[src, query] == 1 &&
			     ++distinct_rejected_PTR_requests[src] >=
			       report_rejected_PTR_thresh )
				check_PTR_scan(src);
			}

		report = fmt("%s %s", report, DNS_code_types[msg$rcode]);
		}

	else if ( is_answer )
		{
		if ( /\?(PTR|\*.*in-addr).*/ in query )
			{
			if ( annotation in actually_rejected_PTR_anno )
				{
				if ( ++distinct_PTR_requests[src, query] == 1 &&
				     ++distinct_rejected_PTR_requests[src] >=
				       report_rejected_PTR_thresh )
					check_PTR_scan(src);
				}
			else
				{
				if ( ++distinct_PTR_requests[src, query] == 1 )
					++distinct_answered_PTR_requests[src];
				}
			}
		}

	if ( logging )
		print dns_log, fmt("%s TTL=%g", report, ans$TTL);

	### Note, DNS_AUTH and DNS_ADDL not processed.

	session$last_active = network_time();
	}

event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr)
	{
	if ( a in sensitive_lookup_hosts )
		event sensitive_addr_lookup(c, a, F);

	DNS_answer(c, msg, ans, fmt("%As", a));

	if ( resolver_consist_check )
		insert_name(c, msg, ans, a );

	}

event dns_NS_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string)
	{
	DNS_answer(c, msg, ans, fmt("%s", name));
	}

event dns_CNAME_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string)
	{
	DNS_answer(c, msg, ans, fmt("%s %s", query_types[ans$qtype], name));
	}

event dns_PTR_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string)
	{
	DNS_answer(c, msg, ans, fmt("%s", name));
	}

event dns_SOA_reply(c: connection, msg: dns_msg, ans: dns_answer, soa: dns_soa)
	{
	DNS_answer(c, msg, ans, fmt("%s", soa$mname));
	}

event dns_MX_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string,
			preference: count)
	{
	DNS_answer(c, msg, ans, fmt("%s/%d", name, preference));
	}

event dns_EDNS(c: connection, msg: dns_msg, ans: dns_answer)
	{
	DNS_answer(c, msg, ans, "<---?--->");
	}


# From here on down we need to modify the way that data is recorded.  The
# standard resource record format is no longer universally applicable in
# that we may see modified structs or some number of value pairs that may take
# more flexability in reporting.

event dns_EDNS_addl(c: connection, msg: dns_msg, ans: dns_edns_additional)
	{
	local session = lookup_DNS_session(c, msg$id);
	local report =
		fmt("%.06f #%d %s", network_time(), session$id, c$id$orig_h);

	if ( ans$is_query == 1 )
		report = fmt("%s <addl_edns ?>", report);
	else
		report = fmt("%s <addl_edns> ", report);

	if ( logging )
		print dns_log, fmt("%s pldsize:%s RCode:%s VER:%s Z:%s",
				report, ans$payload_size,
				base_error[ans$extended_rcode],
				ans$version, edns_zfield[ans$z_field]);
	}

event dns_TSIG_addl(c: connection, msg: dns_msg, ans: dns_tsig_additional)
	{
	local session = lookup_DNS_session(c, msg$id);
	local report =
		fmt("%.06f #%d %s", network_time(), session$id, c$id$orig_h);

	# Error handling with this is a little odd: number collision with EDNS.
	# We set the collided value to the first private space number.  gross.
	local trans_error_num = (ans$rr_error == 16) ?  3842 : ans$rr_error;

	if ( ans$is_query == 1 )
		report = fmt("%s <addl_tsig ?> ", report);
	else
		report = fmt("%s <addl_tsig> ", report);

	if ( logging )
		print dns_log, fmt("%s name:%s alg:%s origID:%s RCode:%s",
				report, ans$query, ans$alg_name,
				ans$orig_id, base_error[trans_error_num]);
	}
