pfi_kif_update() walks kif->pfik_dynaddrs under crit_enter() only while pf_rm_rule()/pfi_dynaddr_remove() free the entries under pf_token on another CPU β cross-CPU use-after-free of pfi_dynaddr (kernel panic)
Summary
pfi_ifaddr_event fires on every interface address add/delete (including kernel-driven DHCP/RA events) and walks the kif's dynaddr TAILQ with only a CPU-local critical section; concurrently, any ruleset change on another CPU (pfctl -f/-k, DIOCCHANGERULE) frees every dynaddr of the outgoing ruleset via pf_rm_rule -> pfi_dynaddr_remove while holding pf_token - which the walker never takes. REPRODUCED 2/2 on stock INVARIANTS guest, panic in ~60-75s per run: 'panic: pfi_dynaddr_update: bad argument', stack pfi_kif_update <- pfi_ifaddr_event <- in_control_internal (fresh-boot rerun included). Distinct from DF-0604 (global pfi_buffer content race: this is the dynaddr list LIFETIME) and DF-0605 (pfi_get_ifaces RB walk). Primitive: a reclaimed dynaddr chunk with controlled contents yields controlled tqe_next + controlled pfid_kt/pfid_kif consumption through pfr_set_addrs; no unpriv->root chain (both racing ops require root). Ceiling: root-triggerable panic + silent table corruption. Fix validated (pf_token around the four event handlers, rebuilt kernel): exact PoC clean 3x8000x8000 + 1x16000x16000 (~7min vs ~70s-to-panic baseline).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3419 Β· 7 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.5 KB | β raw | |
| run.sh | β | 90 B | view raw | |
| fix.diff | β | 1.1 KB | view raw | |
| panic.txt | β | 2.3 KB | view raw | |
| panic.2.txt | β | 2.3 KB | view raw | |
| patched_runs.log | β | 229 B | view raw | |
| verdict.json | β | 4.9 KB | view raw |
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:
- 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 callspfi_kif_update()undercrit_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.
- Freer (under pf_token, different CPU) β every ruleset reload/remove
(
pfctl -f,pfctl -k, DIOCCHANGERULE) runspf_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.
Fix verification
fixedBaseline: 2/2 panics in ~60-75s on stock kernel #0. Patched kernel #1 (pf_token in the four event handlers, built and installed in-guest, RC=0): 4/4 clean runs of the exact PoC incl. one double-length 16000x16000 round (~7min total, ~6x the baseline time-to-panic). Bad behavior gone. Residual: lwkt tokens are surrendered at blocking points inside pfi_table_update, so a fully robust fix should also pin dynaddr entries (refcount) - noted for upstream.
findings/poc/DF-3419/fix.diff; findings/poc/DF-3419/patched_runs.log
Confirmed kernel references
Detail
Exploit chain
root: load ruleset with (tap0:network) dynaddr + root: address churn on tap0 (or in-kernel DHCPv6/RA address events) while concurrently reloading the ruleset -> pfi_kif_update walks dynaddr being freed -> panic (observed) / freed-chunk walk with reclaimed contents -> controlled pointer chain into pfr_set_addrs (characterized, not weaponized - no unpriv trigger exists since /dev/pf is 0600 and ifconfig is privileged)
Evidence (decisive lines)
['findings/poc/DF-3419/panic.txt β first baseline panic: full console trace', 'findings/poc/DF-3419/panic.2.txt β second baseline panic after fresh vm.sh reset (fresh boot, same signature)', 'findings/poc/DF-3419/patched_runs.log β patched kernel (#1) stress results: BOTH-DONE-NOPANIC x3 + 16K-STRESS-DONE-NOPANIC', 'findings/poc/DF-3419/run.sh β exact reproduction script', 'findings/poc/DF-3419/fix.diff β the 4-hunk pf_token patch applied to the guest /usr/src and validated', 'sys/net/pf/pf_if.c:470-482 β unlocked TAILQ walk', 'sys/net/pf/pf_if.c:673-689 β concurrent TAILQ_REMOVE + kfree under pf_token', 'sys/net/pf/pf_ioctl.c:425-433 β pf_rm_rule invoking pfi_dynaddr_remove on ruleset change']
PoC changes
Authored from scratch (no seed). Shell stress: address alias/-alias churn on tap0 concurrent with pfctl -f reload loop of a 41-line ruleset using (tap0:network) dynaddrs (pfctl collapses it to 2 rules/1-2 dynaddrs per reload - race hits anyway, twice).
Verified recommended fix
Take lwkt_gettoken(&pf_token) around pfi_update++/pfi_kif_update in the four event handlers (pfi_attach_ifnet/pfi_detach_ifnet/pfi_group_change/pfi_ifaddr_event), per fix.diff; longer term add dynaddr refcounting so walkers pin entries across blocking points
Verdict
Reproduced 2/2 on the stock INVARIANTS 6-cpu guest (panic in ~60-75s per run, identical stack): pfi_ifaddr_event -> pfi_kif_update walks kif->pfik_dynaddrs under crit_enter() only (no pf_token, pf_if.c:905-916/470-482) while a concurrent pfctl -f on another CPU frees every dynaddr of the old ruleset via pf_rm_rule -> pfi_dynaddr_remove (pf_ioctl.c:425-433 -> pf_if.c:681-686, under pf_token which the walker never takes). The walker dereferences the entry mid-teardown -> panic: pfi_dynaddr_update: bad argument (pf_if.c:491-492), or a freed-chunk UAF read if the kfree lands first. Primitive: with the freed pfi_dynaddr reclaimed by controlled data, the walk follows a controlled tqe_next and consumes controlled pfid_kt/pfid_kif pointers through pfi_table_update/pfr_set_addrs - but both racing operations require root, so the realistic ceiling is a root-triggered kernel panic (plus silent table corruption), not unpriv->root. Fix validated: pf_token added to the four event handlers, kernel rebuilt in-guest (#1 Sat Sep 12 10:51:52), the exact stress then ran clean 3x (8000x8000) + 1x (16000x16000) ~7min total vs ~70s-to-panic baseline.
No comments yet.