ip6_output(): stale exthdrs[] mbuf pointers across pfil_run_hooks() β use-after-free read+write in the fragmentation path
Summary
ip6_output() caches pointers to the hop-by-hop/dest1/rthdr mbufs before running the output pfil hooks. pfil_run_hooks() legally allows a hook to free the chain and substitute a copy while returning 0 - the normalizing-firewall pattern. After the hooks, only ip6 is re-derived; the fragmentation path then reads nextproto and writes IPPROTO_FRAGMENT through the stale exthdrs pointers: a 1-byte write-after-free at the start of the freed (cluster-backed) hbh header plus a 1-byte stale read that becomes ip6f_nxt of every emitted fragment. VERIFIED on guest: demo PFIL_OUT hook deep-copies a >5000-byte ::1 UDP6 probe carrying a 256-byte IPV6_HOPOPTS cmsg (root), lo0 MTU lowered to force fragmentation: emitted fragments show hbh byte0 = 0x11 (17) instead of 0x2c (44) - the rewrite landed in freed memory - and the 5000-byte datagram is never delivered; control run with passthrough hook shows 0x2c and delivery; fixed kernel restores both with the chain still being replaced. Requires root-set hbh/dest1 options + any chain-replacing PFIL_OUT hook: silently-corrupted fragments on the wire + write-after-free into the mbuf-cluster slab. Not an unpriv escalation on stock. Fix validated (re-locate the last unfragmentable header post-hook via ip6_lastunfignxt).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3307 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.2 KB | β raw | |
| VERDICT.md | β | 5.3 KB | β raw | |
| trigger.c | β | 2.8 KB | view raw | |
| instr-ip6_input.c | β | 45.0 KB | view raw | |
| pfildemo.c | β | 4.6 KB | view raw | |
| Makefile | β | 52 B | β download | |
| build.sh | β | 257 B | view raw | |
| run.sh | β | 216 B | view raw | |
| fix.diff | β | 2.9 KB | view raw | |
| run.log | β | 1.3 KB | view raw | |
| run.2.log | β | 1.1 KB | view raw | |
| env.txt | β | 516 B | view raw | |
| verdict.json | β | 5.2 KB | view raw | |
| code_hashes.txt | β | 76 B | view raw |
DF-3307 β stale exthdrs[] pointers across pfil_run_hooks() β use-after-free read+write in ip6_output() fragmentation
File: sys/netinet6/ip6_output.c (pass-2 audit, GLM 5.3)
Class: kernel memory corruption (use-after-free write of one controlled byte
+ stale read that feeds the emitted fragment's ip6f_nxt), packet corruption.
Gate: root-set Hop-by-Hop (or Dest1/Rthdr) option + any inet6 PFIL_OUT hook
that replaces the mbuf chain (e.g. firewall normalization). No unprivileged
trigger exists on a stock system (hbh/dest/rthdr options are privilege-gated
at ip6_setpktoption, ip6_output.c:2763/2794), hence severity Low, but
the primitive itself is a genuine kernel UAF and is fully demonstrated here.
What is in this pack
| file | what |
|---|---|
trigger.c |
root UDP6 sendmsg to ::1 with a 256-byte IPV6_HOPOPTS cmsg and 5000-byte payload (forces IPv6 fragmentation once lo0 MTU is lowered to 1500) |
instr-ip6_input.c |
the instrumentation kernel patch: a demo PFIL_OUT hook compiled into the guest's /usr/src/sys/netinet6/ip6_input.c copy that deep-copies the >5000-byte probe packet's chain, frees the old chain and returns the copy (rv=0) β a perfectly legal pfil_run_hooks() action (sys/net/pfil.c passes &m to every hook) |
pfildemo.c |
the same hook as a KLD module (note: could not be used β see VERDICT.md: kldloading any freshly-built module panics this guest's loader in strcmp; the hook was therefore compiled into the test kernels) |
fix.diff |
the verified fix (re-locate the last unfragmentable next-header field in the current chain via m_getptr() instead of dereferencing pre-pfil exthdrs[] pointers) |
run.log |
vulnerable-kernel TEST run: wire hex β hbh byte 0 = 0x11 (17) |
run.2.log |
vulnerable-kernel CONTROL run (hook passthrough) and FIXED-kernel run β hbh byte 0 = 0x2c (44) |
env.txt |
guest uname/build info |
build.sh / run.sh |
the exact (final) procedures used |
Reproduce (summary β full detail in VERDICT.md)
- Copy
instr-ip6_input.cover the guest's/usr/src/sys/netinet6/ip6_input.c cd /usr/src && make quickkernel KERNCONF=X86_64_GENERIC -DNO_WERROR && make installkernel KERNCONF=X86_64_GENERIC, rebootifconfig lo0 mtu 1500; buildtrigger.c; starttcpdump -i lo0 -U -w cap.pcap ip6sysctl net.inet6.ip6.pfildemo_enable=1then run./triggerβ vulnerable kernel:receiver: no datagram; wire shows hbh.nxt11(fragment header unreachable β receivers parse a bogus UDP header)sysctl net.inet6.ip6.pfildemo_enable=0(control) then./triggerβ datagram delivered; wire shows hbh.nxt2c- Apply
fix.diffto the guest'sip6_output.c,quickkernel+installkernel, reboot, repeat step 4 withenable=1β fixed kernel: datagram delivered, hbh.nxt2ceven though the chain was replaced
Expected result table (observed)
| kernel | pfil hook | hbh.nxt on wire | datagram delivered |
|---|---|---|---|
| stock (vulnerable) | passthrough | 0x2c (44) | yes |
| stock (vulnerable) | replaces chain | 0x11 (17) β write hit freed memory | no (corrupt packet) |
| + fix.diff | replaces chain | 0x2c (44) | yes |
DF-3307 VERDICT β reproduced (use-after-free write, root-gated)
Bottom line
ip6_output() fills exthdrs.ip6e_hbh/dest1/rthdr with pointers into the
packet's mbuf chain before running the pfil output hooks
(sys/netinet6/ip6_output.c:164-173, chained at :249-282). pfil_run_hooks()
(sys/net/pfil.c) hands every hook struct mbuf **mp and hooks are allowed to
free the chain and substitute a copy while still returning success β exactly
what a normalizing firewall does. After the hooks, ip6_output re-derives only
ip6 (:662) and keeps using the stale exthdrs[] pointers in the
fragmentation path:
- read:
nextproto = *mtod(exthdrs.ip6e_hbh, u_char *)(:785, :782, :779) - write:
*mtod(exthdrs.ip6e_hbh, u_char *) = IPPROTO_FRAGMENT(:786, :783, :780)
Both operate on freed memory once a hook replaced the chain. The stale
write puts byte 0x2c at the start of the freed (cluster-backed) hbh header;
the stale read decides the ip6f_nxt protocol byte of every emitted fragment.
How it was demonstrated (guest: DragonFly 6.5-DEVELOPMENT, INVARIANTS kernel)
A demo PFIL_OUT hook (instr-ip6_input.c, compiled into test kernels β
see "kld note" below) replaces the chain of one >5000-byte ::1 UDP6 packet
that carries a 256-byte Hop-by-Hop option (root; options are privilege-gated)
with a deep copy, frees the original, and returns 0.
The packet then fragments (lo0 MTU lowered to 1500). A/B/C results
(run.log / run.2.log, tcpdump -X hex at offset 0x20 row = bytes 32..47;
byte 40 = hbh byte 0):
| run | kernel | hook | hbh byte 0 | outcome |
|---|---|---|---|---|
| TEST | stock | replaces chain | 0x11 (17/UDP) |
5 corrupt fragments; fragment header unreachable; receiver gets nothing; tcpdump decodes a bogus UDP header ("truncated-udplength 3", sport 0x1100 dport 1 ulen 3) |
| CONTROL | stock | passthrough | 0x2c (44/FRAGMENT) |
fragments correct, 5000-byte datagram delivered |
| FIXED | +fix.diff | replaces chain | 0x2c (44/FRAGMENT) |
datagram delivered |
The CONTROL run proves the rewrite code path executes and writes 44 when the cached pointers are still valid; the TEST run proves that after a legal chain replacement the same write lands outside the live packet β i.e. in the freed mbuf's target memory β while the live packet keeps nxt=17 and is emitted structurally corrupt. The FIXED kernel restores correctness even while the chain is being replaced.
(An additional in-hook attempt to reclaim the freed hbh cluster and plant a 0xAA canary read-proof is included in instr-ip6_input.c; on this guest the mbuf-cluster cache did not return the just-freed hbh cluster within the hook's window, so the canary probes were inconclusive β the wire-level A/B/C above is the decisive evidence.)
Threat model / severity
- The victim write is one byte (0x2c) at freed-cluster offset 0, plus a 1-byte stale read that becomes the wire protocol byte. Because freed clusters are immediately reusable by any network path, both are genuine UAF primitives into the mbuf cluster slab.
- Trigger gates: hbh/dest options require privilege at ip6_setpktoption (ip6_output.c:2763, :2794 β sticky options are EPERM even for root per DF-0313, but root CAN pass them per-packet as sendmsg cmsg), and a chain-replacing PFIL_OUT hook must be registered (pf-style normalization). Both are root/admin-side conditions, so this is not an unprivileged escalation; it is kernel self-corruption under firewalling.
- Observable packet corruption (every fragmented hbh packet becomes garbage on the wire) is the reliable impact; heap corruption is the theoretical ceiling.
Fix validation
fix.diff (applied to the guest's /usr/src copy only; the audit tree is
read-only) replaces the stale-pointer cascade with ip6_lastunfignxt(),
which re-locates the last unfragmentable next-header field in the current
chain via m_getptr(), failing closed (EINVAL drop) on malformed headers.
- Baseline reproduced on stock-source kernel #5/#6 (uname in env.txt).
- Patched kernel #7: TEST run with chain replacement delivers the datagram, hbh.nxt = 0x2c on the wire. Bad behavior gone; no regressions in the control run.
kld note (why the hook is compiled-in)
kldload of ANY freshly-built module (kprintf-only test module, kmod.mk
flags with/without KERNBUILDDIR, hand-linked variants) reliably panics this
guest in strcmp (type 9 trap) during module registration, while stock
/boot/kernel modules (if_tap.ko) load fine β the /usr/src snapshot does not
match the running kernel's module ABI on this image. The demo hook was
therefore compiled into instrumented kernels via make quickkernel, which
boots and runs correctly (kernels #1..#7 built and tested during this
session).
Kernel references
- sys/netinet6/ip6_output.c:164-173 (exthdrs fill), :249-282 (chaining), :658-663 (pfil_run_hooks β m may be replaced), :778-790 (stale read+write)
- sys/net/pfil.c:pfil_run_hooks (hook may replace *mp)
- sys/netinet6/raw_ip6.c:302, sys/netinet6/udp6_output.c:134 (cmsg privilege gate that makes the option root-only)
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedApplied fix.diff to the guest /usr/src copy, quickkernel+installkernel, rebooted, re-ran the exact TEST (pfildemo_enable=1, chain replaced, pfildemo_replaced=1): hbh.nxt = 0x2c (44) on the wire and the 5000-byte datagram was delivered - the previously-observed corruption is gone with no behavioral regression in the control run.
run.2.log FIXED section (tcpdump byte-40 = 2c, receiver: got 5000 bytes); env.txt records kernel #7 build/install.
Confirmed kernel references
Detail
Exploit chain
root UDP6 socket -> sendmsg(::1, 5000B payload, 256B IPV6_HOPOPTS cmsg) with lo0 MTU lowered to 1500 -> ip6_output chains hbh, runs PFIL_OUT hook -> hook deep-copies chain, m_freem(old), returns copy with rv=0 (legal pfil API) -> fragmentation path dereferences freed exthdrs.ip6e_hbh: stale read feeds ip6f_nxt, stale write of 0x2c lands in freed cluster -> emitted packet structurally corrupt; freed cluster is in the reusable cluster pool (heap corruption primitive). Unprivileged escalation: not available (options privilege-gated).
Evidence (decisive lines)
["run.log: vulnerable kernel TEST run, tcpdump -X shows byte 40 = 0x11 (17) and 'truncated-udplength 3' misparse", 'run.2.log: CONTROL run (hook passthrough) and FIXED run both show byte 40 = 0x2c (44)', 'VERDICT.md: full A/B/C matrix and kernel reference lines', 'instr-ip6_input.c: the demo PFIL_OUT hook compiled into the test kernels (pfildemo_* sysctls)', 'fix.diff: verified fix re-locating the next-header field via m_getptr()']
PoC changes
Original sketch assumed a loadable kld pfil hook; kldloading ANY freshly built module panics this guest's linker (strcmp GPF), so the hook was compiled into instrumented quickkernel builds instead. Trigger was adapted twice for host-side caps the sketch ignored: sendmsg ancillary data is capped at MLEN (uipc_syscalls.c:935-936 -> hbh shrunk from 2048 to 256 bytes) and UDP6 sends are size-capped (EMSGSIZE at 60000B -> payload 5000B); lo0 MTU lowered to 1500 to force fragmentation; ip6h_len arithmetic fixed ((len+1)*8). A cluster-reclaim canary for direct read-proof stayed inconclusive (cluster cache did not return the hbh cluster in-window); wire-level A/B/C used as decisive proof instead.
Verified recommended fix
After pfil_run_hooks, re-locate the last unfragmentable extension header's next-header byte in the current mbuf chain (m_getptr walk) instead of dereferencing the pre-hook exthdrs[] pointers; see fix.diff.
Verdict
Reproduced on the QEMU guest (DragonFly 6.5-DEVELOPMENT). ip6_output() caches exthdrs.ip6e_hbh/dest1/rthdr pointers into the packet chain before pfil_run_hooks() and, after the hooks legally replace the chain, the fragmentation path both READS nextproto and WRITES IPPROTO_FRAGMENT through those stale pointers (ip6_output.c:778-790) into freed memory. With a demo PFIL_OUT hook that deep-copies and frees the original chain, the emitted fragments carry hbh.nxt=17 instead of 44 (tcpdump hex 0x11 vs 0x2c at byte 40), the fragment header is unreachable, every receiver parses a bogus UDP header, and the datagram is never delivered; a passthrough control run on the same kernel shows nxt=44 and delivery, isolating the mbuf replacement as the only variable. The underlying primitive is a 1-byte write-after-free into the mbuf-cluster slab plus a 1-byte stale read that becomes the wire protocol byte of every fragment (kernel heap corruption class); the reliable demonstrated impact is silent packet corruption (dos). Trigger is root-gated (hbh option privilege check) plus a chain-replacing pfil hook, hence severity Low, not an unprivileged escalation.
No comments yet.