Zeek Scripting
Zeek's scripting language is an event-driven policy language that lets you write custom detection and logging logic that runs as Zeek processes packets. Scripts receive events (dns_request, ssl_established, http_reply, connection_state_remove) and can read connection state, generate notices, create custom logs, and look up values in sets and tables. For a detection engineer, Zeek scripts are how you encode domain-specific knowledge directly into the packet processing pipeline.
You want to detect Kerberoasting by identifying any source IP that makes more than 5 TGS-REQ requests (Kerberos message type 12) within 10 seconds. This detection requires: (1) tracking the count of TGS-REQ per source over time, (2) alerting when the count exceeds the threshold, (3) resetting the counter after the time window. This is stateful detection — exactly what Zeek scripts are designed for.
Zeek Language Fundamentals
Zeek Script Language — Core Concepts
═══════════════════════════════════════════════════════════════════
Types:
├── Basic: bool, int, count, double, time, interval, string, addr, port
├── Collections: table, set, vector, record (struct-like)
├── addr type: IP address (v4 or v6)
└── subnet: IP prefix (10.0.0.0/8)
Event handlers:
event event_name(arguments) { ... }
Events fire when Zeek processes a matching packet/condition.
Common protocol events:
├── connection_established(c: connection)
├── connection_state_remove(c: connection) ← most important for logging
├── dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count)
├── ssl_established(c: connection)
├── http_request(c: connection, method: string, original_URI: string, ...)
├── http_reply(c: connection, version: string, code: count, reason: string)
├── smb2_tree_connect_request(c: connection, hdr: SMB2::Header, path: string)
└── kerberos_krb_tgs_req(c: connection, msg: KRB5::KDC_REQ)
Notice framework: generates alerts
NOTICE([$note=Alert_Type, $msg="description", $conn=c, $identifier=key]);
Tables and sets (stateful storage):
global tracking_table: table[addr] of count;
global seen_domains: set[string];
Detection Scripts
##! Detect Kerberoasting: bulk TGS-REQ requests from single source.
@load base/protocols/krb
module KerberoastDetect;
export {
redef enum Notice::Type += {
Potential_Kerberoasting,
};
# Threshold: more than N TGS-REQ within the window
const tgs_threshold = 5 &redef;
const tgs_window = 10sec &redef;
}
# Per-source table: addr -> [count of TGS-REQ, time of first in window]
global tgs_tracker: table[addr] of record {
count: count;
window_start: time;
spns: set[string];
};
event kerberos_krb_tgs_req(c: connection, msg: KRB5::KDC_REQ)
{
local src = c$id$orig_h;
local now = network_time();
# Get or initialize tracker entry for this source
if ( src !in tgs_tracker )
{
tgs_tracker[src] = [$count=0, $window_start=now, $spns=set()];
}
local tracker = tgs_tracker[src];
# Reset window if expired
if ( now - tracker$window_start > tgs_window )
{
tracker$count = 0;
tracker$window_start = now;
tracker$spns = set();
}
tracker$count += 1;
# Record which SPN was requested
if ( msg?$req_body && msg$req_body?$sname )
{
local sname = msg$req_body$sname;
if ( |sname$name_string| > 0 )
add tracker$spns[sname$name_string[0]];
}
# Check threshold
if ( tracker$count >= tgs_threshold )
{
NOTICE([$note=Potential_Kerberoasting,
$conn=c,
$msg=fmt("Kerberoasting: %d TGS-REQ from %s in %s (%d unique SPNs)",
tracker$count, src, tgs_window, |tracker$spns|),
$identifier=cat(src)]);
# Reset after alerting to avoid alert storm
delete tgs_tracker[src];
}
}
##! Detect DNS tunneling via high query volume + long subdomain labels.
@load base/protocols/dns
module DNSTunnel;
export {
redef enum Notice::Type += {
DNS_Tunneling_Suspected,
};
const max_label_length = 40 &redef;
const query_volume_threshold = 100 &redef;
const query_window = 60sec &redef;
}
global query_counter: table[addr, string] of record {
count: count;
window_start: time;
};
function get_sld(name: string): string
{
local parts = split_string(name, /\./);
if ( |parts| >= 2 )
return parts[|parts|-2] + "." + parts[|parts|-1];
return name;
}
event dns_request(c: connection, msg: dns_msg, query: string,
qtype: count, qclass: count)
{
local src = c$id$orig_h;
local sld = get_sld(query);
local now = network_time();
local key = [src, sld];
# Check for long subdomain labels (tunneling indicator)
local labels = split_string(query, /\./);
if ( |labels| > 0 )
{
local first_label = labels[0];
if ( |first_label| > max_label_length )
{
NOTICE([$note=DNS_Tunneling_Suspected,
$conn=c,
$msg=fmt("Long DNS label (%d chars): %s from %s",
|first_label|, query, src),
$identifier=cat(src, sld)]);
}
}
# Track query volume per source × SLD
if ( key !in query_counter )
query_counter[key] = [$count=0, $window_start=now];
local tracker = query_counter[key];
if ( now - tracker$window_start > query_window )
{
tracker$count = 0;
tracker$window_start = now;
}
tracker$count += 1;
if ( tracker$count >= query_volume_threshold )
{
NOTICE([$note=DNS_Tunneling_Suspected,
$conn=c,
$msg=fmt("High DNS volume: %d queries to %s from %s in %s",
tracker$count, sld, src, query_window),
$identifier=cat(src, sld)]);
delete query_counter[key];
}
}
Q & A
Q: My Zeek script generates too many notices and fills the notice.log with noise. How do I suppress repeats?
Zeek's notice framework has built-in suppression via the $suppress_for and $identifier fields. Key mechanisms: (1) Identifier: the $identifier field uniquely identifies a notice — two notices with the same identifier are considered duplicates. Zeek suppresses repeated notices with the same identifier for a configurable period. (2) suppress_for: add $suppress_for=1hr to a NOTICE call to suppress identical identifier notices for 1 hour after the first fire. (3) Per-key deletion: in the Kerberoasting script above, delete tgs_tracker[src] after alerting means the same source can't re-trigger for another full window. (4) Check for existing notice before firing: use Zeek's Notice::is_being_suppressed() function to check if a notice is already suppressed before generating it. (5) Adjust thresholds: if the thresholds are too low for your environment, raise them. A threshold of 5 TGS-REQ may fire for legitimate software; test against a week of baseline traffic before deploying. Log the notice to notice.log AND to a custom log with more context so analysts can tune the detection without modifying the script each time.