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

nfs_lookup/nfs_mkdir error paths vrele() vnodes that nfs_nget returned LK_EXCLUSIVE-locked β€” permanent vnode-lock leak, mount wedge / vnode exhaustion DoS from malformed server replies

Field Value
ID DF-2997
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-667
File sys/vfs/nfs/nfs_vnops.c
Lines 1270-1274, 2346-2361 (contrast: :1088, :1228)
Area vfs/nfs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:vfs
Reported pending
Known CVE none
CVE match novel

Summary

nfs_nget returns vnodes locked LK_EXCLUSIVE (nfs_node.c:129 vget, :216 vx_downgrade). nfs_lookup's error exit and the ERROROUT post-op-attr paths reached with a successfully nget'd newvp, plus nfs_mkdir's 'EEXIST || !gotvp' vrele and its error tail, drop those vnodes with vrele() instead of vput() β€” the exclusive lock is never released, so the vnode can never be locked or recycled again. nfs_nresolve (the new-API equivalent) does it correctly with vput(), proving the inconsistency. Trigger: a malicious server that returns a valid file handle in a LOOKUP/MKDIR reply followed by malformed post-op attributes (nfsm_dissect NULL β†’ EBADRPC) β€” each occurrence permanently wedges one vnode; repetition exhausts the vnode pool and stalls all operations touching the affected vnodes (including unkillable processes on the mount). Unpriv local user on a client whose NFS server is malicious/compromised (or an active spoofer). Not verified this run (budget spent on DF-2996); code-reading certain. Fix: vput() on nfs_nget-sourced vnodes (vrele remains correct for the dvp-echo vref'd case).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of nfs_vnops.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2997 Β· 26 files
FileTypeDescriptionSize
fakesrv.c β€” 17.0 KB view raw
probe.c β€” 1.3 KB view raw
build.sh β€” 113 B view raw
push.sh β€” 342 B view raw
run.sh β€” 357 B view raw
run_fixed2.sh β€” 3.4 KB view raw
run_stocknod.sh β€” 1.0 KB view raw
fix.diff β€” 1.1 KB view raw
run.raw.baseline β€” 5.3 KB ↓ download
fakesrv.log.baseline β€” 12.8 KB ↓ download
ps.now.baseline β€” 7.0 KB ↓ download
vnlru.before.baseline β€” 7.0 KB ↓ download
vnlru.after.baseline β€” 7.1 KB ↓ download
vn.now.baseline β€” 44 B ↓ download
run_fixed.raw β€” 4.8 KB ↓ download
run_fixed2.raw β€” 5.3 KB ↓ download
run_stocknod.raw β€” 403 B ↓ download
fakesrv2.fixed.log β€” 58 B view raw
fakesrv3.stocknod.log β€” 344 B view raw
panic_fixed.txt β€” 1.7 KB view raw
panic_stocknod.txt β€” 1.8 KB view raw
env.txt β€” 1.2 KB view raw
VERDICT.md β€” 7.7 KB ↓ raw
README.md β€” 4.5 KB ↓ raw
manifest.json β€” 3.0 KB view raw
verdict.json β€” 7.6 KB view raw

DF-2997 β€” PoC evidence pack

nfs_lookup/nfs_mkdir error paths vrele() vnodes that nfs_nget returned LK_EXCLUSIVE-locked β€” permanent vnode-lock leak; unkillable processes, wedged recycler (vnlru), unreclaimable vnode growth from malformed NFS server replies.

What is in here

file what
fakesrv.c userland malicious NFSv3/UDP server (rpcbind :111, mountd :779, nfs :2049 on 127.0.0.1). Four exports keyed by root filehandle: /ct control (honest), /lk LOOKUP-truncation (leak via nfs_vnops.c:1272), /mk MKDIR-truncation (leak via nfs_vnops.c:2361), /nd MKNOD-truncation (drives the STOCK kernel already-vput error path in nfs_mknodrpc β€” used only to prove the umount panic pre-exists)
probe.c one-shot syscall probe (mkdir / open-O_CREAT / stat / mknod-FIFO) printing errno
build.sh cc -O2 -Wall -o fakesrv fakesrv.c; cc -O2 -Wall -o probe probe.c (in guest)
run.sh baseline run on the stock kernel: control, both leak triggers, 80-name growth, wedge probes (repeat syscall β†’ D-state), umount attempt, vnlru pressure
run_fixed2.sh fix validation on the patched kernel (same rig; wedge probes must FAIL FAST; vnlru must stay healthy)
run_stocknod.sh stock-kernel proof that the unmount: dangling vnode panic pre-exists (MKNOD path, no DF-2997 site involved)
fix.diff the verified fix: vput() for nfs_nget-sourced newvp at nfs_vnops.c:1272 / :2348 / :2361 (vrele kept for the dvp-echo cases)
run.raw.baseline FULL baseline log (stock): 82x errno=72 leaks, NVN 599β†’672 never reclaimed, probes D2 wchan vnode surviving kill -9, umount -f wedged D0, mount wedged D0
fakesrv.log.baseline server log: per-name LOOKUP #1 (nresolve, NOENT), #2 (compat-shim old lookup, truncated reply = the leak), #3 (wedge attempt)
ps.now.baseline, vnlru.before/after.baseline, vn.now.baseline process/vnode state snapshots; vnlru.after shows the recycler kthread wedged (B5 vnode vnlru) and cron wedged (D5Vs vnode) after maxvnodes pressure
run_fixed.raw, run_fixed2.raw FULL patched-kernel logs: all symptoms gone
run_stocknod.raw stock MKNOD→umount panic proof log
panic_fixed.txt, panic_stocknod.txt serial-console panic transcripts (pre-existing VNON/vflush defect)
env.txt, VERDICT.md, manifest.json, verdict.json environment, narrative, catalog, machine verdict

How to reproduce (baseline, stock kernel)

dfbsd-qemu/vm.sh reset with-src
sh push.sh
dfbsd-qemu/vm.sh run_root 'cd /root/df2997 && sh build.sh && sh run.sh > run.raw 2>&1'

Expected (stock): every malicious trigger fails fast with errno=72 (RPC struct is bad); debug.numvnodes climbs permanently (599β†’672, zero reclaim after 20s); the SECOND syscall on a leaked name hangs in D2 state, wchan vnode, immune to kill -9; umount -f and even mount (listing) wedge in D-state; after sysctl kern.maxvnodes=<85% of numvnodes> the vnlru kernel thread wedges (B5 vnode vnlru) and innocent processes (cron) wedge at exit. The only recovery is a reboot.

How to reproduce (fix validation)

# apply fix.diff to the guest /usr/src copy (NOT to the audit tree)
dfbsd-qemu/vm.sh run_root 'cd /usr/src && git apply /root/df2997/fix.diff'
dfbsd-qemu/vm.sh run_root 'cd /usr/src && make nativekernel KERNCONF=X86_64_GENERIC && make installkernel KERNCONF=X86_64_GENERIC'
# reboot, then:
dfbsd-qemu/vm.sh run_root 'cd /root/df2997 && sh run_fixed2.sh > run_fixed2.raw 2>&1'

Expected (patched): identical control behavior; the same errno=72 errors (protocol behavior unchanged); repeat syscalls FAIL FAST and the processes exit (no D-state); vnodes are reclaimed (665β†’629β†’481); vnlru stays vlruwt under the same pressure; cron survives. The final umount -f of the still-poisoned mount panics with unmount: dangling vnode β€” a PRE-EXISTING latent defect (vmntvnodescan skips VNON vnodes, sys/kern/vfs_mount.c:931/:978), independently reproduced on the STOCK kernel via run_stocknod.sh through the stock nfs_mknodrpc error path that already vput()s. On the stock kernel the same umount does not even get that far: it hangs forever on the wedged vnode locks.

Attack preconditions (honest)

The trigger requires the client kernel to accept NFS replies from an attacker: a malicious/compromised NFS server, an on-path UDP spoofer (NFSv3/UDP + AUTH_SYS provides no integrity), or an unprivileged local user on a client that mounts attacker-influenced exports. The mount itself must exist (root/automount). Every trigger syscall runs as an unprivileged user (uid 1001 in the demo).

VERDICT.md
↓ download raw

DF-2997 VERDICT β€” nfs_lookup/nfs_mkdir error paths vrele() LK_EXCLUSIVE-locked vnodes β†’ permanent vnode-lock leak β†’ unkillable processes, wedged vnlru, unreclaimable vnode growth (DoS); fix validated

Status: REPRODUCED β€” impact: dos (no privilege escalation; hang-class). Fix validated on a rebuilt kernel: leak, wedge, unkillability, recycler wedge and non-reclamation all eliminated; honest-server behavior identical. One pre-existing, separate latent defect (VNON vnodes skipped by vflush β†’ umount -f panic) was unmasked and independently proven to exist on the stock kernel; it is NOT introduced by the fix (stock hangs at the same point instead of panicking β€” strictly worse).

Root cause (line-accurate)

nfs_nget() returns every vnode LK_EXCLUSIVE-locked with a reference held: - hash-hit path: vget(vp, LK_EXCLUSIVE) β€” sys/vfs/nfs/nfs_node.c:129; - fresh path: getnewvnode() (VX-locked) then vx_downgrade(vp) β€” sys/vfs/nfs/nfs_node.c:163,216.

Leak site 1 β€” nfs_lookup. For a LOOKUP reply that carries a valid filehandle followed by truncated/garbage post-op attributes: - nfsm_getfh() succeeds β€” sys/vfs/nfs/nfs_vnops.c:1175; - nfs_nget() creates the vnode β€” sys/vfs/nfs/nfs_vnops.c:1238, and newvp = NFSTOV(np) β€” :1249 (locked); - ERROROUT(nfsm_postop_attr(&info, newvp, ...)) fails β€” :1251-1253; nfsm_postop_attr β†’ nfsm_dissect returns NULL on short data β†’ EBADRPC (sys/vfs/nfs/nfsm_subs.c:493-495, 287-324); - nfsmout: tail drops it with vrele(newvp) β€” sys/vfs/nfs/nfs_vnops.c:1269-1274. The exclusive lock is never released.

Leak site 2 β€” nfs_mkdir. For a MKDIR reply [ok][fhfollow=1][fh] with nothing after the fh: - nfsm_mtofh() runs nfs_nget() and sets *vpp β€” sys/vfs/nfs/nfsm_subs.c:412,418 β€” then the attrs-bool dissect fails β†’ EBADRPC β€” :421-423; - nfs_mkdir error tail: vrele(newvp) β€” sys/vfs/nfs/nfs_vnops.c:2359-2361; same for the (!error && !gotvp) kludge drop β€” :2346-2350.

Contrast (correct code already in-tree): nfs_nresolve releases with vput() (nvp == dvp ? vrele : vput) β€” sys/vfs/nfs/nfs_vnops.c:1083-1089; nfs_readdirplusrpc uses the same idiom β€” :2968-2974; nfs_lookitup β€” :3106-3124. Only the two old-API sites above are wrong.

Consequence chain (all demonstrated on the stock kernel)

  1. The leaked vnode sits VS_ACTIVE with refcnt 0, exclusive lockmgr lock held forever (vrele 1β†’0 without FINALIZE β€” sys/kern/vfs_lock.c:345-396).
  2. Any later reference to the same name/filehandle funnels into nfs_nget (hash-hit) β†’ vget β†’ vn_lock(vp, LK_EXCLUSIVE) β€” sys/kern/vfs_lock.c:589-592 β€” and sleeps forever with no PCATCH: the triggering process is unkillable (kill -9 ignored, state D2, wchan vnode). Demonstrated twice (probe PIDs 1448/1468).
  3. The vnodes are permanently unreclaimable: debug.numvnodes 599β†’672 after 82 leaks and did not drop by one after 20s of idle (run.raw.baseline), while the same rig on the patched kernel reclaims them (665β†’629β†’481 under identical pressure).
  4. The recycler wedges: cleanfreevnode() deactivate rover refs a 0-ref wedged vnode, sets VREF_FINALIZE, vrele() β†’ finalize path β†’ vx_lock(vp) blocks forever β€” sys/kern/vfs_lock.c:890-895 β†’ :380-387. Demonstrated: after kern.maxvnodes 109306β†’575 the vnlru kernel thread went from B5 vlruwt vnlru to B5 vnode vnlru and never recovered (vnlru.after.baseline); the userexit-GC variant wedged an innocent cron daemon (D5Vs vnode, PID 1526) and every unwarmed command that exec'd under pressure.
  5. Admin recovery is impossible without a reboot: umount -f of the poisoned mount wedges in D0 (PID 1484, wchan ncplk); even mount (listing mounts) wedges (PID 1488, wchan vnode).

Reproduction matrix (guest = DragonFly 6.5-DEVELOPMENT, INVARIANTS

X86_64_GENERIC; fake userland NFSv3/UDP server in-guest; triggers run as unprivileged uid 1001; mount setup by root β€” see preconditions)

kernel server mode trigger result
stock control (honest) unpriv mkdir/stat/touch/ls, umount all OK (rig healthy)
stock lkmal (truncated LOOKUP attrs) unpriv touch w0 β†’ errno=72; repeat touch w0 leak; repeat hangs D2 wchan vnode, kill -9 ignored
stock mkmal (truncated MKDIR attrs) unpriv mkdir m0 β†’ errno=72; repeat mkdir m0 leak; repeat hangs D2 wchan vnode, kill -9 ignored
stock mkmal Γ—80 names 80 Γ— errno=72 82 vnodes permanently unreclaimable (672 forever)
stock + kern.maxvnodes pressure β€” vnlru kthread wedged (vnode wchan); cron wedged; every vnode-allocating exec at risk
patched control same as stock control identical OK
patched lkmal/mkmal Γ—82 same triggers errno=72 as before (protocol error unchanged), vnodes reclaimed (481 end), NO D-state, NO wedge, vnlru stays vlruwt, cron healthy
patched same + final umount -f of poisoned mount β€” panic: unmount: dangling vnode β€” pre-existing latent defect, see below
stock ndmal (truncated MKNOD attrs; nfs_mknodrpc already vput()s on stock) unpriv mknod n0 p β†’ errno=72, then umount -f same panic: unmount: dangling vnode on STOCK β€” proves the panic pre-exists the fix

The unmasked latent defect (follow-up, not DF-2997)

vmntvnodescan() skips v_type == VNON vnodes β€” sys/kern/vfs_mount.c:931 and :978 β€” so vflush() never processes never-attributed vnodes; they remain on mnt_nvnodelist and dounmount() panics (sys/kern/vfs_syscalls.c:1085-1086) once VFS_UNMOUNT reports success. Reachable on the stock kernel through the already-correct vput() error path of nfs_mknodrpc (sys/vfs/nfs/nfs_vnops.c:1655-1657) with a single truncated MKNOD reply (run_stocknod.raw + panic_stocknod.txt). On the stock kernel, DF-2997 masks this by wedging umount -f in an unkillable sleep even earlier. Recommended follow-up: make the VMSC_GETVX slow path (or vflush) handle VNON vnodes, or have the NFS error paths discard never-attributed vnodes with a full vx_lock/vgone_vxlocked/vx_unlock teardown instead of leaving them for the recycler.

Fix validated (fix.diff)

sys/vfs/nfs/nfs_vnops.c, three hunks, mirroring the in-tree idiom of nfs_nresolve:1083-1089 / nfs_readdirplusrpc:2968-2974: - nfs_lookup nfsmout: newvp == dvp ? vrele(newvp) : vput(newvp); - nfs_mkdir kludge drop (:2348): vput(newvp) (always nfs_nget-sourced); - nfs_mkdir error tail (:2361): newvp == dvp ? vrele : vput.

A/B results: patched kernel shows zero lock leaks, zero D-states, reclaimable vnodes, healthy vnlru and cron under the same pressure that wedged the stock kernel, and bit-identical honest-server behavior (control mount: mkdir/stat/touch/ls/umount all OK on both kernels). The EBADRPC errno=72 delivered to the triggering syscall is unchanged (correct: the reply really is malformed).

Impact ceiling (honest)

Denial of service only: unkillable local processes, per-name mount wedges, permanent kernel-memory/vnode growth, recycler-thread death, reboot-required recovery. No memory corruption, no info leak, no privilege escalation primitive. Precondition: attacker-controlled or spoofed NFS server replies (or an attacker-influenced export mounted by the client); triggering syscalls themselves run unprivileged. Matches the filed severity Medium, CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H.

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

fix.diff applied to the guest /usr/src (git apply, 3 hunks clean), kernel rebuilt with make nativekernel KERNCONF=X86_64_GENERIC and installed (build in build.log in guest), rebooted into kernel #1. Identical rig: all wedge probes fail fast and exit (no D-state, no unkillable processes), leaked vnodes are reclaimed (665->629->481 under the same maxvnodes pressure that stock never reclaims under), vnlru stays vlruwt, cron survives, honest control mount bit-identical. The errno=72 EBADRPC user-visible error is unchanged, as expected. Remaining caveat, proven pre-existing on stock: umount -f of a still-poisoned mount panics (unmount: dangling vnode) because vflush skips VNON vnodes - recommend a follow-up fix in vmntvnodescan/vflush or a full vgone teardown of never-attributed vnodes in the NFS error paths.

↓ fix.diffper-fix-DF-2997

Confirmed kernel references

Detail

Exploit chain

malicious/compromised/spoofing NFS server (or attacker-influenced export) replies LOOKUP/MKDIR with valid fh + truncated attrs -> unprivileged client user runs touch/mkdir -> nfs_nget creates vnode LK_EXCLUSIVE-locked -> EBADRPC error path vrele()s it -> vnode permanently locked with refcnt 0 -> any later lookup of that name (any API: nresolve, old lookup, mtofh) blocks forever in vget/vn_lock, uninterruptible and unkillable -> repeat with N distinct filehandles -> N permanently unreclaimable vnodes -> vnode-pool pressure drives the vnlru deactivate rover into vx_lock on a wedged vnode -> recycler kernel thread dies -> innocent processes wedge at exit via allocvnode_gc -> system-wide vnode exhaustion, no unmount, no recovery without reboot

Evidence (decisive lines)

run.raw.baseline (full stock log: 82x errno=72, NVN 599->672 no reclaim, probes D2 wchan vnode surviving kill -9, umount/mount D0); fakesrv.log.baseline (LOOKUP #1 NOENT nresolve, #2 truncated = the leak, #3 = wedge attempt); vnlru.after.baseline (vnlru B5 vnode wedged, cron D5Vs vnode); run_fixed2.raw (patched: probes fail fast and exit, NO_PROBE_PROCESSES, NVN 665->629->481, vnlru vlruwt, cron nanslp); run_stocknod.raw + panic_stocknod.txt (stock kernel panics unmount: dangling vnode via nfs_mknodrpc truncated-MKNOD path, no DF-2997 site involved - proves pre-existence); fix.diff (3 hunks, applied in guest, nativekernel rebuilt, validated)

PoC changes

No seeded pack existed; built the rig from scratch, adapted from the proven DF-2996 userland NFSv3/UDP fake server. Key design decisions verified against the DF client source: per-name LOOKUP counting (#1 = nresolve pre-check must get NOENT so the create proceeds, #2 = compat-shim VOP_OLD_LOOKUP gets the truncated positive reply that leaks, #3+ = wedge trigger); three exports keyed by root filehandle so one server instance serves control/lkmal/mkmal mounts simultaneously; deterministic name->fh mapping so repeats hash-hit the wedged vnode; a fourth export /nd truncating MKNOD replies to prove the umount panic pre-exists on stock through nfs_mknodrpc. Also fixed in the process: run.sh phase ordering (reclaim pressure sysctl moved before growth for the fixed run), binary pre-warming before kern.maxvnodes lowering (unwarmed execs wedge at exit on stock via allocvnode_gc - itself corroborating evidence), and probe.c gained stat and mknod ops plus an unistd include fix.

Verified recommended fix

vput() nfs_nget-sourced vnodes at the three error tails (nfs_vnops.c:1272, :2348, :2361), keeping vrele() only for the dvp-echo cases - mirrors nfs_nresolve:1083-1089; follow-up: make vflush handle VNON vnodes so never-attributed vnodes cannot dangle at forced unmount

Verdict

Reproduced end to end on the stock INVARIANTS guest with an in-guest userland NFSv3/UDP server. nfs_nget returns vnodes LK_EXCLUSIVE-locked (nfs_node.c:129 vget, :163+:216 getnewvnode/vx_downgrade); a LOOKUP or MKDIR reply carrying a valid filehandle followed by truncated post-op attributes makes nfsm_postop_attr / nfsm_mtofh return EBADRPC after a successful nfs_nget, and the nfs_lookup nfsmout tail (nfs_vnops.c:1272) and the nfs_mkdir error tails (:2348, :2361) drop the locked vnode with vrele() instead of vput(), so the exclusive lock is never released. Demonstrated consequences on stock: the triggering syscall sees a clean errno=72 while each occurrence permanently wedges one vnode; any later reference to the same name sleeps forever in vget/vn_lock (state D2, wchan vnode, kill -9 ignored - two unkillable processes left running); 82 leaked vnodes showed zero reclamation after 20s (672 forever); umount -f and even mount listing wedge in D-state; under kern.maxvnodes pressure the vnlru kernel recycler thread wedged permanently (B5 wchan vnode) and an innocent cron daemon wedged at exit; only a reboot recovers. No memory corruption and no info leak, so the ceiling is denial of service; precondition is attacker-controlled or spoofed NFS server replies (NFSv3/UDP, AUTH_SYS, no integrity) or an attacker-influenced export mounted by the client, with every triggering syscall running unprivileged (uid 1001). fix.diff (vput for nfs_nget-sourced newvp at the three sites, vrele kept for the dvp-echo cases, mirroring the in-tree idiom of nfs_nresolve:1083-1089) was built and booted: identical honest-server behavior, same errno=72 protocol errors, zero D-states, vnodes reclaimed (665 to 629 to 481), vnlru and cron healthy under identical pressure. One separate latent defect was unmasked: vflush skips v_type==VNON vnodes (vfs_mount.c:931/:978) so forced unmount of a mount holding never-attributed vnodes panics (unmount: dangling vnode); proven pre-existing on the STOCK kernel via the already-vput error path of nfs_mknodrpc with one truncated MKNOD reply (run_stocknod.raw, panic_stocknod.txt); on stock the DF-2997 wedge masks it by hanging umount forever instead - strictly worse.