autofs_node_vn loses a create race panicking kernel on concurrent first-lookup
Summary
autofs_vnops.c:571 mtx_lock(an_vnode_lock). :573 vp=an_vnode. :589 mtx_unlock BEFORE getnewvnode. :591 getnewvnode (can sleep). :597 KASSERT(anp->an_vnode==NULL lost race) fires when two threads both observe NULL. :598 anp->an_vnode=vp unsynchronized. Callers (nresolve:225/nmkdir:265) unlock am_lock BEFORE calling. With INVARIANTS = panic. Without = vnode leak + reclaim confusion. Any unprivileged user on autofs box. Fix: hold an_vnode_lock across getnewvnode.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0886 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0886_race.c | trigger-source | kernel-module harness: 4 CPU-pinned kthreads race real autofs_node_vn() | 5.5 KB | view raw |
| stress_autofs_race.c | trigger-source | userspace concurrent-stat stress (probabilistic) | 5.4 KB | view raw |
| Makefile | build-config | kmod makefile for df0886_race | 227 B | β download |
| build.sh | build-script | builds stress + kmod harness | 910 B | view raw |
| run.sh | run-script | kldloads the race harness | 1.1 KB | view raw |
| VERDICT.md | verdict | full analysis: mechanism, reachability, fix validation | 7.5 KB | β raw |
| README.md | readme | quickstart reproduction guide | 1.3 KB | β raw |
| fix.diff | suggested-fix | hold an_vnode_lock across getnewvnode() (removes race) | 1014 B | view raw |
| panic.txt | panic-signature | panic 'lost race' at autofs_node_vn+0x2c4 (unpatched) | 743 B | view raw |
| fix_run.log | run-log | 3 fixed-module runs: all racers rc=0, no panic | 1.9 KB | view raw |
| fix_build.log | build-log | autofs.ko module build with fix applied | 8.2 KB | view raw |
| env.txt | environment | uname, cc version, INVARIANTS=ON, module hashes | 422 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-0886 β autofs_node_vn create race
Bug: autofs_node_vn() (sys/vfs/autofs/autofs_vnops.c:564-602)
drops an_vnode_lock (line 589) before sleeping in getnewvnode()
(line 591) and assigns anp->an_vnode = vp (line 598) without re-taking
the lock. The KASSERT at line 597 catches the create race; with
INVARIANTS (default GENERIC) the kernel panics.
Impact: Medium β local kernel DoS (panic) on any autofs-configured
box. Any unprivileged user can trigger via stat()/ls under the
mountpoint. No escalation path (vnode-pointer-to-vnode-pointer overwrite,
no attacker-controlled content).
Reproduce
Prerequisites (root, once)
kldload autofs
mkdir -p /autofs_test
mount_autofs -f autofs_race "" /autofs_test
Build
./build.sh # builds stress_autofs_race + df0886_race.ko
Run (deterministic harness)
./run.sh # kldload df0886_race.ko
# UNPATCHED: panic "lost race" in autofs_node_vn
# FIXED: all 4 racers complete, no panic
Racer output goes to the serial console (/var/run/dmesg.boot or
dmesg).
Run (userspace stress, probabilistic)
./stress_autofs_race /autofs_test 16 30
# May panic under vnode pressure; window is ~us otherwise.
Fix
See fix.diff β hold an_vnode_lock across getnewvnode() (mtx_t is a
sleeping mutex in DragonFly, so this is safe).
DF-0886 β autofs_node_vn create race (lost-race KASSERT panic)
Verdict: REPRODUCED (panic, then FIXED)
The race in autofs_node_vn() is real and deterministically panics the
default GENERIC kernel (INVARIANTS ON). The fix β holding an_vnode_lock
across the sleepable getnewvnode() call β eliminates the race; validated
on a single-fix autofs.ko module with 3 consecutive race runs, no panic.
The bug
File: sys/vfs/autofs/autofs_vnops.c:564-602
Function: autofs_node_vn()
retry:
KKASSERT(mtx_notlocked(&anp->an_mount->am_lock));
mtx_lock_ex_quick(&anp->an_vnode_lock); // 571 LOCK
vp = anp->an_vnode; // 573 read
if (vp != NULL) {
vhold(vp);
mtx_unlock_ex(&anp->an_vnode_lock); // 576 fast path unlock
error = vget(vp, flags | LK_RETRY);
...
return (0);
}
mtx_unlock_ex(&anp->an_vnode_lock); // 589 *** UNLOCK ***
error = getnewvnode(VT_AUTOFS, mp, &vp, // 591 SLEEPS
VLKTIMEOUT, LK_CANRECURSE);
if (error)
return (error);
vp->v_type = VDIR;
vp->v_data = anp;
KASSERT(anp->an_vnode == NULL, ("lost race")); // 597 KASSERT
anp->an_vnode = vp; // 598 assign (UNLOCKED)
The lock is dropped at line 589 before getnewvnode() (line 591),
which can sleep arbitrarily long (vnode allocation, vnlru reclaim).
The assignment anp->an_vnode = vp at line 598 is done without
re-taking the lock. The KASSERT at line 597 catches the lost race.
Race scenario
- Thread A: locks
an_vnode_lock(571), readsan_vnode == NULL(573), drops lock (589), entersgetnewvnode()(591) β sleeps. - Thread B: locks
an_vnode_lock(571), readsan_vnode == NULL(573, because A hasn't assigned yet), drops lock (589), entersgetnewvnode()(591) β sleeps. - Thread A wakes: KASSERT(597) passes (
an_vnodestill NULL), assignsan_vnode = vp(598), returns. - Thread B wakes: KASSERT(597) fails (
an_vnode != NULL) βpanic: lost race.
Callers (reachability)
autofs_node_vn() is called from:
- autofs_nresolve() (autofs_vnops.c:232) β VOP_NRESOLVE, triggered by
any stat(), ls, open(), or pathname resolution on the autofs
mountpoint. Any unprivileged user can trigger this.
- autofs_nmkdir() (autofs_vnops.c:268) β called by automountd.
- autofs_root() (autofs_vfsops.c:271) β root vnode access.
Both nresolve and nmkdir release am_lock before calling
autofs_node_vn (line 225 / 265), so concurrent callers can both find
the same child node and race in autofs_node_vn().
Preconditions
autofsmodule loaded (kldload autofs) β root-only.- An autofs mount exists (
mount_autofs) β root-only setup. - Once the admin has set up autofs, any unprivileged user triggers
the race via
stat()/lsof a path under the mountpoint.
Reproduction
Userspace stress (probabilistic)
stress_autofs_race.c forks N processes that all stat() the autofs
mountpoint root concurrently, in a mount/unmount loop. The race window
is the duration of one getnewvnode() call (~us under no pressure), so
this approach is probabilistic and did not fire in 30 loops Γ 16 racers
on an idle guest. Under vnode-table pressure (low kern.maxvnodes), the
window widens.
Kernel-module harness (deterministic) β USED FOR VALIDATION
df0886_race.c is a kldload module that:
1. Finds the first mounted autofs filesystem via mountlist.
2. NULLs the root node's an_vnode (simulating a reclaim, as
autofs_vnops.c:425-428 does).
3. Spawns 4 kthreads on different CPUs (kthread_create_cpu) that
all spin-wait on a barrier, then simultaneously call the REAL
autofs_node_vn() on the same node.
Because getnewvnode() takes several microseconds, overlapping calls
from different CPUs guarantee the race fires. Module-only (root
kldload); the bug IS userspace-reachable via stat(), just too
tight to fire reliably from userspace without pressure.
Panic signature (unpatched #0 kernel, original autofs.ko)
DF-0886: releasing 4 racers -- expect panic
panic: lost race
Trace beginning at frame 0xfffff80117c0fa20
autofs_node_vn() at autofs_node_vn+0x2c4 0xffffffff82603cd4
autofs_node_vn() at autofs_node_vn+0x2c4 0xffffffff82603cd4
racer_thread() at racer_thread+0x46 0xffffffff82659046
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>
The double autofs_node_vn in the stack trace is the smoking gun: two
threads are inside autofs_node_vn concurrently; the one deeper in the
stack hit the KASSERT after the shallower one won the assignment.
Impact
- Default GENERIC (INVARIANTS ON):
panic: lost raceβ kernel DoS. Any unprivileged user on an autofs-configured box can crash the kernel by racing lookups of a path under the mountpoint. - Without INVARIANTS: the KASSERT is compiled out; both threads assign
anp->an_vnode, leaking one vnode and corrupting reclaim state (the lost vnode'sv_datastill points at the autofs node, leading to use-after-free on unmount). - No escalation path: this is a pure race-condition DoS / memory
corruption. The write (
anp->an_vnode = vp) overwrites a vnode pointer with another vnode pointer β both are valid kernel objects, so there is no attacker-controlled content primitive. No path touid=0.
CVSS: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H (Medium)
Fix
fix.diff β hold an_vnode_lock across getnewvnode().
mtx_t in DragonFly is a sleeping mutex (_mtx_lock_ex blocks via
tsleep, confirmed in sys/sys/mutex2.h:153), so holding it across the
sleepable getnewvnode() is legal. The fix:
- Removes the
mtx_unlock_exat line 589 (the unlock before getnewvnode). - Adds
mtx_unlock_exon the error return path of getnewvnode. - Adds
mtx_unlock_exafteranp->an_vnode = vp(line 598). - Removes the now-dead
KASSERT(anp->an_vnode == NULL, ...)at 597.
The second concurrent caller blocks on mtx_lock_ex_quick at line 571
until the first finishes and sets an_vnode != NULL; it then takes the
fast path (line 574). No double-create, no KASSERT, no vnode leak.
Fix validation (single-fix autofs.ko)
Built the fixed autofs.ko module from /usr/src/sys/vfs/autofs/ with
the fix applied, installed it at /boot/kernel/autofs.ko, loaded it,
mounted autofs, and ran the race harness 3 times:
| Run | autofs.ko | Result |
|---|---|---|
| baseline | original (bc43bbβ¦) | panic: lost race at autofs_node_vn+0x2c4 |
| fix-1 | fixed (68eec6β¦) | 4/4 racers rc=0, same vp, no panic |
| fix-2 | fixed (68eec6β¦) | 4/4 racers rc=0, same vp, no panic |
| fix-3 | fixed (68eec6β¦) | 4/4 racers rc=0, same vp, no panic |
The fix is deterministic: all 4 racers always observe the same vp
(serialized by the lock), and no panic occurs. INVARIANTS is ON
(confirmed: grep -c INVARIANTS sys/config/X86_64_GENERIC = 1).
PoC changes
df0886_race.c+Makefileβ new deterministic kernel-module harness (not in the original PoC folder, which didn't exist). Calls the realautofs_node_vn()from 4 CPU-pinned kthreads.stress_autofs_race.cβ userspace concurrent-stat stress (probabilistic; documents the userspace reachability path but the window is too tight to fire reliably without vnode pressure).fix.diffβ the verified fix (holdan_vnode_lockacross getnewvnode).
Fix verification
fixedVALIDATED the fix: the race harness panicked the unpatched baseline autofs.ko (bc43bb10) with 'panic: lost race' at autofs_node_vn+0x2c4 (2 concurrent autofs_node_vn frames in the trace). After replacing autofs.ko with the fixed version (68eec66c, fix.diff applied) and kldload-ing it, the SAME harness ran 3 times with all 4 racers completing rc=0 and observing the same vp (serialized by the lock) -- NO panic. The fix holds an_vnode_lock across getnewvnode, serializing vnode creation and eliminating the race window.
BASELINE (bc43bb10): panic: lost race / autofs_node_vn() at autofs_node_vn+0x2c4 / autofs_node_vn() at autofs_node_vn+0x2c4 / racer_thread() at racer_thread+0x46 / db> || FIXED (68eec66c, run 1): racer[0] rc=0 vp=...c380 / racer[3] rc=0 vp=...c380 / racer[2] rc=0 vp=...c380 / racer[1] rc=0 vp=...c380 / no panic || FIXED run 2: all rc=0 vp=...c500 / no panic || FIXED run 3: all rc=0 vp=...c800 / no panic
Confirmed kernel references
Detail
Exploit chain
none -- this is a pure race-condition DoS. The write (anp->an_vnode = vp at :598) overwrites a vnode pointer with another vnode pointer; both are valid kernel objects, so there is no attacker-controlled content primitive and no path to uid=0. With INVARIANTS ON (default GENERIC): kernel panic (DoS). Without INVARIANTS: silent vnode leak + reclaim-state corruption (the lost vnode's v_data still points at the autofs node, leading to UAF on unmount). No escalation chain was developed because the primitive is not a write-capable corruption with attacker-controlled content.
Evidence (decisive lines)
BASELINE (unpatched autofs.ko bc43bb10): DF-0886: releasing 4 racers -- expect panic / panic: lost race / Trace: autofs_node_vn() at autofs_node_vn+0x2c4 / autofs_node_vn() at autofs_node_vn+0x2c4 / racer_thread() at racer_thread+0x46 / Stopped at Debugger+0x7c / db> || FIXED (autofs.ko 68eec66c, 3 runs): DF-0886 racer[0]: rc=0 vp=0xfffff8008f53c380 / racer[3]: rc=0 vp=0xfffff8008f53c380 / racer[2]: rc=0 vp=0xfffff8008f53c380 / racer[1]: rc=0 vp=0xfffff8008f53c380 / all racers done (no panic). All 4 racers observe the SAME vp -- serialized by the fix.
PoC changes
Created the entire PoC folder from scratch (only a DB row existed). Added: df0886_race.c -- deterministic kernel-module harness that finds the autofs mount via mountlist, NULLs the root node's an_vnode (simulating reclaim), and spawns 4 CPU-pinned kthreads (kthread_create_cpu) that spin-barrier then simultaneously call the REAL autofs_node_vn(). stress_autofs_race.c -- userspace concurrent-stat stress documenting the unprivileged reachability path. Makefile, build.sh, run.sh, VERDICT.md, README.md, fix.diff, manifest.json, panic.txt, fix_run.log, fix_build.log, env.txt.
Verified recommended fix
Hold an_vnode_lock across the sleepable getnewvnode() call in autofs_node_vn() (sys/vfs/autofs/autofs_vnops.c:589-598). Remove the mtx_unlock_ex at :589, add mtx_unlock_ex on the getnewvnode error-return path, add mtx_unlock_ex after the anp->an_vnode=vp assignment at :598, and remove the now-dead KASSERT at :597. mtx_t is a sleeping mutex in DragonFly (sys/sys/mutex2.h:153), so holding it across getnewvnode is safe. This is an original fix (the finding folder had no prior fix.diff); it matches the finding markdown's stated intent ('Fix: hold an_vnode_lock across getnewvnode').
Verdict
REPRODUCED. The race in autofs_node_vn() (sys/vfs/autofs/autofs_vnops.c:564-602) is real: the function drops an_vnode_lock at line 589 before sleeping in getnewvnode() (line 591) and assigns anp->an_vnode = vp at line 598 without re-taking the lock. The KASSERT at line 597 ('lost race') catches concurrent callers. A deterministic kernel-module harness (df0886_race.c) that spawns 4 CPU-pinned kthreads simultaneously calling the REAL autofs_node_vn() on the same NULL'd node panics the default GENERIC kernel (INVARIANTS ON): 'panic: lost race' at autofs_node_vn+0x2c4, with two autofs_node_vn frames in the stack trace proving the concurrent entry. The bug is userspace-reachable (autofs_nresolve at :232 is a VOP triggered by any stat()/ls under the mountpoint, called after am_lock is released at :225) but the race window is ~1 getnewvnode() call (~us), so a probabilistic userspace stress needs vnode-table pressure to widen it. The module harness is the deterministic proof.
No comments yet.