# 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.
