odhcpd: one RENEW underflows a size_t and walks the stack
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.
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:
| IA | IAID | what odhcpd does | bytes | buflen after |
|---|---|---|---|---|
| #1 | matched | echoes 17 IA_ADDRs | 16 + 17×28 = 492 | 512 → 20 |
| #2 | unmatched | NOBINDING status branch | writes 22 (2 past pdbuf), returns 22 | 20 - 22 → ~SIZE_MAX |
| #3 | matched | echoes N IA_ADDRs | 28×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
| target | status |
|---|---|
openwrt/odhcpd HEAD cfddd56 (2026-05-18) | vulnerable (ASan + live) |
any release shipping the current build_ia status branch | vulnerable |
| 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