Chapter 47

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.

Scenario

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

zeekkerberoast-detect.zeek
##! 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];
        }
    }
zeekdns-tunnel-detect.zeek
##! 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.