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)
PoC verification
Evidence pack
findings/poc/DF-2997 Β· 26 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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)
- The leaked vnode sits VS_ACTIVE with refcnt 0, exclusive lockmgr lock
held forever (
vrele1β0 without FINALIZE β sys/kern/vfs_lock.c:345-396). - 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 -9ignored, state D2, wchanvnode). Demonstrated twice (probe PIDs 1448/1468). - The vnodes are permanently unreclaimable:
debug.numvnodes599β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). - 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: afterkern.maxvnodes109306β575 thevnlrukernel thread went fromB5 vlruwt vnlrutoB5 vnode vnlruand never recovered (vnlru.after.baseline); the userexit-GC variant wedged an innocentcrondaemon (D5Vs vnode, PID 1526) and every unwarmed command that exec'd under pressure. - Admin recovery is impossible without a reboot:
umount -fof the poisoned mount wedges in D0 (PID 1484, wchanncplk); evenmount(listing mounts) wedges (PID 1488, wchanvnode).
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
fixedfix.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.
Confirmed kernel references
- sys/vfs/nfs/nfs_node.c:129
- sys/vfs/nfs/nfs_node.c:216
- sys/vfs/nfs/nfs_vnops.c:1238
- sys/vfs/nfs/nfs_vnops.c:1272
- sys/vfs/nfs/nfs_vnops.c:2348
- sys/vfs/nfs/nfs_vnops.c:2361
- sys/vfs/nfs/nfsm_subs.c:412
- sys/vfs/nfs/nfsm_subs.c:493
- sys/kern/vfs_lock.c:589
- sys/kern/vfs_lock.c:380
- sys/kern/vfs_lock.c:890
- sys/kern/vfs_mount.c:548
- sys/kern/vfs_mount.c:931
- sys/kern/vfs_syscalls.c:1085
- sys/vfs/nfs/nfs_vnops.c:1083
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.
No comments yet.