[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