[Dnsmasq-discuss] Missing prefix-length validation in --add-subnet and --auth-zone leads to OOB write/read (reproduced on v2.93 and master)
Critizero Chen
critizero at gmail.com
Tue Sep 15 12:01:32 UTC 2026
Hi Simon, hi list,
While reviewing the option parser with a sanitizer-instrumented build, I
found that --add-subnet and --auth-zone accept prefix lengths far outside
their documented ranges (the man page allows 0-32 for IPv4 and 0-128 for
IPv6, and 0 is explicitly valid) and silently store them. The values are
later used as memcpy()/memcmp() lengths on fixed-size stack objects, so a
single DNS query from any client crashes the daemon. Both issues reproduce
on the v2.93 release and on current master (a9880c59).
Since these values cannot occur in a sane configuration, this is an
input-validation/hardening issue rather than a classic remote hole - but
dnsmasq configs are often generated by management tools, so refusing the
values at start-up would be the right fix. Details and suggested fixes
below; happy to test any proposed patch.
Affected versions: --add-subnet since 2.67 (when it was added);
--auth-zone since 2.68 (the same release entry reads "Remove restriction
on prefix-length in --auth-zone", so prefixes have been unbounded since
then). Verified on tag v2.93 (3ff66da) and master
a9880c595f052d63859d6ed8aa86a3a6b007bf20; all line numbers below refer to
master.
1. --add-subnet: stack-buffer-overflow (WRITE), CWE-787
-------------------------------------------------------
Parse side (option.c, LOPT_ADD_SBNET, lines 2248-2293; the atoi_check()
calls at 2262/2266/2280 only reject non-digit strings, no range check):
if ((end = split_chr(arg, '/')))
{ parse_mysockaddr(arg, &new->addr);
if (!atoi_check(end, &new->mask)) ... } /* 2262: addr/mask form */
else if (!atoi_check(arg, &new->mask)) ... /* 2266: bare number form
*/
daemon->add_subnet4 = new; /* first field = v4 entry
*/
... atoi_check(end, &new->mask) ... /* 2280: v6 entry */
Consumer (edns0.c:339-343 and 395-396):
struct subnet_opt { /* 20 bytes total */
u16 family; u8 source_netmask, scope_netmask;
u8 addr[IN6ADDRSZ]; /* last member, 16 bytes */
};
len = ((opt->source_netmask - 1) >> 3) + 1; /* 395: mask=192 ->
len=24 */
memcpy(opt->addr, addrp, len); /* 396: OOB write, up to
16 bytes */
`opt` is a stack local of add_source_addr() (edns0.c:415-446).
Reproduction (any query works and no upstream is needed:
add_edns0_config() runs unconditionally in receive_query() before the
answer/forward decision, so even locally answered queries hit it):
$ cat p1.conf
port=5354
add-subnet=192
$ dnsmasq -d -C p1.conf &
$ dig @127.0.0.1 -p 5354 anything.example.com A
==1687==ERROR: AddressSanitizer: stack-buffer-overflow ... WRITE of
size 24
#1 calc_subnet_opt src/edns0.c:396:7
#2 add_source_addr src/edns0.c:427:13
#3 add_edns0_config src/edns0.c:575:10
#4 receive_query src/forward.c:1966:11
Negative control: identical config without the add-subnet line -> process
survives, no ASAN report. Without ASAN the 8-16 byte write corrupts
adjacent stack frames; the daemon dies or is left corrupted either way.
2. --auth-zone: out-of-bounds read in is_same_net6(), CWE-125
--------------------------------------------------------------
Parse side (option.c:2564/2576/2582):
if (prefix && !atoi_check(prefix, &prefixlen)) /* 2564: no
bounds check */
...
subnet->prefixlen = (prefixlen == 0) ? 24 : prefixlen; /* 2576: v4 */
subnet->prefixlen = (prefixlen == 0) ? 64 : prefixlen; /* 2582: v6 */
Authoritative PTR queries reach find_addrlist() -> is_same_net6()
(auth.c:36 -> util.c:518-534):
int pfbytes = prefixlen >> 3; /* 526 */
if (memcmp(&a->s6_addr, &b->s6_addr, pfbytes) != 0) /* 529 */
...
a->s6_addr[pfbytes] ... /* ~533 */
`a` is a 24-byte union all_addr local of answer_auth(); `b` is the heap
struct addrlist. A stored prefixlen of 193-255 makes memcmp() read 25-31
bytes from the 24-byte stack object - a deterministic ASAN report, while
non-ASAN builds stay inside mapped memory and only corrupt the
subnet-match result (wrong authoritative answers). A *negative* prefixlen
(decimal overflow, e.g. "/2147483649") makes the memcmp length huge and
crashes production builds immediately (SIGSEGV, no sanitizer involved).
Reproduction:
$ cat p2.conf
port=5355
auth-server=8.b.d.0.1.0.0.2.ip6.arpa,lo
auth-zone=8.b.d.0.1.0.0.2.ip6.arpa,2001:db8::/240
$ dnsmasq -d -C p2.conf &
$ dig @127.0.0.1 -p 5355 -x ::1 # any IPv6 reverse query works; the
# address need not be inside the
subnet
==1696==ERROR: AddressSanitizer: stack-buffer-overflow ... READ of size
30
#2 is_same_net6 src/util.c:529:7
#3 find_addrlist src/auth.c:36:14
#4 find_subnet src/auth.c:49:10
#5 answer_auth src/auth.c:160:20
#6 receive_query src/forward.c:1953:11
Two configuration details matter here (both verified empirically):
* the interface qualifier on auth-server (",lo"): auth_dns must come
from the interface dns_auth flag; the pure domain-match path sets
local_auth=1, and answer_auth() skips the find_subnet() branch when
local_query is true;
* the zone domain should be the reverse zone of the subnet, otherwise
the PTR query is forwarded instead of answered authoritatively.
Negative control: prefix /64 (in range) -> process survives and answers
authoritative NXDOMAIN; no ASAN report.
Suggested fix
-------------
Parse-side, consistent with existing checks elsewhere (synth-domain
already rejects "msize > 128"):
/* LOPT_ADD_SBNET: first field feeds add_subnet4 (IPv4) */
if (new->mask < 0 || new->mask > 32) ret_err(...);
/* field after comma feeds add_subnet6 (IPv6) */
if (new->mask < 0 || new->mask > 128) ret_err(...);
/* addr/mask form: validate per the parsed address family */
/* auth-zone subnets: same 32/128 bounds per family, reject negatives */
Consumer-side hardening (defense in depth):
/* calc_subnet_opt() */
if (opt->source_netmask > 8 * sizeof(opt->addr))
opt->source_netmask = 8 * sizeof(opt->addr);
/* is_same_net6() */
if (prefixlen < 0 || prefixlen > 128) return 0;
Related spots of the same family (same root cause; happy to test a fix
for these too):
* dhcp-host=.../<negative>: atoi_check(pref, &new_addr->prefixlen) only
rejects >128 (option.c:4127-4129); negative values (atoi overflow)
later reach is_same_net6() / 1 << (128 - prefixlen) (rfc3315.c:1795,
util.c:529) -> huge memcmp / 2^63-iteration allocation loop;
* bogus-nxdomain=X/0: prefix 0 is not rejected (option.c:2999) and
is_same_net_prefix() (util.c:514-521) computes 1 << (32 - prefix),
a shift-width UB.
Environment
-----------
Both issues were verified against a fresh clone of
https://thekelleys.org.uk/git/dnsmasq.git - tag v2.93 (3ff66da) and
master a9880c595f052d63859d6ed8aa86a3a6b007bf20 - built with clang 10
(-fsanitize=address). Clients were standard dig queries over UDP; no
upstream servers were configured (not needed for either issue).
Happy to test a proposed patch on master and v2.93; the configs above
reproduce each issue with a single dig command.
Thanks,
Chen
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/attachments/20260915/96199efa/attachment.htm>
More information about the Dnsmasq-discuss
mailing list