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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0725 Β· 14 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + how to reproduce
↓ download 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).

VERDICT.md verdict full narrative: mechanism, reachability, fix, validation
↓ download raw

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:

  1. First destroyer: if_clone_free_unit() clears the unit bitmap bit, then ifc->ifc_destroy(ifp) β†’ if_detach() removes ifp from ifnetlist and the driver frees the ifp memory.
  2. Second destroyer: if_clone_free_unit() re-clears an already-cleared bitmap bit β†’ KKASSERT "bit is already cleared" at if_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 β†’ panic if_clone_free_unit: bit is already cleared (captured in boot.log / panic.txt). vm.sh status β‡’ down.
  • Patched (#1 single-fix kernel, 6.5-DEVELOPMENT #1 Wed Jul 8 23:24:38): applied fix.diff to /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)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Jul 8 23:24:38 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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