if_clone_destroy UAF: drops ifnet_lock before dereferencing ifp->if_dname/if_dunit β concurrent destroy frees ifnet
Summary
if_clone.c:110-128: ifnet_lock :110 ifunit :111 ifnet_unlock :112 then ifp->if_dname :116 and ifp->if_dunit :119 dereferenced WITHOUT lock. if_var.h:894-896 contract: "ifnet lock must be held... for accessing of ifp returned by this function". Lock re-acquired :126 for ifc_destroy. Window :112-126 concurrent destroy frees ifnet: ifc_destroy calls if_detach+kfree. First thread stale ifp->if_dname read = UAF read or if_clone_free_unit KASSERT panic at :367 "bit already cleared". Trigger: two concurrent SIOCIFDESTROY same interface OR tap/tun auto-destroy-on-close. Unprivileged path via /dev/tap in jail/VPN setups. Fix: hold ifnet_lock across entire if_clone_destroy (mtx recursive safe for ifc_destroy callbacks).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0725 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| race_destroy.c | trigger-source | concurrent SIOCIFDESTROY race harness (12 procs x 5000 rounds on gif666) | 3.9 KB | view raw |
| build.sh | build-script | cc -O2 -o race_destroy race_destroy.c | 218 B | view raw |
| run.sh | run-script | ./race_destroy 12 5000 (must be root) | 261 B | view raw |
| README.md | readme | build/run/expected + how to reproduce | 1.1 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism, reachability, fix, validation | 7.1 KB | β raw |
| build.log | build-log | baseline build output (cc 8.3, BUILD_EXIT=0) | 187 B | view raw |
| run.log | run-log | baseline panic run: KKASSERT + boot.log panic trace | 1018 B | view raw |
| panic.txt | panic-signature | fatal panic 'if_clone_free_unit: bit is already cleared' at if_clone_destroy+0x7c | 2.0 KB | view raw |
| fix.diff | suggested-fix | hold ifnet_lock across ifunit() + all ifp derefs in if_clone_destroy (git apply-able) | 1.5 KB | view raw |
| fix_build.log | build-log | single-fix kernel build output, make -j6 nativekernel, NK_DONE rc=0 | 5.6 MB | β download |
| fix_run.log | run-log | patched #1 kernel run: completed 5000 rounds with no panic, RACE_EXIT=0 | 421 B | view raw |
| env.txt | environment | uname, cc version, kern.version | 247 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-0725 race harness β build/run
Build: cc -O2 -o race_destroy race_destroy.c
Run (MUST be root β SIOCIFDESTROY is SYSCAP_RESTRICTEDROOT gated): ./race_destroy [NCHILD] [ROUNDS] # default: 12 5000
Expected on the UNPATCHED audit kernel (#0, INVARIANTS ON): Kernel panic within seconds: panic: if_clone_free_unit: bit is already cleared if_clone_free_unit.isra.1() at if_clone_free_unit.isra.1+0x49 if_clone_destroy() at if_clone_destroy+0x7c ifioctl() -> mapped_ioctl() -> syscall2() Captured in dfbsd-qemu/boot.log (serial console).
Expected on the PATCHED single-fix kernel (#1): Clean completion: "[+] completed N rounds with no panic." RACE_EXIT=0
Notes: - The harness forks N children and uses a pipe as a barrier; each round the parent creates gif666, releases all children simultaneously, and they all race SIOCIFDESTROY on it. This widens the ifunit() -> ifp deref window in if_clone_destroy() that the bug leaves unlocked. - Root-only reachability is inherent to the bug (SIOCIFDESTROY / tun+tap auto-destroy-on-close all require SYSCAP_RESTRICTEDROOT).
DF-0725 β if_clone_destroy UAF / double-free-unit race
Verdict
REPRODUCED (panic / DoS). The cited UAF race in if_clone_destroy is real
and deterministically triggers a kernel panic on the default GENERIC kernel
(#0, INVARIANTS ON). Fix validated: a single-fix kernel that holds
ifnet_lock across all ifp derefs eliminates the panic.
Reachability is root-only, so this is a rootβkernel race-condition DoS / hardening gap, not an unprivileged privesc.
Mechanism (trigger β primitive β effect)
if_clone_destroy() (sys/net/if_clone.c:103-135) violates the MPSAFE contract
documented in sys/net/if_var.h:894-896 ("ifunit() β¦ ifnet lock must be held
β¦ for the accessing of the ifp returned by this function"):
110: ifnet_lock(); 111: ifp = ifunit(name); 112: ifnet_unlock(); <-- LOCK DROPPED 113: if (ifp == NULL) 114: return (ENXIO); 116: if ((ifc = if_clone_lookup(ifp->if_dname)) == NULL) <-- DEREF ifp w/o lock 119: unit = ifp->if_dunit; <-- DEREF ifp w/o lock β¦ 126: ifnet_lock(); <-- re-take 127: if_clone_free_unit(ifc, unit); <-- clears bitmap bit (under lock) 128: error = ifc->ifc_destroy(ifp); <-- may free ifp (gif_clone_destroyβif_detachβkfree) 132: ifnet_unlock();
Two concurrent destroyers of the same interface both call ifunit() while the
ifp is still alive (before either enters the lock-protected section at 126),
so both obtain a live ifp pointer. They then serialize at line 126:
- First destroyer:
if_clone_free_unit()clears the unit bitmap bit, thenifc->ifc_destroy(ifp)βif_detach()removes ifp fromifnetlistand the driver frees the ifp memory. - Second destroyer:
if_clone_free_unit()re-clears an already-cleared bitmap bit βKKASSERT "bit is already cleared"atif_clone.c:367-368β panic (INVARIANTS ON, default GENERIC).
With INVARIANTS OFF the second destroyer would instead proceed past the assert
to ifc->ifc_destroy(ifp) on already-freed ifp memory β genuine UAF
(silent heap corruption / secondary fault). The underlying primitive is thus a
use-after-free on the struct ifnet reached through the if_clone_destroy
framework path.
Reproduction
Harness: race_destroy.c β forks N children, uses a pipe as a barrier; each
round the parent creates gif666, releases all children, and they race
SIOCIFDESTROY gif666.
On the unpatched #0 kernel (GENERIC, INVARIANTS ON), ./race_destroy 12 5000
panics within seconds:
panic: if_clone_free_unit: bit is already cleared if_clone_free_unit.isra.1() at if_clone_free_unit.isra.1+0x49 if_clone_free_unit.isra.1() at if_clone_free_unit.isra.1+0x49 if_clone_destroy() at if_clone_destroy+0x7c ifioctl() at ifioctl+0x243 mapped_ioctl() at mapped_ioctl+0x5fa syscall2() at syscall2+0x11e
The trace names exactly the cited function (if_clone_destroy β
if_clone_free_unit); reproduced twice (initial + post-reset baseline).
Reachability & threat model (why impact = panic/DoS, not uid0)
if_clone_destroy() is reachable from userspace only through:
- SIOCIFDESTROY ioctl β sys/net/if.c:2012-2016, gated by
caps_priv_check(cred, SYSCAP_RESTRICTEDROOT) at if.c:2013.
- tun/tap auto-destroy-on-close: sys/net/tun/if_tun.c:352,
sys/net/tap/if_tap.c:460. But opening the device is itself root-gated
(tunopen at if_tun.c:283, tapopen at if_tap.c:324 both require
SYSCAP_RESTRICTEDROOT). Verified: maxx (uid 1001, not in wheel) gets
SIOCIFCREATE2: Operation not permitted.
- wlan vap destroy (sys/netproto/802_11/wlan/ieee80211_dragonfly.c:339) β
also root-gated.
There is no unprivileged path to if_clone_destroy. This is a valid Phase-6
hard blocker for escalation: the write/UAF is reachable only from an already-root
context, so there is no privilege boundary to cross (rootβkernel is game-over by
definition). Impact is therefore panic / DoS from concurrent root operations
(a hardening gap), not a privilege escalation. Severity Medium is appropriate.
Exploit chain
Not pursued beyond the panic/DoS characterization because reachability is
root-only (valid hard blocker per Phase 6: the primitive is reachable only from
an already-root context). The primitive is a struct ifnet UAF (the first
destroyer frees ifp; the second destroyer's ifc->ifc_destroy(ifp) operates on
freed memory when INVARIANTS is OFF), but since root already owns the kernel,
escalation is moot. exploit_chain: "none (root-only reachability hard blocker)".
Fix
fix.diff holds ifnet_lock() continuously from ifunit() through all ifp
derefs and the ifc->ifc_destroy(ifp) call (the destroy already ran under
ifnet_lock in the original code at line 128, and ifnet_mtx is recursive β
sys/sys/mutex.h:47 β so the destroy callback's own if_detach()βifnet_lock()
acquires recursively with no new lock-ordering constraint). Each error return
now drops the lock before returning. This matches the documented MPSAFE
contract and eliminates the race window: the second destroyer's ifunit() runs
under the same lock that the first destroyer holds during teardown, so it no
longer finds the interface and returns ENXIO cleanly.
Fix validation (Phase 8)
- Baseline (#0 unpatched):
./race_destroy 12 5000β panicif_clone_free_unit: bit is already cleared(captured inboot.log/panic.txt).vm.sh status β down. - Patched (#1 single-fix kernel,
6.5-DEVELOPMENT #1 Wed Jul 8 23:24:38): appliedfix.diffto/usr/src,make -j6 nativekernel KERNCONF=X86_64_GENERIC(rc=0),make installkernel, reboot. Same harness β[+] completed 5000 rounds with no panic.RACE_EXIT=0, guest stays up. Reproduced clean twice (12Γ5000 and 16Γ3000). - Before/after: panic-on-baseline vs clean-completion-on-patched β fix closes the bug.
PoC changes
The finding folder shipped with no PoC source (DF-0725 had no prior PoC). I
authored race_destroy.c (the concurrent-destroy race harness), build.sh,
run.sh, and fix.diff from scratch based on the line-by-line source trace of
sys/net/if_clone.c.
Kernel references (confirmed during verification)
sys/net/if_clone.c:110-132β the buggyif_clone_destroylock-drop window.sys/net/if_clone.c:367-368βKKASSERT "bit is already cleared"that fires on the second concurrent destroyer (the observed panic site).sys/net/if_var.h:894-896β MPSAFE contract requiring ifnet_lock held for accessing the ifp returned by ifunit().sys/net/if.c:1946βifunit()KASSERTifnet is not locked.sys/net/if.c:2012-2016βSIOCIFDESTROYroot-gate (caps_priv_check(SYSCAP_RESTRICTEDROOT)).sys/net/if.c:949-991βif_detach()removes ifp from ifnetlist under ifnet_lock; driver frees ifp after.sys/net/gif/if_gif.c:176-203βgif_clone_destroyβif_detach(called under ifnet_lock at if_clone.c:128).sys/net/tun/if_tun.c:283,sys/net/tap/if_tap.c:324β root-gate on the auto-destroy-on-close path.sys/sys/mutex.h:47βmtxis recursive (justifies holding ifnet_lock over the destroy callback).
Fix verification
fixedVALIDATED the fix. Applied fix.diff to /usr/src/sys/net/if_clone.c, built make -j6 nativekernel KERNCONF=X86_64_GENERIC (NK_DONE rc=0), make installkernel, rebooted into 6.5-DEVELOPMENT #1. Re-ran the SAME ./race_destroy 12 5000 plus a 16x3000 follow-up: both completed cleanly ('[+] completed N rounds with no panic.' RACE_EXIT=0), guest stayed up. The unpatched #0 baseline panicked within seconds on the identical harness (KKASSERT 'bit is already cleared' at if_clone_destroy+0x7c). Clean before/after => fix closes the bug.
BEFORE (#0 unpatched): panic: if_clone_free_unit: bit is already cleared / if_clone_destroy() at if_clone_destroy+0x7c / vm.sh status => down (DDB). AFTER (#1 patched, ./race_destroy 12 5000): [*] round 5000/5000 / [+] completed 5000 rounds with no panic. / RACE_EXIT=0 / vm.sh status => up. AFTER (#1 patched, ./race_destroy 16 3000): [+] completed 3000 rounds with no panic. RACE_EXIT=0 (guest up).
Confirmed kernel references
- sys/net/if_clone.c:110
- sys/net/if_clone.c:112
- sys/net/if_clone.c:116
- sys/net/if_clone.c:119
- sys/net/if_clone.c:126
- sys/net/if_clone.c:128
- sys/net/if_clone.c:367
- sys/net/if_var.h:894
- sys/net/if.c:1946
- sys/net/if.c:2013
- sys/net/if.c:949
- sys/net/gif/if_gif.c:198
- sys/net/tun/if_tun.c:283
- sys/net/tap/if_tap.c:324
- sys/sys/mutex.h:47
Detail
Exploit chain
none (root-only reachability is a valid Phase-6 hard blocker). The primitive is a real struct ifnet UAF -- with INVARIANTS OFF the second destroyer's ifc->ifc_destroy(ifp) at if_clone.c:128 would operate on the ifp that the first destroyer already freed (gif_clone_destroy -> if_detach -> kfree ifp). However, every userspace path to if_clone_destroy requires an already-root credential (SIOCIFDESTROY caps_priv_check; tun/tap open + auto-destroy-on-close both SYSCAP_RESTRICTEDROOT), so there is no privilege boundary to cross -- root->kernel is game-over by definition. Impact is therefore panic/DoS from concurrent root operations (a hardening gap), not an unprivileged privesc. No escalation attempted beyond the DoS characterization because the bug is unreachable from an unprivileged user.
Evidence (decisive lines)
BASELINE (#0 unpatched, ./race_destroy 12 5000 as root): panic: if_clone_free_unit: bit is already cleared cpuid = 5 if_clone_free_unit.isra.1() at if_clone_free_unit.isra.1+0x49 0xffffffff80733f29 if_clone_destroy() at if_clone_destroy+0x7c 0xffffffff807341fc ifioctl() at ifioctl+0x243 0xffffffff80731d73 mapped_ioctl() -> syscall2() Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip) [db>] (vm.sh status => down) PATCHED (#1 single-fix): [*] round 5000/5000 [+] completed 5000 rounds with no panic. RACE_EXIT=0 (guest stays up)
PoC changes
DF-0725 shipped with no prior PoC source. Authored from scratch after line-by-line tracing of sys/net/if_clone.c: race_destroy.c (concurrent SIOCIFDESTROY harness: 12 forked children synchronized on a pipe barrier, each round parent creates gif666 then releases all children to race-destroy it, repeating 5000 rounds), build.sh, run.sh, and fix.diff. fix.diff holds ifnet_lock continuously from ifunit() through all ifp derefs (each error path now drops the lock before returning), eliminating the race window.
Verified recommended fix
In if_clone_destroy() (sys/net/if_clone.c:104), remove the ifnet_unlock() at line 112 and the re-take at line 126 so ifnet_lock is held continuously from ifunit() through ifp->if_dname/if_dunit derefs and the ifc->ifc_destroy(ifp) call; each early error return now does ifnet_unlock() before returning. This restores the documented MPSAFE contract (if_var.h:894-896) and is safe because ifc_destroy() already ran under ifnet_lock in the original code and ifnet_mtx is recursive (sys/sys/mutex.h:47). The full git-apply-able diff is in findings/poc/DF-0725/fix.diff (verified applies clean).
Verdict
REPRODUCED as a kernel panic (DoS). if_clone_destroy() (sys/net/if_clone.c:104-135) violates the MPSAFE contract in if_var.h:894-896: it takes ifnet_lock, calls ifunit() to get an ifnet pointer, DROPS the lock at line 112, then dereferences ifp->if_dname (:116) and ifp->if_dunit (:119) without the lock. Two concurrent destroyers of the same interface both pass ifunit() with a live ifp and then serialize at the re-take (:126); the second one's if_clone_free_unit() re-clears an already-cleared bitmap bit -> KKASSERT 'bit is already cleared' (if_clone.c:367-368) -> panic. Confirmed by ./race_destroy 12 5000 (root): panic 'if_clone_free_unit: bit is already cleared' at if_clone_destroy+0x7c, trace if_clone_free_unit->if_clone_destroy->ifioctl->mapped_ioctl->syscall2, guest -> DDB (down). Reproduced twice (initial + post-reset baseline). Reachability is ROOT-ONLY: SIOCIFDESTROY is caps_priv_check(SYSCAP_RESTRICTEDROOT) at if.c:2013, and tun/tap auto-destroy-on-close is gated by the same check in tunopen (if_tun.c:283) / tapopen (if_tap.c:324) -- verified maxx (uid 1001) gets 'SIOCIFCREATE2: Operation not permitted'.
No comments yet.