nfs_nget/nfs_nget_nonblock race-loser paths vx_put() a never-connected VNON vnode β vx_put forces VREF_FINALIZE for VNON, vnode_terminate runs VOP_INACTIVE, and nfs_inactive dereferences v_data == NULL at nfs_node.c:393 β guaranteed kernel panic whenever the duplicate-validation branch executes (unprivileged local race on any NFS mount)
Summary
When the validate-that-we-did-not-race loop finds a duplicate (:189-202 and :331-350), the code drops the freshly created vnode with a bare vx_put(vp) (:194,:336) and manually frees np. At that point vp is a stock getnewvnode product: v_type==VNON, v_data==NULL, NFS v_ops. DragonFly's vx_put forces VREF_FINALIZE for VNON/VBAD vnodes, so the 1->0 vrele runs vnode_terminate -> VOP_INACTIVE -> nfs_inactive, which executes sp = np->n_sillyrename on np = VTONFS(vp) == NULL (:389-393) -> page-0 fault panic. The stale view needed is real: lwkt_gettoken holds no critical section and lwkt_switch releases all tokens, so a scanner preempted mid-LIST_FOREACH can resume past another CPU's insert. Purpose-built harness (100k fnv-colliding fhs, 12 barrier-synced threads, udp_flood/memhog/oversubscription amplifiers): ~620k races across 3 configurations did NOT fire on the quiet QEMU guest (objcache magazine hits make creation effectively non-sleeping; full scheduling analysis in VERDICT.md) - not_reproduced dynamically, static chain certain end-to-end. Unpriv local on any NFS mount; panic-class, no memory corruption on this path. Fix (connect np<->vp before vx_put) compiled and booted in the DF-3195 validation kernel.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3194 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 5.6 KB | β raw | |
| VERDICT.md | β | 5.7 KB | β raw | |
| fakesrv_race.c | β | 12.4 KB | view raw | |
| race_trigger.c | β | 3.8 KB | view raw | |
| udp_flood.c | β | 1.3 KB | view raw | |
| memhog.c | β | 1.2 KB | view raw | |
| build.sh | β | 85 B | view raw | |
| run.sh | β | 103 B | view raw | |
| run3.sh | β | 1.0 KB | view raw | |
| run.log | β | 1.2 KB | view raw | |
| env.txt | β | 550 B | view raw | |
| build.log | β | 225 B | view raw | |
| fix.diff | β | 1.8 KB | view raw | |
| verdict.json | β | 4.7 KB | view raw |
DF-3194 β nfs_nget/nfs_nget_nonblock race-loser path vx_put()s a never-connected VNON vnode β forced VREF_FINALIZE β VOP_INACTIVE runs nfs_inactive with v_data == NULL β NULL-deref kernel panic
What this is
In nfs_nget() (sys/vfs/nfs/nfs_node.c) the "validate that we did not race
another nfs_nget()" loop (:189-202) handles a discovered duplicate by
dropping the freshly created-but-never-connected vnode with a bare
vx_put(vp) (:194), then manually freeing the nfsnode. The identical
pattern exists in nfs_nget_nonblock() (:336).
At that point vp is exactly what getnewvnode(VT_NFS, mntp, &vp, 0, 0)
returned: v_type == VNON (set by allocvnode, sys/kern/vfs_lock.c:1213),
v_data == NULL, v_mount == mntp (set by insmntque),
v_ops == &mp->mnt_vn_use_ops (NFS ops).
DragonFly's vx_put() forces VREF_FINALIZE for VNON/VBAD vnodes
(sys/kern/vfs_lock.c, vx_put), so the 1β0 refcount transition in vrele()
runs vnode_terminate() β VOP_INACTIVE(vp) β nfs_inactive():
np = VTONFS(ap->a_vp); /* NULL */
if (ap->a_vp->v_type != VDIR) { /* VNON != VDIR β true */
sp = np->n_sillyrename; /* NULL-deref β page fault β panic */
(sys/vfs/nfs/nfs_node.c:389-393)
Reachability of the loser path: an nfs_nget creator must end up with a stale view of its hash bucket. Two verified schedulings do this:
-
Mid-scan deschedule:
lwkt_gettoken()does not hold a critical section, andlwkt_switch()releases all of the thread's tokens (sys/kern/lwkt_thread.c:636-638). A creator preempted mid-LIST_FOREACHhas its token transparently released; a creator on another CPU can then complete a whole nfs_nget andLIST_INSERT_HEAD()the node behind the first thread's cursor. Resuming, the stale thread misses the new head, acquiresnfsnhash_lockwithout sleeping (LK_SLEEPFAIL only discards acquisitions that slept), creates its own vnode, and the validation loop finds the duplicate βvx_put(VNON vp)β panic. (The other nfs_nget caller must holdnfsnhash_lockduring this β satisfied when the preempted thread's outage covers the other creator's remaining scan+create time.) -
Winner blocks mid-create: the lockmgr winner blocks between
lockmgr(...LK_SLEEPFAIL)success andLIST_INSERT_HEAD(both allocations areM_WAITOK;getnewvnodeβallocvnodealso hasM_WAITOK/kmalloc_objfallbacks under VM pressure). A contender cycles in the SLEEPFAIL retry loop (lockmgrfails βgoto loopβ full-bucket rescan). When the winner wakes and inserts at the bucket head mid-rescan, the contender's cursor is already past the head; its nextlockmgrsucceeds sleep-free (the winner released) and its validation loop finds the duplicate β panic.
Trigger surface: any unprivileged local userspace concurrency on an
NFS-mounted system that drives concurrent nfs_nget() for the same fresh
file handle (concurrent stat/open of aliased names resolving to the same
fh β a namecache-resolution-serialized same-name lookup does NOT race, so
aliases or lookitup paths are needed).
PoC files
fakesrv_race.cβ NFSv3 server over loopback UDP. Precomputes 100,000 32-byte file handles that all satisfyfnv1_32(fh,32) & 0x1FFFF == 0(the guest's nfsnode hash mask;hashinit(vfs_inodehashsize())with kern.maxvnodes=109306 β hsize 131072). Names[a-m]<idx>all resolve to pool fh<idx>, so N threads can racenfs_nget()for one fh without namecache serialization. Pool cached in/tmp/fh_pool.bin.race_trigger.cβ fill phase (stat every pool name once β one hash bucket holds ~all nodes β nfs_nget's initial scan takes ~1-2 ms), then 12 pthread_barrier-synchronised threads stat the same fresh fh each round via distinct name aliases.udp_flood.c,memhog.cβ amplifiers (netisr preemption bursts; VM pressure for mid-create M_WAITOK blocks).
Build (in-guest, base cc)
cd /root/df3194 cc -O2 -o fakesrv_race fakesrv_race.c cc -O2 -pthread -o race_trigger race_trigger.c cc -O2 -o udp_flood udp_flood.c cc -O2 -o memhog memhog.c
Run
./fakesrv_race & # ~5 min: builds the colliding pool once mount_nfs -o port=2049,udp 127.0.0.1:/P /mnt ./udp_flood & ./udp_flood & ./memhog & ... # amplifiers ./race_trigger 24000 76000 # fill + hammer
Expected
- If the race fires:
Fatal trap 12: page fault while in kernel modeinnfs_inactive+β¦with a small fault address (β offsetof(struct nfsnode, n_un3), page-0 fault), current process = the stat()ing user program. The guest dies. - Observed in this campaign: no panic across ~620,000 barrier-synchronised 12-thread races over three configurations (shallow bucket; 76k-node deep bucket + netisr flood + host oversubscription; deep bucket + VM pressure). The two required schedulings need a β₯~100-200 Β΅s mid-scan LWKT preemption or a β₯~2 ms mid-create block of the lockmgr winner β neither could be forced reliably from unprivileged userspace in the QEMU guest (objcache magazine hits make the creation-phase allocations effectively non-sleeping, and quiet-guest preemptors run for Β΅s). Verdict: not_reproduced dynamically; static chain certain (see VERDICT.md for the full scheduling analysis).
Fix
fix.diff (verified to build; included in the DF-3195 validation kernel):
connect (np, vp) before vx_put() in both loser paths so the forced
VOP_INACTIVE/VOP_RECLAIM teardown sees a consistent pair (reclaim then
frees np because n_hash.le_prev == NULL), plus the vp = NULL fix for
the related stale-vp double-vput restart in nfs_nget_nonblock (:271-273).
DF-3194 VERDICT β NOT REPRODUCED dynamically (race); static chain certain; ~620k attempts across 3 amplified configurations
Bottom line
The "validate that we did not race another nfs_nget()" loser paths in
nfs_nget() (:189-202, drop at :194) and nfs_nget_nonblock() (:331-350,
drop at :336) release the freshly created vnode with a bare vx_put(vp):
vx_put(vp); /* nfs_node.c:194 / :336 */
lockmgr(&nfsnhash_lock, LK_RELEASE);
if (np->n_fhsize > NFS_SMALLFH) kfree(np->n_fhp, M_NFSBIGFH);
np->n_fhp = NULL;
kfree_obj(np, nmp->nm_mnode);
At that instant vp is a stock getnewvnode() product: v_type == VNON
(set in allocvnode, sys/kern/vfs_lock.c:1213), v_data == NULL,
v_mount == mntp (insmntque), v_ops = NFS ops. DragonFly's vx_put()
forces VREF_FINALIZE for VNON/VBAD vnodes (sys/kern/vfs_lock.c,
vx_put), so vrele()'s 1β0 transition runs vnode_terminate() β
VOP_INACTIVE(vp) β nfs_inactive():
np = VTONFS(ap->a_vp); /* NULL :389 */
if (ap->a_vp->v_type != VDIR) { /* VNON != VDIR :392 */
sp = np->n_sillyrename; /* NULL+offset deref :393 β page fault β panic */
Every link is verified in source; there is no guard anywhere in the chain
(vop_inactive dispatch is a plain DO_OPS, vnode_terminate only checks
v_mount != NULL).
Why the loser path is reachable (scheduling analysis)
nfsnhash_lock (exclusive) serializes insertion; a stale view therefore
requires the losing thread to observe the bucket across another
creator's insert. Two schedulings do it:
- Mid-scan deschedule.
lwkt_gettoken()does not hold a critical section over the protected region, andlwkt_switch()drops all of the thread's tokens (sys/kern/lwkt_thread.c:636-638). A creator preempted mid-LIST_FOREACH(nfs_node.c:116) transparently losesnfsnhash_token; a creator on another CPU then runs a complete nfs_nget andLIST_INSERT_HEAD()s the node behind the first thread's cursor. On resume the stale thread misses the new head, callslockmgr(... LK_SLEEPFAIL)β which succeeds without sleeping because the other creator already released β allocates its own vnode, and the validation loop finds the duplicate βvx_put(VNON vp)β panic. Requirement: preemption duration p > (scan progress c) + winner create time Οc; with a deep bucket and cβ0 this is p > Οc β 100-200 Β΅s. - Winner blocks mid-create. The lockmgr winner can block between
acquiring
nfsnhash_lockandLIST_INSERT_HEAD(both allocations are M_WAITOK;getnewvnodeβallocvnodehas M_WAITOK/kmalloc_objfallbacks under VM pressure). A contender then spins in the SLEEPFAIL retry loop (fail βgoto loopβ full-bucket rescan). If the winner's lockmgr-hold H β₯ one rescan D, its insert lands mid-rescan behind the contender's cursor; the contender's next lockmgr succeeds sleep-free and its validation loop finds the duplicate β panic. Requirement: one β₯ ~2 ms block of the winner (D β 1.8 ms with a 76k-node bucket).
Reproduction campaign (all on stock INVARIANTS kernel #0 Jul 2, 6 vCPU, 4 GB)
| run | bucket depth | amplifiers | races | result |
|---|---|---|---|---|
| 1 | 16k colliding fhs | 12 barrier-synced threads, host CPU hogs | ~200k | no panic |
| 2 | 58k | + loopback-UDP flood Γ2 (netisr preemption) | ~216k | no panic |
| 3 | 76k | + memhog Γ6 (VM pressure for mid-create blocks), 10 host hogs | ~288k | no panic |
Why it never fired: on this quiet QEMU guest, (a) LWKT preemptors that run longer than ~100 Β΅s are rare β per-packet netisr work is Β΅s-scale and user-level threads cannot preempt a thread in kernel mode mid-syscall; and (b) the creation-phase allocations are objcache-backed (magazine hits), so the winner essentially never sleeps mid-create. Both conditions are ordinary on a real loaded NFS client (interrupt bursts, VM pressure, NFS timer/callout storms) but could not be forced deterministically from unprivileged userspace here.
Status: not_reproduced (dynamically). Confidence in the defect: certain (statically) / likely (to occur in the wild). The PoC infrastructure (colliding-fh pool builder, alias-name racing driver, amplifiers) is left in the pack for re-testing on noisier hardware.
Fix
fix.diff β connect (np, vp) before the drop in both loser paths so the
forced VOP_INACTIVE/VOP_RECLAIM teardown sees a consistent pair
(nfs_reclaim frees np because n_hash.le_prev == NULL), plus the stale
vp = NULL fix for the related double-vput restart in
nfs_nget_nonblock (:271-273, DF-3196).
Fix build: both hunks compiled into the DF-3195 validation kernel
(make nativekernel RC=0, nfs_node.c compile confirmed, installed,
booted, NFS mount/lookup lifecycle exercised β see DF-3195's run.fixed.log
which ran on the kernel containing this fix too). Behavioral
before/after comparison is not possible because the baseline race never
fired in-guest. fix_status: inconclusive (builds & boots; race not
triggerable to compare).
Threat
Unprivileged local user on any NFS-mounted DragonFly system; trigger is ordinary concurrent path resolution (stat/open of names aliasing to the same file handle β the server controls fh identity for LOOKUP replies, so even an honest server plus a flaky scheduler suffices). Outcome: kernel page fault β panic (local DoS). No memory corruption on this path (NULL + fixed-offset read).
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
inconclusivefix.diff compiles and boots (included in the DF-3195 validation kernel build, RC=0; NFS mount/lookup/open/unlink lifecycle exercised clean on it - see DF-3195 run.fixed.log), but a behavioral before/after comparison is impossible because the baseline race could not be triggered in-guest.
fix.diff hunks 1-2 (nfs_nget :191/:280 loser paths); DF-3195 pack run.fixed.log ran on the kernel containing this fix.
Confirmed kernel references
Detail
Evidence (decisive lines)
['VERDICT.md: full scheduling analysis with the two reachable interleavings, the p > c + tau_c / H >= D conditions, and the campaign table', 'run.log: console excerpts of all three hammer runs (each finished 18000-40000 rounds x 12 threads without a panic; guest stayed up)', 'fakesrv_race.c: NFSv3 server precomputing 100000 file handles with fnv1_32(fh,32)&0x1FFFF==0 (guest mask; hashinit of vfs_inodehashsize() with kern.maxvnodes=109306 -> hsize 131072) and mapping names [a-m]<idx> to pool[idx] so 12 threads race nfs_nget for one fh without namecache serialization', 'race_trigger.c: fill phase + pthread_barrier hammer', 'fix.diff: connect (np,vp) before vx_put in both loser paths (plus DF-3196 hunk)']
PoC changes
n/a (purpose-built harness: colliding-fh pool with on-disk cache /tmp/fh_pool.bin, per-thread name aliases to defeat namecache-resolution serialization, fill+hammer driver, udp_flood/memhog amplifiers).
Verified recommended fix
In both loser paths, connect np<->vp (np->n_vnode = vp; vp->v_data = np) before vx_put() so the forced VOP_INACTIVE/VOP_RECLAIM teardown sees a consistent pair and nfs_reclaim frees np (n_hash.le_prev == NULL); see fix.diff.
Verdict
Statically certain, dynamically not reproduced in ~620k barrier-synchronised 12-thread same-filehandle nfs_nget races across three amplified configurations (16k/58k/76k colliding-fh hash-bucket depths, loopback netisr UDP flood, VM pressure via memhogs, host vCPU oversubscription). The defect: both nfs_nget() race-loser paths (nfs_node.c:194 and :336) drop the freshly created never-connected vnode with a bare vx_put(); vx_put() forces VREF_FINALIZE for VNON vnodes, vnode_terminate() then runs VOP_INACTIVE on it and nfs_inactive() dereferences v_data == NULL at nfs_node.c:393 -> guaranteed page-fault panic whenever the loser path executes. The loser path requires a stale bucket view: either a >=~100-200us mid-scan LWKT preemption of a token-holding scanner (lwkt_gettoken holds no critical section; lwkt_switch releases all tokens, lwkt_thread.c:636) while another CPU completes a full nfs_nget creation, or a >=~2ms block of the lockmgr winner between lockmgr acquire and LIST_INSERT_HEAD while a contender is mid-rescan in the LK_SLEEPFAIL retry loop. Neither could be forced deterministically from unprivileged userspace in the quiet QEMU guest (objcache magazine hits make creation-phase allocations effectively non-sleeping; per-packet netisr preemptors run for microseconds). Impact if it fires in the wild: local unprivileged kernel panic on any NFS-mounted system.
No comments yet.