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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3419 Β· 7 files
FileTypeDescriptionSize
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:

  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.

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline: 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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 12 10:51:52 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.