# DF-2998 VERDICT — nfs_loadattrcache retypes live vnodes from server attributes; engine verified live with two independent kernel panics; fix validated

**Status: REPRODUCED (fresh verification on the stock kernel, 2026-10-12)
— impact: kernel type confusion → deterministic panics (memory
corruption class). Fix VALIDATED on a single-fix rebuilt kernel.**

## Root cause (line-accurate, current tree)

`nfs_loadattrcache()` — `sys/vfs/nfs/nfs_subs.c:723-733`:

```c
	np = VTONFS(vp);
	if (vp->v_type != vtyp) {              /* :723 — no VNON guard */
		nfs_setvtype(vp, vtyp);        /* flips v_type + vinitvmio */
		if (vp->v_type == VFIFO) {
			vp->v_ops = &vp->v_mount->mnt_vn_fifo_ops;
		} else if (vp->v_type == VCHR || vp->v_type == VBLK) {
			vp->v_ops = &vp->v_mount->mnt_vn_spec_ops;
			addaliasu(vp, rmajor, rminor);   /* server rdev */
		} else {
			vp->v_ops = &vp->v_mount->mnt_vn_use_ops;
		}
		np->n_mtime = mtime.tv_sec;
	}
```

`vtyp/rmajor/rminor` come straight from the wire (`nfsv3tov_type`
bounds only the enum). There is no "only at creation" guard: an
EXISTING, typed, possibly open vnode is retyped, its `v_ops` switched,
and for VCHR/VBLK `addaliasu()` records the server-chosen device
numbers. Every attribute load in the NFS client funnels here (GETATTR,
LOOKUP/ACCESS/READDIR(PLUS) post-op attrs, WCC paths) — call sites in
nfs_vnops.c at :1071-1077 (read/write attr loads), :1420, :2903
(readdirplus), :3099-3101 (lookitup), etc.

## What was demonstrated (stock kernel, fresh `vm.sh reset with-src`)

Rig: `fakesrv2998` (userland NFSv3 server, loopback) + `trigger2998`
(driver). Mount with `acregmin=1,acregmax=1,acdirmin=1,acdirmax=1`
pins attribute-cache expiry to 1s so each lie lands deterministically
on the next stat. (With default cache settings the same happens after
up to 60s — the ancient-mtime clamp; observed in an earlier run where
a 12s sleep wasn't enough.)

1. **Live VREG→VDIR retype of an open file** (`run.log` TEST 1):
   `fstat(fd)` BEFORE: `type=REGULAR`; after `LIE file DIR` + sleep:
   `type=DIRECTORY` — the vnode behind an open, written-to file fd was
   retyped mid-life by a single attribute reply. Confirmed identically
   as unprivileged user `maxx` (TEST 1b) — no privileges needed on the
   client side; the lying is entirely server-side.
2. **Repeated retypes of one live vnode**: REG→DIR→CHR→DIR, each
   visible via fstat (`AFTER-DIR-LIE`, `AFTER-CHR-LIE`,
   `AFTER-DIR-RELIE`).
3. **VCHR injection**: `AFTER-CHR-LIE: type=CHARDEV` with server-chosen
   mode/rdev (`addaliasu` + `mnt_vn_spec_ops` switch, nfs_subs.c:728).
   Honest limitation: `open()` succeeds as a stub but `read()` returns
   EINVAL — NFS special vnodes use `nfsv2_spec_vops`
   (`vop_stdnoread`/`vop_stdnowrite`, sys/vfs/nfs/nfs_vnops.c:162-175)
   and devfs binds devices by `v_rdev` pointer (never set for NFS
   nodes; `devfs_spec_open` would ENXIO on NULL). So no raw-device I/O
   through the injected node on this DragonFly — the injection is a
   namespace/type spoof (server-controlled st_mode/st_uid/st_rdev
   visible to stat/tooling), not the old-BSD device-access primitive.
4. **Panic A — `nfs_removerpc` GPF** (`panic.removerpc.txt`): the
   getdents probe on the retyped-VDIR "file" caches nfsdmap cookie
   blocks on the node (`nfs_getcookie(add=1)`, nfs_subs.c:1338-1356,
   via READDIR offsets > 4KiB). A later lie leaves the node VCHR at
   inactive time; `nfs_inactive()` gates the sillyrename branch on
   `vp->v_type != VDIR` (nfs_node.c:392-395) and reads
   `np->n_sillyrename` — which ALIASES the cookie list head (`n_un3`
   union, nfsnode.h:125-133) — non-NULL, so `nfs_removeit(sp)`
   (nfs_vnops.c:1913) dereferences the nfsdmap as `struct
   sillyrename`: `sp->s_dvp` = `ndm_list.le_prev` (pointer into the
   nfsnode) treated as a vnode → `Fatal trap 9 ... Stopped at
   nfs_removerpc+0x57: movl (%rax),%eax`. Then (if it survived)
   `crfree(sp->s_cred)`, `vrele(sp->s_dvp)`, `kfree(sp, M_NFSREQ)`
   (wrong zone) would follow. First observed live (unplanned) when
   umounting the TEST-1 leftover mount; deterministic.
5. **Panic B — buffer-cache state confusion at retype time**
   (`panic.bgetvp.txt`, `run.log` TEST 2): directory `big` (>4KiB of
   dirents drained → cookies cached), then `LIE big REG` + `stat`:
   applying the VDIR→VREG retype makes
   `nfs_getattrcache()`'s shrink path (nfs_subs.c:943-949:
   `vap->va_type == VREG` → `nfs_meta_setsize` → `nvtruncbuf`) feed the
   node's DIRECTORY buffer state to the regular-file truncate
   machinery → `panic: bgetvp - overlapping buffer` with trace
   `bgetvp ← getblk ← breadnx ← nvtruncbuf ← nfs_getattrcache`. The
   panic fires inside the attribute-load machinery itself — no umount
   needed.
6. **Third sink (already persisted as DF-2996's `typelie`)**: retype
   VREG→VDIR + sillyrename stores `sp` into the cookie head → reclaim
   walk panics. That finding's fix handles the sillyrename half; the
   retype engine is this finding.

## Exploitability assessment (honest)

Both new sinks are wild-pointer dereferences through
partially-attacker-shaped kernel heap objects (the nfsdmap carries
server-chosen 64-bit cookies in `ndm_cookies`; `s_name`/`s_namelen`
overlay them; `s_dvp` is a fixed kernel address inside the nfsnode —
KASLR-irrelevant). On this INVARIANTS guest they manifest as
deterministic panics; on production builds they are memory-corruption
primitives (fake-vnode `v_ops`/`v_data` derefs through nfsnode bytes
the server influences via file-handle and cookie values, wrong-zone
frees, ucred refcount corruption via `crfree`). Not pushed to a uid=0
chain within this run: the demonstrated primitives corrupt control
data adjacent to nfsnode/nfsdmap allocations; weaponizing would need
slab grooming of the M_NFSNODE neighborhood and a leak to place the
fake ops table — out of scope for a verify pass, and the finding's
severity (High, memcorrupt bucket) is already supported by the
deterministic panics plus the type-confusion primitives.

## Fix validation (single-fix kernel, this run)

`fix.diff`: assign `v_type` only at creation (`vp->v_type == VNON`);
if a live vnode's type differs from the server's claim, invalidate the
attr cache (`np->n_attrstamp = 0`) and fail the load with `EBADRPC` so
callers treat the reply as bogus. Applied to a clean-source snapshot,
`make nativekernel KERNCONF=X86_64_GENERIC` + `installkernel`, reboot
(`DragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 12:33:28 UTC 2026`):

- TEST 1 / TEST 1b (root + unprivileged): every lie now yields
  `fstat/stat FAILED: RPC struct is bad` (EBADRPC) — no retype, no
  injection, no crash; `BEFORE` attrs still load (creation-time
  VNON assignment intact).
- TEST 2: `AFTER-REG-LIE: stat FAILED: RPC struct is bad`; node stays
  VDIR; `umount` clean (`UMOUNT_RC=0`), guest stays up — the bgetvp
  panic is gone.
- Honest lifecycle on the fixed kernel: mount, write, read-back
  (`HONEST-DATA-123`), stat (REGULAR), mkdir, full 500-entry
  directory listing, rm, umount — all clean (`fix_run.log` tail).

Baseline reproduced / patched not reproduced → **fix_status: fixed**.

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