# 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.
