β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3307

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3307 Β· 14 files
FileTypeDescriptionSize
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)

  1. Copy instr-ip6_input.c over the guest's /usr/src/sys/netinet6/ip6_input.c
  2. cd /usr/src && make quickkernel KERNCONF=X86_64_GENERIC -DNO_WERROR && make installkernel KERNCONF=X86_64_GENERIC, reboot
  3. ifconfig lo0 mtu 1500; build trigger.c; start tcpdump -i lo0 -U -w cap.pcap ip6
  4. sysctl net.inet6.ip6.pfildemo_enable=1 then run ./trigger β†’ vulnerable kernel: receiver: no datagram; wire shows hbh.nxt 11 (fragment header unreachable β†’ receivers parse a bogus UDP header)
  5. sysctl net.inet6.ip6.pfildemo_enable=0 (control) then ./trigger β†’ datagram delivered; wire shows hbh.nxt 2c
  6. Apply fix.diff to the guest's ip6_output.c, quickkernel+installkernel, reboot, repeat step 4 with enable=1 β†’ fixed kernel: datagram delivered, hbh.nxt 2c even 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
VERDICT.md
↓ download raw

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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied 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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #7: Thu Sep 10 12:19:45 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.