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

tun(4) softc lifetime race: cdev open/close vs clone create/destroy unsynchronized β€” reproduced kernel panic (UAF family); tap DF-3405 twin

Summary

The whole cdev side of tun(4) (tunopen/tunclose/tunioctl/tunread/tunwrite/tunkqfilter) manipulates TUN_OPEN, dev->si_drv1 and the softc with no lock, while the entire ifconfig clone side runs under the ifnet lock. tunclose clears TUN_OPEN 35 lines before its own if_clone_destroy, so a concurrent SIOCIFDESTROY passes the gate, kfrees the softc under the still-running tunclose (UAF), and both sides tear down the same clone unit (double-destroy). tunopen additionally resurrects via tuncreate when si_drv1==NULL onto a cdev already in destroy_dev() putaway. VERIFIED on guest: stock kernel panicked in <=60s of racing ('panic: if_clone_free_unit: bit is already cleared', tunclose -> if_clone_destroy on the stack), and a partially-fixed kernel panicked at the resurrection window (tunopen -> tuncreate -> sysref_get SRF_PUTAWAY assertion) - two independent panic signatures from the same missing synchronization. Both endpoints root-gated; ceiling root-triggered panic/UAF. Full fix validated in 2 stages (serialize cdev side with ifnet_lock + no resurrection + the if_clone.c TOCTOU fix of DF-3414): 2x120s of racing clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3407 Β· 12 files
FileTypeDescriptionSize
race_tun.c β€” 2.5 KB view raw
build.sh β€” 96 B view raw
run.sh β€” 98 B view raw
build.log β€” 80 B view raw
run.log β€” 886 B view raw
run.fix.log β€” 396 B view raw
panic.txt β€” 1.1 KB view raw
env.txt β€” 654 B view raw
fix.diff β€” 2.9 KB view raw
fix_kernel_info.txt β€” 1.1 KB view raw
VERDICT.md β€” 4.7 KB ↓ raw
verdict.json β€” 4.7 KB view raw
VERDICT.md
↓ download raw

DF-3407 β€” VERDICT

Status: REPRODUCED (panic, 3 distinct kernel-panic signatures across the lifetime race family); fix validated after 3 iterations (v3 clean, 2Γ—120s stress runs).

Baseline (stock kernel #0, INVARIANTS)

Guest: DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC, vm.sh reset with-src fresh boot. PoC race_tun.c: parent loops open("/dev/tun")/close() (autoclone open creates a unit via tunclone() β†’ if_clone_create(), close auto-destroys via tunclose() β†’ if_clone_destroy(), sys/net/tun/if_tun.c:352); a forked child hammers SIOCIFDESTROY on tun0..tun32 through an AF_ROUTE socket.

Run 1 (≀60s): panic (panic.txt):

panic: if_clone_free_unit: bit is already cleared
if_clone_destroy() at if_clone_destroy+0x7c
tunclose() at tunclose+0x2ba
dev_dclose() ...

Root cause (three unsynchronized lifetime domains + one framework TOCTOU)

The cdev side of tun(4) takes no lock at all, while the whole clone create/destroy side runs under the ifnet lock (sys/net/if_clone.c:74-95). The race family:

  1. destroy-vs-close: tunclose() clears TUN_OPEN at if_tun.c:317 and runs a long unlocked body (:320-347: purge, if_down, if_purgeaddrs_nolink, EVENTHANDLER, rt_ifannouncemsg, funsetown, KNOTE) before its own if_clone_destroy() at :352. From the moment TUN_OPEN clears, a concurrent SIOCIFDESTROY passes the gate in tun_clone_destroy() (:419) and tundestroy() frees the softc (kfree, :270) while tunclose still dereferences it (UAF).
  2. open NULL-resurrection: tunopen() (:286-287) resurrects (tuncreate) when dev->si_drv1 == NULL β€” which, post-destroy, means the cdev is already in destroy_dev() putaway.
  3. if_clone_destroy lookup/act TOCTOU (sys/net/if_clone.c:110-132): the framework looks the ifnet up under the ifnet lock, drops the lock, then re-acquires and runs if_clone_free_unit() / ifc->ifc_destroy(ifp) on the stale ifp. Two concurrent destroyers of the same unit both pass the lookup and then double-run the teardown: KKASSERT "bit is already cleared" on INVARIANTS builds (observed), and β€” without INVARIANTS β€” a double bit-clear plus tun_clone_destroy() on a freed ifnet (UAF).

Domain 1+3 together produced the baseline panic; the exact interleave is tunclose and SIOCIFDESTROY both carrying the same unit through if_clone_destroy()'s unlocked gap.

Fix validation β€” 3 iterations (this is the interesting part)

  • v1 (if_tun.c only: ifnet_lock around tunopen/tunclose critical sections): race re-run β†’ panic moved to domain 2 (panic.2.txt): tunopen β†’ tuncreate β†’ reference_dev β†’ sysref_get: assertion "(sr->flags & SRF_PUTAWAY) == 0" β€” proof that the NULL-resurrection window is independently live.
  • v2 (v1 + no-resurrect: sc == NULL β†’ ENXIO under the lock): race re-run β†’ panic back at domain 1+3 signature (tunclose+0x24f β†’ if_clone_destroy β†’ if_clone_free_unit), exposing the framework lookup/act TOCTOU: serialization of the tun paths cannot close a window inside if_clone_destroy itself.
  • v3 (v2 + if_clone.c: hold the ifnet lock across lookupβ†’free_unitβ†’destroy): race re-run 2Γ—120s (56521 and 59259 open/close cycles against the destroyer child): clean exit both times, guest up, no panic (run.fix.log, run.fix.2.log). ifc_destroy (tundestroy) was already invoked under this lock by the original code path, so the lock condition is unchanged for every cloner.

Baseline: panic in ≀60s (1/1 runs). Patched v3: no panic in 240s of racing (2/2 runs).

Exploitability / escalation ceiling

Every entry point in the race is root-gated: tunopen() requires caps_priv_check(SYSCAP_RESTRICTEDROOT) (if_tun.c:283 β€” refused in jails), SIOCIFCREATE/SIOCIFDESTROY are root-gated (sys/net/if.c:2008-2017). No unprivileged reachability exists, so a uid=0 chain is impossible by construction; the ceiling is a root-triggered kernel panic / UAF of tun_softc/ifnet. (Note: the if_clone.c TOCTOU half is triggerable by any two concurrent ifconfig <clonable-if> destroy callers β€” still root-only β€” and is filed as the framework component DF-3414.)

Artifacts

panic.txt, panic.2.txt (resurrection panic on v1), run.log (baseline), run.fix.log + run.fix.2.log (v3 clean), fix.diff (v3, two files), fix_kernel_info.txt (kernel #1 of 09:05:29, KERNEL_RC=0/INSTALL_RC=0), env.txt, build.log.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

v3 fix (if_tun.c ifnet_lock around open/close critical sections + no-resurrect ENXIO; if_clone.c ifnet_lock held across lookup..free_unit..ifc_destroy) validated on the rebuilt guest kernel: baseline panicked <=60s; patched kernel survived 2x120s of the identical race (56521 and 59259 open/close cycles, exit 0, guest up). Intermediate v1/v2 failures (resurrection panic, framework TOCTOU panic) are documented in panic.2.txt and VERDICT.md and are covered by the v3 diff.

run.fix.log; run.fix.2.log; fix.diff (v3, if_tun.c + if_clone.c); fix_kernel_info.txt; panic.2.txt (v1 intermediate)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 12 09:05:29 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root: fork opener(/dev/tun open/close loop; autoclone units) + destroyer(AF_ROUTE SIOCIFDESTROY on tun0..32) -> tunclose's unlocked tail races an external destroy -> softc freed under the running tunclose (UAF read/write) and/or both destroyers carry the same unit through if_clone_destroy's unlocked lookup gap -> double clone-unit free + ifnet UAF -> INVARIANTS KKASSERT panic (non-INVARIANTS: silent UAF). Unprivileged escalation impossible: every entry is root-gated.

Evidence (decisive lines)

['panic.txt β€” baseline panic: tunclose -> if_clone_destroy -> if_clone_free_unit: bit is already cleared', 'panic.2.txt β€” v1-patched kernel: tunopen -> tuncreate -> reference_dev sysref_get SRF_PUTAWAY (resurrection window live)', 'run.log β€” baseline race output (ssh died mid-run; guest panicked)', 'run.fix.log / run.fix.2.log β€” v3 kernel: 2x120s races, 56521/59259 cycles, no panic', 'fix.diff β€” v3: if_tun.c serialization + no-resurrect; if_clone.c lock across lookup..teardown', 'VERDICT.md β€” full 3-iteration validation narrative']

PoC changes

PoC written fresh for this finding (no seed). Fixed a missing sys/wait.h include after the first build (warning only).

Verified recommended fix

Take the ifnet lock (recursive sleepable mtx) around the tunopen/tunclose critical sections so the cdev paths agree with the ifconfig clone create/destroy paths that already hold it.

Verdict

REPRODUCED: stock kernel panicked in <=60s with tunclose -> if_clone_destroy -> 'if_clone_free_unit: bit is already cleared' while racing open()/close() on /dev/tun against SIOCIFDESTROY. Root cause is a family of unsynchronized lifetime windows: tunclose clears TUN_OPEN at if_tun.c:317 then runs an unlocked body on the softc before its own if_clone_destroy at :352 (concurrent SIOCIFDESTROY passes the TUN_OPEN gate at :419 and kfrees the softc at :270 -> UAF); tunopen resurrects via tuncreate when si_drv1==NULL (:286-287) onto a cdev already in destroy_dev putaway (proved live: v1-patched kernel panicked at tunopen->tuncreate->reference_dev sysref_get SRF_PUTAWAY, panic.2.txt); and if_clone_destroy itself drops the ifnet lock between the ifunit lookup and if_clone_free_unit/ifc_destroy (sys/net/if_clone.c:110-132) so two destroyers double-run teardown (filed as framework component DF-3414). Fix validated in 3 iterations: v3 (if_tun.c ifnet_lock serialization + no-resurrect, plus if_clone.c lock held across lookup..teardown) survives 2x120s races (56521/59259 open-close cycles) with no panic vs baseline panic in <=60s. Both endpoints root-gated (if_tun.c:283, if.c:2008-2017) -> no unprivileged escalation possible; ceiling is root-triggered panic/UAF.