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)
PoC verification
Evidence pack
findings/poc/DF-3407 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
- destroy-vs-close:
tunclose()clearsTUN_OPENat 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 ownif_clone_destroy()at :352. From the momentTUN_OPENclears, a concurrentSIOCIFDESTROYpasses the gate intun_clone_destroy()(:419) andtundestroy()frees the softc (kfree, :270) while tunclose still dereferences it (UAF). - open NULL-resurrection:
tunopen()(:286-287) resurrects (tuncreate) whendev->si_drv1 == NULLβ which, post-destroy, means the cdev is already indestroy_dev()putaway. - 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 staleifp. 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 plustun_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 insideif_clone_destroyitself. - 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
fixedv3 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)
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.
No comments yet.