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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3194 Β· 14 files
FileTypeDescriptionSize
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:

  1. Mid-scan deschedule: lwkt_gettoken() does not hold a critical section, and lwkt_switch() releases all of the thread's tokens (sys/kern/lwkt_thread.c:636-638). A creator preempted mid-LIST_FOREACH has its token transparently released; a creator on another CPU can then complete a whole nfs_nget and LIST_INSERT_HEAD() the node behind the first thread's cursor. Resuming, the stale thread misses the new head, acquires nfsnhash_lock without 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 hold nfsnhash_lock during this β€” satisfied when the preempted thread's outage covers the other creator's remaining scan+create time.)

  2. Winner blocks mid-create: the lockmgr winner blocks between lockmgr(...LK_SLEEPFAIL) success and LIST_INSERT_HEAD (both allocations are M_WAITOK; getnewvnode→allocvnode also has M_WAITOK/kmalloc_obj fallbacks under VM pressure). A contender cycles in the SLEEPFAIL retry loop (lockmgr fails → 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 next lockmgr succeeds 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 satisfy fnv1_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 race nfs_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 mode in nfs_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).

VERDICT.md
↓ download raw

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:

  1. Mid-scan deschedule. lwkt_gettoken() does not hold a critical section over the protected region, and lwkt_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 loses nfsnhash_token; a creator on another CPU then runs a complete nfs_nget and LIST_INSERT_HEAD()s the node behind the first thread's cursor. On resume the stale thread misses the new head, calls lockmgr(... 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.
  2. Winner blocks mid-create. The lockmgr winner can block between acquiring nfsnhash_lock and LIST_INSERT_HEAD (both allocations are M_WAITOK; getnewvnodeβ†’allocvnode has M_WAITOK/kmalloc_obj fallbacks 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

inconclusive
baseline no→ patch + rebuild →patched clean

fix.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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 8 08:54:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.