_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

odhcpd: one RENEW underflows a size_t and walks the stack

2026-06-11 · openwrtodhcpddhcpv6integer-underflowstack-bofdoscve-2026-53922

odhcpd is the thing on an OpenWrt box that answers the moment anything shows up on the LAN asking for IPv6. it's the default, it's on basically every install, and it runs as root. that combination is why i keep coming back to small-network daemons when i have a spare evening. i was reading the DHCPv6 reply path for length math that didn't add up, and this one turned out to be a single crafted RENEW that takes the daemon down from any host on the link, no auth, no setup beyond a normal lease handshake. it's filed as CVE-2026-53922 / GHSA-7hcw-g2jh-pqv5.

one crafted RENEW onto virbr0 and the live OpenWrt box drops odhcpd on the stack canary. procd brings it back. nothing is running on the target and nobody is logged in - this is the whole attack.

the function that lies about how much it wrote

build_ia caught my eye first. it serializes one IA option into the response buffer and hands back how many bytes it used. the bound check and the amount it actually writes don't agree - a helper that lies about its own write size is exactly the kind of thing that makes a caller's unsigned subtraction go sideways. here's the status branch, in src/dhcpv6-ia.c around line 580:

size_t ia_len = sizeof(o_ia);    /* 16 */
if (buflen < ia_len)             /* only checks room for 16 */
    return 0;
if (status) {
    struct _o_packed { uint16_t type, len, val; } o_status = {...};
    memcpy(buf + ia_len, &o_status, sizeof(o_status)); /* writes 6 more */
    ia_len += sizeof(o_status);                        /* now 22 */
    ...
    return ia_len;               /* tells the caller 22 */
}

it confirms there's room for the 16-byte IA header, treats that as permission, then writes another 6 bytes of status option and reports 22. for any buflen from 16 to 21 it has already run 6 bytes past its own budget. by itself that's nothing - six bytes, one option, who cares. the damage is in the 22 it returns.

the caller trusts the 22

dhcpv6_ia_handle_IAs loops over the client's IA options, packs a reply for each with build_ia, and keeps a running buflen. around line 1385:

ia_response_len = build_ia(buf, buflen, status, ia, a, iface, false);
...
buf    += ia_response_len;
buflen -= ia_response_len;       /* size_t. 20 - 22 is not -2 */

buflen is a size_t. when build_ia says 22 and only 20 were left, 20 - 22 doesn't go negative, it wraps to something a hair under 264. every if (buflen < ...) downstream is now a green light, and the next IA that matches a real lease writes its addresses into the buffer with no ceiling at all. that buffer is pdbuf, 512 bytes, and it lives in handle_client_request's stack frame (dhcpv6.c:630) - so it isn't even build_ia's own frame that gets shredded, it's the caller's.

the same unguarded buflen -= ... shows up again near line 1267 in the ReconfigureAccept path. i only built a trigger for the first one; the second is left as an exercise.

landing buflen in the window

the whole trick is getting buflen into [16,21] right before an IA that takes the status branch. one RENEW with three IA_NA options does it:

IAIAIDwhat odhcpd doesbytesbuflen after
#1matchedechoes 17 IA_ADDRs16 + 17×28 = 492512 → 20
#2unmatchedNOBINDING status branchwrites 22 (2 past pdbuf), returns 2220 - 22 → ~SIZE_MAX
#3matchedechoes N IA_ADDRs28×N, all of it past offset 512-

IA #1 matches a lease and i pad it with 17 IA_ADDR echoes - the 16-byte header plus 17×28 - which burns 492 of the 512 bytes and leaves 20. IA #2 uses an IAID with no binding, so odhcpd takes the NOBINDING status path, writes its 22 bytes (the last 2 already off the end) and wraps buflen. IA #3 matches a lease again and just keeps echoing addresses, except now build_ia believes it has the entire address space to play in. N is yours; 200 is plenty.

you can't summon two leases from nothing on a live router, so the attacker does one ordinary SOLICIT/REQUEST first to pick up IA_NA bindings, then sends the RENEW. that's the entire prerequisite, and it's the same thing every real client on the network already does. no special timing, no race.

making asan say it out loud

i didn't want to argue this from a disassembly, so i compiled the real, unmodified src/dhcpv6-ia.c into a harness, fed it the crafted RENEW, and let AddressSanitizer weigh in:

WRITE of size 6 in build_ia ... dhcpv6-ia.c:594
[96, 608) 'pdbuf' <== Memory access at offset 604 partially overflows

that's the six-byte overrun at the status branch, caught writing off the end of pdbuf. to see how far the cascade actually reaches i ran the same call with buflen=512 but backed it with a 64 KB buffer so the write wouldn't fault, then counted the highest byte touched: with IA #3 carrying 200 echoes it lands 5610 bytes past the boundary. there's nothing magic about 5610 - it's 28 bytes an echo times however many you cared to send.

on an actual OpenWrt VM it's blunter. one packet across virbr0 and odhcpd is on the floor:

traps: odhcpd[PID] general protection fault ip:libc+0x3f761 ... in libc.so

libc+0x3f761 is musl's __stack_chk_fail. the write runs through the stack canary on its way to the saved return address, the function epilogue sees the cookie is wrong, and the process aborts. procd restarts it a second later with a fresh canary - which also means you can't camp on the crashes and brute the cookie, because every respawn re-rolls it.

why this is a DoS and not a shell

fair question, since i'm writing attacker bytes through the return address. two reasons, both honest. first, stock OpenWrt builds odhcpd with -fstack-protector, so the smashed frame never gets to return - the canary check fires first and that's the crash you saw. second, in the layout i was looking at, the bytes that land on the saved-RIP slot overlap the IA_ADDR preference field and i only had partial control of them. so i reported the thing i can actually stand behind: remote, pre-auth, single-packet DoS. if your build drops the canary, or your frame lines up differently than mine did, redo that part before you take my word for where the ceiling is.

who this hits

the reach is the whole story. no auth, no earlier packet, nobody clicking anything - just a host sharing a link with the odhcpd interface. one packet drops the daemon; keep sending them and DHCPv6 is simply down, logs filling, procd churning. and this isn't an exotic config: every OpenWrt device acting as a DHCPv6 server has it, and that's the default LAN setup. in NVD terms it's CWE-191 feeding CWE-121, scored CVSS 3.1 6.5 (AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). the only thing holding it out of the high 7s is that AV:A - you have to be on the link.

the fix

two lines of not trusting your own helper. cap the subtraction so a misbehaving build_ia can never wrap the counter:

ia_response_len = build_ia(buf, buflen, status, ia, a, iface, false);
if (ia_response_len > buflen)
    ia_response_len = buflen;
buf    += ia_response_len;
buflen -= ia_response_len;

and don't let the status branch write at all unless there's really room for it:

if (status) {
    if (buflen < ia_len + sizeof(o_status))
        return 0;
    ...
}

the same guard belongs in front of the buflen -= handshake_len near line 1267 while you're in there.

affected

targetstatus
openwrt/odhcpd HEAD cfddd56 (2026-05-18)vulnerable (ASan + live)
any release shipping the current build_ia status branchvulnerable
openwrt/openwrt master + 25.12.x (odhcpd is the default)vulnerable downstream

the trigger

the wire PoC is one file: odh_pwn.py. commit two leases, then send the crafted RENEW. everything else is in the advisory. the trigger, honestly, is just a RENEW with three IA_NA options and the right padding.

Except the LORD build the house, they labour in vain that build it: except the LORD keep the city, the watchman waketh but in vain.

Psalm 127:1 (KJV)

no warranty. no support. no apologies.

. _SiCk · afflicted.sh