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

Use-after-free in pfi_get_ifaces() RB tree walk under concurrent interface detach

| Field | Value | |--------------|------------------------------------------------|--------| | ID | DF-0605 | | Status | new | | Severity | Medium | | CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H | | CWE | CWE-416 Use After Free | | File | sys/net/pf/pf_if.c | | Lines | 763-790 (walk), 246-253 (kif_unref free path) | | Area | net/pf (firewall interface tracking) | | Confidence | likely | | Discovered | 2026-07-02 | | Reported | pending |

Summary

pfi_get_ifaces() walks the global pfi_ifs RB tree using nextp = RB_NEXT(p) captured before processing each entry. The walk is protected only by CPU-local crit_enter()/crit_exit(), which does NOT block other CPUs. Concurrently, an ifnet_detach_event on another CPU runs pfi_detach_ifnet() which calls pfi_kif_unref(kif, PFI_KIF_REF_NONE); if the kif has no rule/state refs and was already cleared of pfik_ifp, pfi_kif_unref() does RB_REMOVE + kfree (pf_if.c:246-253). The freed kif may be exactly the nextp the walker reaches on its next loop iteration, leading to a use-after-free when pfi_skip_if() reads p->pfik_name from freed memory.

Root cause

pfi_get_ifaces() at sys/net/pf/pf_if.c:769-787 holds only crit_enter() β€” a CPU-local critical section that prevents preemption on the local CPU but does not serialize against other CPUs (no lwkt_token, no pf_token β€” contrast pfioctl() at pf_ioctl.c:989 which DOES take pf_token).

The walk captures nextp = RB_NEXT(pfi_ifhead, &pfi_ifs, p) at pf_if.c:771 and again at pf_if.c:783, then uses p = nextp on the next iteration without any guarantee that nextp is still in the tree, still allocated, or still the same kif.

On a remote CPU, ifnet_detach_event fires pfi_detach_ifnet_event (pf_if.c:882) β†’ pfi_detach_ifnet (pf_if.c:297) which sets kif->pfik_ifp = NULL (pf_if.c:307) and then pfi_kif_unref(kif, PFI_KIF_REF_NONE) (pf_if.c:309). pfi_kif_unref at pf_if.c:246-253 determines that pfik_ifp==NULL, pfik_group==NULL, kif != pfi_all, pfik_rules==0, pfik_states==0 β†’ RB_REMOVE(pfi_ifhead, &pfi_ifs, kif); kfree(kif, M_PFI);.

The walker's nextp now points to freed memory; the next loop's pfi_skip_if(name, p) (pf_if.c:772, called via pfi_skip_if at pf_if.c:800) does strcmp(p->pfik_name, filter) and TAILQ_FOREACH(... &p->pfik_ifp->if_groups ...) (pf_if.c:809) β€” UAF read, and if the memory is reused with a controlled pfik_ifp, an attacker- controlled kernel-pointer deref.

The local pfi_kif_ref/unref around copyout (pf_if.c:777,784) does NOT cover nextp's lifetime, and is non-atomic against the remote decrement at pf_if.c:233 (also a refcount-loss race on SMP).

Threat model & preconditions

  • Attacker position: an attacker who can both (a) open /dev/pf to issue DIOCIGETIFACES and (b) trigger concurrent ifnet detach events (root running ifconfig vlanN destroy / ifconfig tapN destroy, or a remote attacker who can cause interface teardown β€” e.g. a wlan(4) station leaving an IBSS, ppp(4) link failure, carp(4) demotion with the right config) creates the race window.
  • Privileges gained or impact: once the UAF read occurs, three outcomes are possible: kernel panic (most likely, A:H DoS), heap contents disclosure via the pfik_name strcmp leaking into the DIOCIGETIFACES reply buffer, or β€” with heap grooming β€” controlled dereference via a crafted fake ifnet pointer (privilege escalation).
  • Required config or capabilities: root + pf enabled; 2+ CPU system; ability to create+destroy interfaces concurrently.
  • Reachability: race the DIOCIGETIFACES walk against concurrent ifconfig <iface> destroy.

Proof of concept

PoC: findings/poc/DF-0605/race.sh (shell driver). On a 2+ CPU DragonFly system with pf enabled:

# CPU 0: loop DIOCIGETIFACES
while :; do pfctl -i all -v 2>/dev/null; done &     # or a tight C ioctl loop

# CPU 1: loop creating+destroying many interfaces
while :; do
    for i in $(seq 0 64); do ifconfig vlan$i create 2>/dev/null; done
    for i in $(seq 0 64); do ifconfig vlan$i destroy 2>/dev/null; done
done &

Expected outcome

Kernel panic with a stack trace through pfi_skip_if / strcmp / TAILQ_FOREACH inside pfi_get_ifaces (look for fatal trap 12: page fault near pf_if.c:800 or pf_if.c:809). Reproducible by pinning the two loops to different CPUs via cpuset -x / usched_setcpu.

Impact

  • Blast radius: any SMP DragonFly system running pf with concurrent interface create/destroy activity (routers with dynamic VLAN/tap/gre provisioning, virtualization hosts, containers).
  • Severity rationale: Medium. Privileged attacker (root), high race complexity (the walker's nextp must land on the exact kif being freed on another CPU). Worst-case impact includes code execution via heap grooming of the freed kif; minimum reliable case is panic. CVSS 3.1 base β‰ˆ 6.2.
  • Reliability: the loop runs once per kif in the tree, so on a system with many interfaces/groups the per-call success probability scales linearly with the number of kifs.

Hold pf_token (or a dedicated pfi_ifs token) across the entire RB walk in pfi_get_ifaces(), AND take it in the event handlers that mutate the tree (pfi_attach_ifnet, pfi_detach_ifnet, pfi_attach_ifgroup, pfi_detach_ifgroup, pfi_group_change, pfi_ifaddr_event). Minimum- change fix for the walk itself:

--- a/sys/net/pf/pf_if.c
+++ b/sys/net/pf/pf_if.c
@@ -764,6 +764,7 @@
 pfi_get_ifaces(const char *name, struct pfi_kif *buf, int *size)
 {
    struct pfi_kif  *p, *nextp;
    int      n = 0;

+   lwkt_gettoken(&pf_token);
    crit_enter();
    for (p = RB_MIN(pfi_ifhead, &pfi_ifs); p; p = nextp) {
        nextp = RB_NEXT(pfi_ifhead, &pfi_ifs, p);
@@ -786,6 +787,8 @@
        }
    }
    crit_exit();
    *size = n;
+   lwkt_reltoken(&pf_token);
    return (0);
 }

AND add the same lwkt_gettoken(&pf_token) / lwkt_reltoken(&pf_token) pair inside pfi_attach_ifnet, pfi_detach_ifnet, pfi_attach_ifgroup, pfi_detach_ifgroup, pfi_group_change, and pfi_ifaddr_event so that tree mutation is serialized with the walk. The non-atomic refcount increments at pf_if.c:209,212,233,240 should also be converted to atomic(9) operations or guarded by the same token.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0605 Β· 12 files
FileTypeDescriptionSize
README.md readme original reviewer-supplied README 1.9 KB ↓ raw
VERDICT.md verdict full narrative: reproduced? how/why? exploit chain? fix validation 10.4 KB ↓ raw
race.c trigger-source header-free C harness: N DIOCIGETIFACES walkers + M SIOCIFCREATE/SIOCIFDESTROY mutators, CPU-pinned via lwp_setaffinity 4.5 KB view raw
race.sh trigger-source original shell driver (pfctl -i all -v + ifconfig vlan create/destroy) 695 B view raw
build.sh build-log exact cc command (cc -O2 -o race race.c) 112 B view raw
run.sh build-log exact run invocation (root; checks /dev/pf + uid 0) 444 B view raw
fix.diff suggested-fix git-apply-able: lwkt_gettoken(&pf_token) around pfi_{attach,detach}_ifnet/ifgroup, pfi_group_change, pfi_get_ifaces, pfi_set_flags, pfi_clear_flags (15 hunks) 3.8 KB view raw
fix_build.log build-log full untrimmed single-fix kernel build output (make -j6 nativekernel; rc=0) 5.6 MB ↓ download
fix_run.log run-log patched-kernel #1 race run: clean exit, no panic, pf still works 768 B view raw
env.txt environment guest uname, cc version, kldstat, sysctls 512 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme original reviewer-supplied README
↓ download raw

DF-0605 β€” PoC: pfi_get_ifaces UAF walk (shell driver)

Privileged local UAF PoC. pfi_get_ifaces() walks the global pfi_ifs RB tree with only crit_enter() (CPU-local), while concurrent ifnet_detach_event on another CPU frees kifs via pfi_kif_unref. The walker's nextp may land on a freed kif β†’ UAF read in pfi_skip_if.

Files

  • race.sh β€” shell driver (tight pfctl -i all -v loop + concurrent ifconfig vlan* create/destroy loop).
  • (added by per-PoC verifier) full C harness with cpuset -x pinning, build.sh, run.sh, run.log, VERDICT.md, manifest.json, fix.diff.

Run

On a 2+ CPU DragonFly system with pf enabled:

# CPU 0: tight DIOCIGETIFACES loop
while :; do pfctl -i all -v 2>/dev/null; done &

# CPU 1: create+destroy many interfaces concurrently
while :; do
    for i in $(seq 0 64); do ifconfig vlan$i create 2>/dev/null; done
    for i in $(seq 0 64); do ifconfig vlan$i destroy 2>/dev/null; done
done &

Expected outcome

Kernel panic with a stack trace through pfi_skip_if / strcmp / TAILQ_FOREACH inside pfi_get_ifaces:

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x...
backtrace:
    pfi_skip_if+0x...
    pfi_get_ifaces+0x...
    pfioctl+0x...

Notes for the per-PoC verifier

  • Use cpuset -x / usched_setcpu to pin the two loops to different CPUs for reliable race overlap.
  • The walker visits each kif in the tree; on a system with many interfaces / groups, the per-call success probability scales linearly with the number of kifs.
  • Heap grooming of the freed kif (sizeof(struct pfi_kif)) for code- execution escalation requires a slab-size analysis; document the victim object in VERDICT.md.
  • Verify the fix with git apply findings/poc/DF-0605/fix.diff (the lwkt_gettoken(&pf_token) around the walk + tree-mutating event handlers); after the fix the race should no longer fire.
VERDICT.md verdict full narrative: reproduced? how/why? exploit chain? fix validation
↓ download raw

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

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

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

VALIDATED by code analysis + build + boot + clean PoC run. fix.diff (15 hunks) applies cleanly to /usr/src; make -j6 nativekernel KERNCONF=X86_64_GENERIC completed rc=0 (no errors, full log in fix_build.log); installed kernel.stripped -> /boot/kernel/kernel and kernel.debug -> /boot/kernel/kernel.debug; kern.version correctly bumped #0 -> #1; SHA256 (/boot/kernel/kernel) = dd1cad3740948ebe007453f2dc67e6e60969a4f45f0d2c1d2110c158aad8a9d2. On the patched #1 kernel: 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. The fix is validated primarily by code analysis: pf_token held by both walker and mutator => lwkt_token exclusive-acquire serializes them across CPUs => race window eliminated by construction. Note on fix_baseline_reproduced=0/fix_patched_reproduced=0: the live race is statistically rare (INVARIANTS poisons only first 64 bytes of the freed 224-byte chunk, leaving pfik_ifp intact at NULL, so the UAF read usually returns stale-but-valid RB-tree pointers); neither the unpatched baseline nor the patched kernel panicked in the test windows, so the before/after is not a live-panic-elimination but a code-analysis-confirmed race-closure. The first ever race invocation DID wedge the guest to DDB (vm.sh reset reported 'likely DDB on panic'), but the panic signature was lost in the syslog 'vlanN: MAC address' flooding (2529 lines in 30s) and could not be cleanly attributed.

BASELINE (unpatched #0, Thu Jul 2 06:02:54): race 90 4 3 -> 'race: finished without panic' (race is statistically rare; first invocation wedged guest to DDB but no clean panic signature captured).
PATCHED (#1, Wed Jul 8 19:08:12, sha256 dd1cad3740948ebe007453f2dc67e6e60969a4f45f0d2c1d2110c158aad8a9d2): race 90 4 3 -> 'race: finished without panic'; pf.ko loads; pfctl -s info works; ifconfig vlan10 create+destroy -> vlan_create_destroy_ok.
The fix closes the race window by construction (pf_token serializes walker vs mutator); PF functionality is preserved.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Jul 8 19:08:12 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

none -- and none is meaningful here. PF (pf.ko) is NOT loaded by default on the with-src baseline; /dev/pf appears only after kldload pf.ko (root-only) with permissions crw------- root:wheel (0600). DIOCIGETIFACES on /dev/pf therefore requires root. SIOCIFCREATE/SIOCIFDESTROY are gated by caps_priv_check(cred, SYSCAP_RESTRICTEDROOT) at sys/net/if.c:2007,2013 -- also root-only. Both sides of the race are root-only; there is no unprivileged path to trigger this bug, hence no privilege boundary to cross. Root->kernel is game-over by definition. This is a root->kernel robustness/DoS gap (admin who loads PF and concurrently provisions/tears-down interfaces -- e.g. a virtualization host or router with dynamic VLAN/tap/gre churn -- can panic the kernel), NOT an unprivileged->root escalation primitive. No uid0 chain pursued because the preconditions rule it out per the bright-line rule. Realistic impact ceiling: kernel panic (DoS) caused by root.

Evidence (decisive lines)

race: pfi_kif element size = 224 bytes (struct layout in race.c = 216)
race: launching 4 walkers + 3 mutators for 90 s...
race: stop; killing 7 children
race: finished without panic
SSH_RC=0
[First invocation wedge -- dfbsd-qemu/vm.sh output: 'guest not answering (likely DDB on panic); killing qemu pid 251020' -- required hard reset. Subsequent races after kern.log_console_output=0 muted the syslog 'vlanN: MAC address' flooding (2529 lines in 30s, saturating the serial buffer); no panic captured in clean test windows because the UAF read usually returns stale-but-valid RB-tree pointers.]
Kernel: DragonFly 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026 (unpatched baseline)
Kernel after fix: DragonFly 6.5-DEVELOPMENT #1: Wed Jul  8 19:08:12 UTC 2026 -- single-fix kernel boots, pf.ko loads, DIOCIGETIFACES works, SIOCIFCREATE/SIOCIFDESTROY works, race PoC runs clean.

PoC changes

Replaced the original shell driver concept with a header-free C harness race.c because is NOT installed in /usr/include on the guest (PF is a module). race.c inlines struct pfi_kif, struct pfioc_iface, and the DIOCIGETIFACES macro verbatim from sys/net/pf/pfvar.h. It uses lwp_setaffinity (syscall 544) with the correct DragonFly cpumask_t size (struct {u64 ary[4];} = 32 bytes, not 8). It forks N walker + M mutator children (default 4+3) pinned across the 6 vCPUs, arms per-child SIGALRM for clean termination, and probes the kernel's sizeof(struct pfi_kif) at startup -- found 224 bytes vs the 216-byte C layout in the source. race.sh (the original shell driver) is kept for reference. fix.diff authored with lwkt_gettoken(&pf_token) around pfi_attach_ifnet, pfi_detach_ifnet, pfi_attach_ifgroup, pfi_detach_ifgroup, pfi_group_change, pfi_get_ifaces, pfi_set_flags, pfi_clear_flags.

Verified recommended fix

fix.diff adds lwkt_gettoken(&pf_token) / lwkt_reltoken(&pf_token) around the bodies of the tree-mutating event handlers (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) AND around the unlocked RB walks (pfi_get_ifaces pf_if.c:787 [defense-in-depth; recursive-safe since pfioctl already holds pf_token], pfi_set_flags pf_if.c:821, pfi_clear_flags pf_if.c:836 -- same unlocked-walk pattern). No new locks introduced; the patch uses the existing pf_token that pfioctl and pf.c packet processing already use. With walker and mutator both holding pf_token, lwkt_token's exclusive-acquire semantics serialize them across CPUs and the race window disappears by construction. MATCHES the finding markdown's ## Recommended fix proposal and additionally covers pfi_set_flags/pfi_clear_flags which have the identical bug pattern.

Verdict

REPRODUCED (code-level; live trigger rare). Line-by-line code review confirms the synchronization hole: pfi_get_ifaces() (sys/net/pf/pf_if.c:764-790) walks the global pfi_ifs RB tree with only CPU-local crit_enter() (line 769), capturing nextp via RB_NEXT() (lines 771 and 783) that may be freed by a concurrent ifnet_detach_event on another CPU. Although pfi_get_ifaces's only caller pfioctl() (sys/net/pf/pf_ioctl.c:981-989) takes pf_token, the mutator side does NOT: if_detach() (sys/net/if.c:949) fires EVENTHANDLER_INVOKE(ifnet_detach_event) at line 958 WITHOUT pf_token (and without ifnet_lock, which is only acquired later at line 970), reaching pfi_detach_ifnet() (pf_if.c:297) -> sets pfik_ifp=NULL (line 307) -> pfi_kif_unref() (line 309) -> RB_REMOVE + kfree (pf_if.c:246-253) when pfik_rules==0 && pfik_states==0. pfi_detach_ifnet uses only crit_enter (line 304), so it races the walker on SMP. Same unlocked-walk pattern in pfi_set_flags (pf_if.c:821) and pfi_clear_flags (pf_if.c:836). One observed guest wedge-to-DDB on the first invocation (vm.sh reset reported 'guest not answering (likely DDB on panic)'); subsequent races after sysctl kern.log_console_output=0 did not panic in 30/60/90s windows because INVARIANTS only poisons the first 64 bytes of the freed chunk (WEIRD_ADDR=0xdeadc0de, sizeof(weirdary)=64), leaving pfik_ifp (offset ~176) at its NULL value set by pfi_detach_ifnet -- so pfi_skip_if's pfik_ifp!=NULL check evaluates false, the walker silently skips, and the stale RB-tree pointers usually still resolve to valid nodes. The race IS the bug; live manifestation is statistically rare.