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/pfto issueDIOCIGETIFACESand (b) trigger concurrentifnetdetach events (root runningifconfig vlanN destroy/ifconfig tapN destroy, or a remote attacker who can cause interface teardown β e.g. awlan(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_namestrcmpleaking into theDIOCIGETIFACESreply buffer, or β with heap grooming β controlled dereference via a crafted fakeifnetpointer (privilege escalation). - Required config or capabilities: root + pf enabled; 2+ CPU system; ability to create+destroy interfaces concurrently.
- Reachability: race the
DIOCIGETIFACESwalk against concurrentifconfig <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
nextpmust 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.
Recommended fix
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
sys/net/pf/pf_if.c:246-253βpfi_kif_unref'sRB_REMOVE + kfreepath.sys/net/pf/pf_ioctl.c:989βpfioctlcorrectly takespf_token;pfi_get_ifacesdoes not.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0605 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 (tightpfctl -i all -vloop + concurrentifconfig vlan* create/destroyloop).- (added by per-PoC verifier) full C harness with
cpuset -xpinning,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_setcputo 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 inVERDICT.md. - Verify the fix with
git apply findings/poc/DF-0605/fix.diff(thelwkt_gettoken(&pf_token)around the walk + tree-mutating event handlers); after the fix the race should no longer fire.
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 thepfiio_esizecheck atpf_ioctl.c:3022). - Race harness (
race.c): spawns N walker processes (each tight- loopingDIOCIGETIFACESon/dev/pf, pinned to CPU 0..N-1) and M mutator processes (each tight-loopingSIOCIFCREATE/SIOCIFDESTROYonvlanN, pinned to CPU N..N+M-1). - Result: One observed guest wedge-to-DDB on the first invocation
(
vm.sh resetreported "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 thevlanN: MAC addresssyslog 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.kois not loaded by default (thewith-srcbaseline has no/dev/pfuntilkldload pf.kois run as root)./dev/pfiscrw------- root:wheel(0600).DIOCIGETIFACEStherefore requires root.SIOCIFCREATE/SIOCIFDESTROYare gated bycaps_priv_check(cred, SYSCAP_RESTRICTEDROOT)atsys/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
kldloadarbitrary code). A root-only kernel panic is a robustness/DoS issue, not a privilege escalation. -
Race complexity: the walker's
nextpmust 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:
<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 theDIOCIGETIFACES_IOWRmacro verbatim fromsys/net/pf/pfvar.h.cpumask_tin DragonFly isstruct { u64 ary[4]; }(32 bytes), not a singleunsigned longβ thelwp_setaffinity(syscall 544) pinning had to use the right mask size.- The harness probes the kernel's
sizeof(struct pfi_kif)at startup (thepfiio_esizecheck atpf_ioctl.c:3022returnsENODEVbeforepfi_get_ifacesis called if the element size is wrong) β it found 224 bytes, vs the 216-byte C-layout inrace.c(8 bytes of compiler-injected tail padding). The probe handles both. - 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).
race.shis 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 pathpfi_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 holdspf_token, but guards against future callers that forgetpfi_set_flags(pf_if.c:821) andpfi_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_GENERICsucceeded (rc=0, no errors; full build log infix_build.log). - Patched (#1): copied
kernel.strippedβ/boot/kernel/kernel,kernel.debugβ/boot/kernel/kernel.debug, rebooted.kern.versioncorrectly bumped from6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026to6.5-DEVELOPMENT #1: Wed Jul 8 19:08:12 UTC 2026. - Post-fix behavior:
pf.koloads,/dev/pfappears,pfctl -s infoworks,ifconfig vlanN create/destroyworks, 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_tokenheld 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 referencebuild.sh/run.shβ exact reproduce commandsfix.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
fixedVALIDATED 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.
Confirmed kernel references
- sys/net/pf/pf_if.c:764
- sys/net/pf/pf_if.c:769
- sys/net/pf/pf_if.c:771
- sys/net/pf/pf_if.c:783
- sys/net/pf/pf_if.c:800
- sys/net/pf/pf_if.c:807
- sys/net/pf/pf_if.c:297
- sys/net/pf/pf_if.c:307
- sys/net/pf/pf_if.c:309
- sys/net/pf/pf_if.c:246
- sys/net/pf/pf_if.c:252
- sys/net/pf/pf_if.c:821
- sys/net/pf/pf_if.c:836
- sys/net/pf/pf_ioctl.c:981
- sys/net/pf/pf_ioctl.c:989
- sys/net/pf/pf_ioctl.c:3019
- sys/net/pf/pf_ioctl.c:3026
- sys/net/if.c:949
- sys/net/if.c:958
- sys/net/if.c:2007
- sys/net/if.c:2013
- sys/kern/kern_slaballoc.c:231
- sys/kern/kern_slaballoc.c:313
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
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.
No comments yet.