# DF-3157 VERDICT — reproduced (kernel panic + OOB read + arbitrary-offset R/W via forged fh)

## Bottom line

`msdosfs_fhtovp` → `deget` accept fully attacker-chosen
`(dirclust, dirofs)` from an NFS file handle with no validation, and
`readep`/`bptoep`/`DE_INTERNALIZE`/`DE_EXTERNALIZE` trust them for kernel
pointer arithmetic. Verified on the stock INVARIANTS guest (kernel #0,
2026-07-02): deterministic fatal kernel page fault in `deget` from an
unprivileged forged NFS GETATTR (3/3 runs), a silent 31-byte heap OOB read
exfiltrated through `stat()`, an arbitrary in-volume 32-byte read
(planted-marker proof), a persisted arbitrary-offset 32-byte write with
attacker-chosen bytes, and an nfsd wedge from a forged SETATTR. The
two-part fix (fh bounds validation in `msdosfs_fhtovp` + `de_refcnt`
ordering in `deget`, the latter for DF-3158) was built as kernel #3 and
re-validated: every OOB/out-of-range probe returns EINVAL, the panic is
gone, nfsd stays healthy, and legitimate in-range handles keep working.

## How it was reproduced (baseline, stock kernel #0)

1. Deterministic FAT16 image (mkfat.py): 512 B/cluster, FAT16, root dir at
   sector 257 (16 sectors), data from sector 273; SUB = cluster 2,
   VICTIM.DAT = cluster 3 whose first 32 bytes double as a fake dirent with
   size field "ABCD" at +28. Guest: `vnconfig vn0`, `mount -t msdos`,
   `/etc/exports` → `/fat -network 127.0.0.0/mask 255.255.255.0
   -maproot=0`, rpcbind + mountd + nfsd.
2. fh layout learned from mountd GETFH (privileged src port): 28 bytes =
   fsid(8) + `struct defid{len=12,pad,dirclust,dirofs}` at offset 8; DF
   nfsd requires exactly `NFSX_V3FH=28` on the wire (`nfsm_srvmtofh`,
   sys/vfs/nfs/nfsm_subs.c:934-965).
3. **Marker read** (`getattr 3 0`, local `fhstat(3,0)`): `st_size =
   1145258561 = 0x44434241 = "ABCD"` — stat() returning file-content bytes
   at a forged location (arbitrary in-volume 32-byte read; also verified
   with cluster 100 free-space filler and the "."-fixup kernel print
   `deget(): "." entry at clust 100 != 63222` proving the internalization).
4. **OOB read** (`fhstat(3,511)`): succeeds silently; `st_mtime =
   315360000 (0x12CE0FC0)`, `st_size` garbage — 31 bytes past the 512-byte
   buffer internalized into the denode and returned by stat.
5. **Kernel panic** (`df3157 getattr 0 32` as unprivileged user, via
   localhost nfsd): Fatal trap 12 in `deget+0x2b5` — disassembly of the
   guest's own kernel.debug shows the faulting instruction is the inlined
   `DE_INTERNALIZE` (`movq (%rax),%rcx` = deName copy) with %rax = the
   `bptoep`-computed direntry pointer on an unmapped page. `current
   process` = the nfsd server thread. Reproduced 3/3 (captures: crash 1 &
   2 in run logs, crash 3 in panic_capture.txt). One session also shows
   `dscheck(vn0): b_bcount 1 is not on a sector boundary` — a malformed
   bread sizing artifact of the same unvalidated input.
6. **Arbitrary-offset write** (`fhtest 3 0 write` = fhopen forged fd +
   futimes+fchmod → msdosfs_setattr → deupdat → `*dirp = dir`): after
   `sync`, raw block 274 (VICTIM.DAT data) shows byte 11 `4b→6b`
   (attributes) and bytes 22-25 = the exact FAT encoding of the chosen
   mtime; readback through a fresh forged fhstat returns `mtime=1599999998`
   (the set second). 9+ attacker-chosen bytes at any device offset.
7. **nfsd wedge** (`df3157 setattr 0 511`): the nfsd server thread stops
   answering (subsequent NULL/GETATTR time out; guest otherwise alive).

Unprivileged position: the RPC client runs as uid 1001 with a spoofable
AUTH_SYS credential; only the fsid seed was obtained root-assisted for the
local harness (a network client gets it from mountd GETFH — that is the
actual remote attack path; noted in README).

## Fix validation (kernel #3, 2026-09-07 09:32:17, guest-built)

`fix.diff` adds to `msdosfs_fhtovp`: fid length check, cluster-range check
(`CLUST_FIRST..pm_maxcluster`), root-area bounds for FAT12/16, and a
uniform "entry must not cross its buffer" bound
(`(dirofs & crbomask) ≤ bpcluster-32`) — plus the DF-3158 `de_refcnt`
ordering change in `deget`. Post-fix matrix (same probes):

| probe | baseline (kernel #0) | patched (kernel #3) |
|---|---|---|
| fhstat(3,0) marker | size=0x44434241 leak | still works — in-range by design (residual, see below) |
| fhstat(3,511)/(3,481) OOB read | garbage exfil | EINVAL |
| fhstat(0,511)/(0,481) OOB read | garbage exfil | EINVAL |
| fhstat(0,8192) beyond root | (offset probe) | EINVAL |
| fhstat(4294967295,0) cluster range | (offset probe) | EINVAL |
| NFS GETATTR(0,32) | fatal panic 3/3 | clean ESTALE, guest up |
| NFS SETATTR(0,511) | nfsd wedge | no wedge; nfsd still serving (NULL OK) |
| fhopen+setattr write | 32-byte persisted at forged offset | in-range only; OOB write EINVAL |

Residual (documented, matches the validation level upstream BSDs use):
a forged handle naming a *valid* cluster and a fitting offset still lets
stat() disclose the first 32 bytes of any in-volume cluster — FAT clusters
carry no type tags, so a handle cannot be told "this cluster is file data";
the fix removes every out-of-bounds and out-of-range case, which is where
the memory-safety violation lived.

## Not fully root-caused (honesty notes)

* The nfsd-context ESTALE for cluster-mode forged keys (while the identical
  local fhstat path succeeds) was observed pre- and post-fix and not
  root-caused; it does not affect the demonstrated primitives (the local
  path proves them end-to-end; the NFS path demonstrably reaches the same
  deget code — it panicked there three times).
* The precise micro-mechanism of the (0,32) panic (which buffer placement
  makes `bptoep+32` land on an unmapped page) is pinned to the
  DE_INTERNALIZE read via disassembly but not traced to the allocation
  site; the `dscheck b_bcount 1` console line from the same input family
  corroborates malformed-size buffers on this path.

uid=0 escalation: not pursued — the guest's buffer-cache heap layout makes
reliable grooming from these 31-byte windows a research project; the
panic + arbitrary-offset R/W + leak are sufficient to establish the
memory-safety violation.

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