# DF-0605 — VERDICT

**Verdict: REPRODUCED (code-level; live trigger rare).** The bug is real
per line-by-line code analysis of the cited paths. Live panic
manifestation is statistically rare (the UAF read usually returns
stale-but-valid RB tree pointers, masking the corruption), but the
synchronization hole is unambiguous in the source.

**Severity: Medium (root-only trigger, no privilege boundary to cross)**

---

## Mechanism (confirmed by code review, path:line citations)

`pfi_get_ifaces()` at `sys/net/pf/pf_if.c:764-790` walks the global
`pfi_ifs` RB tree with only CPU-local `crit_enter()` (line 769).
`crit_enter()` prevents preemption on the local CPU but does NOT
serialize against other CPUs. The walker captures `nextp` via
`RB_NEXT(pfi_ifhead, &pfi_ifs, p)` at line 771 (and again at line 783
after the `copyout`) and uses `p = nextp` on the next loop iteration
without any guarantee that `nextp` is still allocated or still in the
tree.

Although `pfi_get_ifaces`'s only caller, `pfioctl()` at
`sys/net/pf/pf_ioctl.c:981-989`, **does** take `lwkt_gettoken(&pf_token)`
for the entire ioctl duration, the **mutator** side does NOT. The
`ifnet_detach_event` handler chain:

  `if_detach()` (`sys/net/if.c:949`) →
  `EVENTHANDLER_INVOKE(ifnet_detach_event, ifp)` (`sys/net/if.c:958`,
  fired WITHOUT `pf_token` and WITHOUT `ifnet_lock` — that lock is only
  taken later at line 970) →
  `pfi_detach_ifnet_event()` (`sys/net/pf/pf_if.c:882`) →
  `pfi_detach_ifnet()` (`sys/net/pf/pf_if.c:297`) →
  sets `kif->pfik_ifp = NULL` (line 307) and calls
  `pfi_kif_unref(kif, PFI_KIF_REF_NONE)` (line 309) →
  `pfi_kif_unref()` at `sys/net/pf/pf_if.c:246-253` does
  `RB_REMOVE(pfi_ifhead, &pfi_ifs, kif); kfree(kif, M_PFI);` when
  `pfik_ifp==NULL && pfik_group==NULL && kif != pfi_all &&
  pfik_rules==0 && pfik_states==0`.

`pfi_detach_ifnet` itself uses only `crit_enter()` (line 304), so it
runs concurrently with the walker on another CPU. The walker's `nextp`,
captured before the free, may point at the freed kif; the next loop
iteration's `pfi_skip_if(name, p)` then reads `p->pfik_name` (line 800)
and `p->pfik_ifp` (line 807) from freed memory, and
`RB_NEXT(pfi_ifhead, &pfi_ifs, p)` at line 771/783 reads stale RB tree
pointers from the freed slab chunk.

The same unlocked-walk pattern is present in `pfi_set_flags()`
(pf_if.c:821-833) and `pfi_clear_flags()` (pf_if.c:836-848), which use
`RB_FOREACH` over `pfi_ifs` with only `crit_enter()`.

## Live reproduction attempts

- **Build**: `cc -O2 -o race race.c` (header-free C harness; no
  dependency on `<net/pfvar.h>` which is not in `/usr/include`). Builds
  cleanly.
- **Probe**: kernel `sizeof(struct pfi_kif)` = 224 bytes (matches the
  `pfiio_esize` check at `pf_ioctl.c:3022`).
- **Race harness** (`race.c`): spawns N walker processes (each tight-
  looping `DIOCIGETIFACES` on `/dev/pf`, pinned to CPU 0..N-1) and M
  mutator processes (each tight-looping `SIOCIFCREATE`/`SIOCIFDESTROY`
  on `vlanN`, pinned to CPU N..N+M-1).
- **Result**: One observed guest wedge-to-DDB on the first invocation
  (`vm.sh reset` reported "guest not answering (likely DDB on panic)"
  and the QEMU process was killed to recover). The serial boot log did
  not capture a clean panic signature — most likely because the
  `vlanN: MAC address` syslog flooding (2529 lines in 30s) saturated
  the serial buffer. After muting console logging
  (`sysctl kern.log_console_output=0`), subsequent 30s/60s/90s races
  with 4 walkers + 3 mutators did not panic.

The race is genuinely hard to win because:
  1. The walker's window between `nextp = RB_NEXT(...)` (line 771) and
     the next iteration's `pfi_skip_if(name, p=nextp)` is microseconds.
  2. Even when the race is won, INVARIANTS only poisons the first
     64 bytes of the freed chunk (`WEIRD_ADDR = 0xdeadc0de`,
     `sizeof(weirdary) = 64` at `sys/kern/kern_slaballoc.c:231,313`).
     `pfik_ifp` lives at offset ~176 — outside the poisoned region, so
     it retains its NULL value (set by `pfi_detach_ifnet` at line 307
     before the `kfree`). The walker's `if (p->pfik_ifp != NULL)` test
     at line 807 evaluates false, `pfi_skip_if` returns 1 (skip), and
     the walk continues silently along stale RB-tree pointers which
     usually still point to valid tree nodes.
  3. A visible panic requires the freed slab chunk to be reused for an
     object that writes non-NULL data over the RB_ENTRY/pfik_ifp fields
     before the walker re-reads them — a narrow timing condition.

This is the typical profile of a real-but-hard-to-trigger kernel UAF:
the bug is unambiguously present in the source, but the live
manifestation is probabilistic and often silent. The race IS the bug.

## Why this is Medium (not higher)

- **Privilege requirement**: `pf.ko` is **not loaded by default** (the
  `with-src` baseline has no `/dev/pf` until `kldload pf.ko` is run as
  root). `/dev/pf` is `crw------- root:wheel` (0600). `DIOCIGETIFACES`
  therefore requires root. `SIOCIFCREATE`/`SIOCIFDESTROY` are gated by
  `caps_priv_check(cred, SYSCAP_RESTRICTEDROOT)` at `sys/net/if.c:2007,
  2013`. Both sides of the race are **root-only**. There is no
  unprivileged path to trigger this bug.

- **No privilege boundary to cross**: root→kernel is game-over by
  definition (root can `kldload` arbitrary code). A root-only kernel
  panic is a robustness/DoS issue, not a privilege escalation.

- **Race complexity**: the walker's `nextp` must land on the exact kif
  being freed on a remote CPU within a microsecond window.

## Exploit chain / escalation

**none — no escalation chain is meaningful for this finding.**

Per the bright-line rule in the runner procedure: an escalation chain
must be exercisable by an unprivileged user end-to-end. This finding
has no unprivileged path at all (`/dev/pf` is 0600 root:wheel; PF is a
non-default module requiring `kldload`; both `DIOCIGETIFACES` and
`SIOCIFCREATE`/`SIOCIFDESTROY` require root). Root→kernel is game-over
by definition. So this is a **root→kernel hardening/robustness gap**,
not an escalation primitive. The realistic impact ceiling is a kernel
panic (DoS) caused by an administrator who loads PF and concurrently
provisions/tears-down interfaces (e.g. a virtualization host or router
with dynamic VLAN/tap/gre churn).

## PoC changes

The original `findings/poc/DF-0605/README.md` suggested a shell driver
(`pfctl -i all -v` loop + `ifconfig vlanN create/destroy` loop). I
implemented it as a header-free C harness (`race.c`) because:

1. `<net/pfvar.h>` is **not installed** in `/usr/include` (PF is a
   module), so a PoC that `#include`s it cannot compile from the
   standard include path. The harness inlines the necessary struct
   definitions (`struct pfi_kif`, `struct pfioc_iface`) and the
   `DIOCIGETIFACES` `_IOWR` macro verbatim from
   `sys/net/pf/pfvar.h`.
2. `cpumask_t` in DragonFly is `struct { u64 ary[4]; }` (32 bytes),
   not a single `unsigned long` — the `lwp_setaffinity` (syscall 544)
   pinning had to use the right mask size.
3. The harness probes the kernel's `sizeof(struct pfi_kif)` at startup
   (the `pfiio_esize` check at `pf_ioctl.c:3022` returns `ENODEV`
   before `pfi_get_ifaces` is called if the element size is wrong) —
   it found 224 bytes, vs the 216-byte C-layout in `race.c` (8 bytes
   of compiler-injected tail padding). The probe handles both.
4. The harness forks N walker + M mutator children (default 4 + 3),
   pins them across CPUs, and arms per-child SIGALRM so they all
   terminate cleanly on timeout (the original 1-walker/1-mutator
   version almost never hit the window).
5. `race.sh` is the original shell driver, kept for reference.

## Recommended fix

`fix.diff` adds `lwkt_gettoken(&pf_token)` / `lwkt_reltoken(&pf_token)`
around the bodies of:

- `pfi_attach_ifnet` (pf_if.c:278)
- `pfi_detach_ifnet` (pf_if.c:296) — the actual free path
- `pfi_attach_ifgroup` (pf_if.c:313)
- `pfi_detach_ifgroup` (pf_if.c:327)
- `pfi_group_change` (pf_if.c:343)
- `pfi_get_ifaces` (pf_if.c:787) — defense-in-depth; recursive-safe
  since the only current caller (`pfioctl`) already holds `pf_token`,
  but guards against future callers that forget
- `pfi_set_flags` (pf_if.c:821) and `pfi_clear_flags` (pf_if.c:836) —
  same unlocked-walk pattern

The fix is conservative and minimal: it adds NO new locks, just takes
the existing `pf_token` (already used by the rest of `pfioctl` and by
`pf.c` packet processing) at the tree-mutation entry points. Once both
walker and mutator hold the same token, lwkt_token's exclusive-acquire
semantics serialize them across CPUs and the race window disappears.

This **matches the finding markdown's `## Recommended fix` proposal**
(which asked for `pf_token` around the walk and the tree-mutating event
handlers), and additionally covers `pfi_set_flags`/`pfi_clear_flags`
which have the identical unlocked-walk pattern.

## Fix validation (Phase 8)

- **Baseline (#0 unpatched)**: applied fix.diff to `/usr/src`,
  `make -j6 nativekernel KERNCONF=X86_64_GENERIC` succeeded (rc=0,
  no errors; full build log in `fix_build.log`).
- **Patched (#1)**: copied `kernel.stripped` → `/boot/kernel/kernel`,
  `kernel.debug` → `/boot/kernel/kernel.debug`, rebooted.
  `kern.version` correctly bumped from
  `6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026` to
  `6.5-DEVELOPMENT #1: Wed Jul  8 19:08:12 UTC 2026`.
- **Post-fix behavior**: `pf.ko` loads, `/dev/pf` appears, `pfctl -s
  info` works, `ifconfig vlanN create/destroy` works, and the same
  race harness (4 walkers + 3 mutators, 90s) ran clean with no panic.
- **Caveat**: the race is statistically rare on the unpatched kernel
  too (it requires the walker's microsecond window to overlap a
  remote-CPU free AND the freed chunk's RB-tree pointers to be
  corrupted by slab reuse). The fix is therefore validated primarily
  by code analysis: with `pf_token` held by both walker and mutator,
  lwkt_token's exclusive-acquire semantics serialize them across CPUs
  and the race window is eliminated by construction. A successful
  compile + boot + clean PoC run confirms the patch does not regress
  PF functionality.

## Files

- `race.c` — C harness (header-free; N walkers + M mutators across CPUs)
- `race.sh` — original shell driver, kept for reference
- `build.sh` / `run.sh` — exact reproduce commands
- `fix.diff` — git-apply-able fix (15 hunks, all apply cleanly)
- `fix_build.log` — full untrimmed single-fix kernel build output (rc=0)
- `fix_run.log` — patched-kernel PoC run (clean exit, no panic)
- `env.txt` — guest environment for this run
