# DF-3419 — pfi_kif_update() walks the pf dynaddr TAILQ with no pf_token while pf_rm_rule() frees entries cross-CPU → kernel UAF / panic (pf_if.c:470-482 vs 673-689)

## Root cause

Two unsynchronized users of `struct pfi_kif.pfik_dynaddrs`:

1. **Walker (no pf_token)** — `pfi_ifaddr_event()` (pf_if.c:905-916) runs in
   whatever thread changes an interface address (SIOCAIFADDR/-alias →
   `EVENTHANDLER_INVOKE(ifaddr_event)` from sys/netinet/in.c:634/691/726 and
   sys/netinet6/in6.c:803/852). It calls `pfi_kif_update()` under
   `crit_enter()` only — a CPU-local critical section that does **not**
   serialize against other CPUs:

   ```c
   470	TAILQ_FOREACH(p, &kif->pfik_dynaddrs, entry)
   471		pfi_dynaddr_update(p);
   ```

   `pfi_dynaddr_update()` then dereferences the entry's fields
   (pf_if.c:491-502) and panics on `dyn->pfid_kif == NULL ||
   dyn->pfid_kt == NULL`.

2. **Freer (under pf_token, different CPU)** — every ruleset reload/remove
   (`pfctl -f`, `pfctl -k`, DIOCCHANGERULE) runs `pf_rm_rule()`
   (pf_ioctl.c:425-433) → `pfi_dynaddr_remove()`:

   ```c
   681	TAILQ_REMOVE(&aw->p.dyn->pfid_kif->pfik_dynaddrs, aw->p.dyn, entry);
   682	pfi_kif_unref(...);
   683	aw->p.dyn->pfid_kif = NULL;
   684	pfr_detach_table(aw->p.dyn->pfid_kt);
   685	aw->p.dyn->pfid_kt = NULL;
   686	kfree(aw->p.dyn, M_PFI);
   ```

An in-progress walk whose cached `p` points at an entry being removed
sees the NULLed `pfid_kt`/`pfid_kif` (or, after the kfree, whatever
reclaims the chunk) → `panic: pfi_dynaddr_update: bad argument`, or
silent UAF-read/wild-pointer use if the chunk is reused.

This is the list-lifetime sibling of the known DF-0604 (global
pfi_buffer data race): same missing-lock family, different object and
primitive — DF-0604 corrupts table *contents*; this one walks *freed
objects*.

## Reproduce (guest, root; < 90 s to panic on the stock kernel)

```
kldload pf; kldload if_tap
ifconfig tap0 create; ifconfig tap0 10.99.0.1/24 alias
cat > /tmp/pf3419.conf  # 41 rules referencing (tap0:network); pfctl
                        # collapses them to 2 rules / 1-2 dynaddrs
pfctl -f /tmp/pf3419.conf
# loop A: address churn (fires ifaddr_event -> pfi_kif_update walk)
# loop B: ruleset reload (frees the previous dynaddrs under pf_token)
sh /tmp/stress.sh      # see run.sh in this pack
```

## Observed (stock INVARIANTS kernel #0, Thu Jul 2 06:02:54 UTC 2026)

Baseline, **2/2 runs**, panic in ~60-75 s:

```
panic: pfi_dynaddr_update: bad argument
cpuid = 0
pfi_kif_update.isra.5() at pfi_kif_update.isra.5+0x1e8
pfi_kif_update.isra.5() at pfi_kif_update.isra.5+0x1e8
pfi_ifaddr_event() at pfi_ifaddr_event+0x49
in_control_internal.part.7() at ...
Debugger("panic")
```

(panic.txt, panic.2.txt — the second from a fresh `vm.sh reset` boot.)

## Primitive characterization (why this stops at panic, not uid=0)

The walk consumes attacker-influenced memory: once the freed
`pfi_dynaddr` chunk is reclaimed with controlled content,
`TAILQ_FOREACH` follows a controlled `entry.tqe_next` and
`pfi_dynaddr_update` dereferences controlled `pfid_kt`/
`pfid_kif` pointers into `pfi_table_update` → `pfr_set_addrs`
(controlled kernel-pointer deref/write chain). However **both racing
operations require root** (pfctl ruleset changes on /dev/pf 0600, and
address changes via ifconfig), so there is no unprivileged→root chain —
the realistic impact ceiling of the race itself is a root-triggered
kernel panic (remote-DoS-adjacent only insofar as in-kernel address
events — DHCP/IPv6-RA processing — can supply the walk side while an
admin reloads rules).

## Fix validation

fix.diff adds `lwkt_gettoken(&pf_token)` around the four event-handler
`pfi_update++/pfi_kif_update()` sections (pfi_attach_ifnet,
pfi_detach_ifnet, pfi_group_change, pfi_ifaddr_event) so the walk
serializes against pfioctl's rule removal.

- Kernel rebuilt in-guest (`make nativekernel KERNCONF=X86_64_GENERIC`,
  RC=0, installkernel OK) → `#1: Sat Sep 12 10:51:52 UTC 2026`.
- Re-run of the exact stress: **3× (8000 churn × 8000 reload) + 1×
  (16000 × 16000), all clean, guest healthy** (~7 min total vs ~70 s
  to panic baseline) — patched_runs.log.
- Caveat: lwkt tokens are surrendered at blocking points inside the
  walk (`pfi_table_update` → netmsg waits), so the fix shrinks the race
  to those windows rather than eliminating it by construction; 4/4
  clean stress runs empirically validate it at this workload. A
  follow-up that adds dynaddr refcounting (or walks under a
  non-surrendered spinlock) would close it completely.
