# DF-5788 VERDICT — kern_getcwd unlocked fd_ncdir + unreferenced mount-topology walk

## Bottom line

**not_reproduced (black-box).** The reference-discipline defect in
`kern_getcwd()` is *certain* at the code level — the function is the only
member of the fullpath family that reads `fdp->fd_ncdir` with no lock and
walks `nch.mount` (`mnt_ncmountpt` / `mnt_ncmounton`) with no mount
reference — but ~900M `getcwd()` walks against ~25,000 forced unmounts on
an instrumented detection kernel produced **zero** dereferences of freed
mount memory, and the structural analysis below explains why that is the
*expected* outcome: the kernel's fd-pin/`mnt_refs` protocol collapses the
reachable misuse window to nanosecond-scale adjacent loads. Downgraded to
**Low (hardening / latent UAF)**. A corrected `fix.diff` was built and
stress-validated.

## The defect (code-level, certain)

- `sys/kern/vfs_cache.c:5076-5079` — `nch = fdp->fd_ncdir; ncp = nch.ncp;
  _cache_hold(ncp);` with no `fd_spin`, no `p_token`. `kern_chdir` swaps
  `fd_ncdir` under `fdp->fd_spin` (`sys/kern/vfs_syscalls.c:1945-1950`)
  and drops the old handle at `:1952`.
- `sys/kern/vfs_cache.c:5094` — `ncp == nch.mount->mnt_ncmountpt.ncp`
  dereferenced every loop iteration; `:5095` — `nch = nch.mount->mnt_ncmounton`
  follows two pointers read out of the mount. No `_cache_mntref` anywhere in
  the function; the mount is freed by `dounmount` → `mount_drop` →
  `kfree(mp, M_MOUNT)` (`sys/kern/vfs_syscalls.c:1107-1117`,
  `sys/kern/vfs_mount.c:399-403`) once `mnt_refs` reaches 0.
- Contrast: `sysctl_kern_proc_cwd` snapshots with `cache_copy()` under
  `p_token` (`sys/kern/kern_proc.c:2056-2059`); `cache_fullpath()` requires
  a referenced `nchp` and its callers (`kern_proc.c`, `procfs_vnops.c`,
  `kern_jail.c`, `kern_descrip.c`, `kern_linker.c`, `vfs_mount.c`) all
  `cache_copy` first. `kern_getcwd` is the lone outlier.

## Why it does not manifest black-box (the structural analysis)

The obvious race — "walk is mid-mount when `umount -f` frees it" — is
closed by three interacting mechanisms, verified on the guest by the
detection rig (0 hits in ~900M walks / ~25K frees):

1. **The fd pin blocks the free.** Walks through mount M only start while
   some fd's `nchandle` pins M (`fd_ncdir.mount` ref counts in `mnt_refs`).
   `dounmount` waits at `sys/kern/vfs_syscalls.c:1110`
   (`while (mp->mnt_refs > 0)`), so M cannot be freed while walks *can
   begin* in it. When the flapping thread drops the pin, no new walks enter
   M — and in-flight walks die within microseconds.
2. **`cache_inval(CINV_DESTROY)` walls off the crossing.** At umount start
   (`vfs_syscalls.c:897-900`) the mount root is marked `NCF_DESTROYED`;
   `kern_getcwd` aborts at `vfs_cache.c:5084` before the `mnt_ncmountpt`
   compare/transition. Children are only *unresolved*, so walks continue
   mid-chain (and do keep re-reading `nch.mount->mnt_ncmountpt.ncp` at
   `:5094`), but they always reach the destroyed root and abort ~ms before
   the free at the end of `dounmount` (VFS_SYNC + VFS_UNMOUNT + drops).
3. **Manifestation (A) (TOCTOU on the initial copy) is protocol-closed
   too.** For chdir's `cache_drop` to *free* the ncp, the ncp must be at
   refs==1 (fd only) — only reachable for unlinked ncps (mount roots via
   `cache_allocroot`, `vfs_syscalls.c:437`) *after* their mount was
   unmounted — but the unmount cannot complete while that same fd pins the
   mount. In every legal ordering the fd swap precedes (and enables) the
   free by ≥ the 100 ms `tsleep` granularity of dounmount's wait loop,
   while the copy→hold window is a few nanoseconds.

What remains is the nanosecond-scale adjacent-load window (compare `:5094`
→ follow `:5095` across the free instant) and freed-slab-reuse grooming
(the freed `M_MOUNT` object would have to be reallocated with a crafted
`mnt_ncmountpt.ncp` matching the held ncp). Both are far beyond black-box
reach; no leak, panic, or corruption was observed in any run
(`run_*.log`).

## Verification runs (all on the QEMU/KVM guest, 6 vCPU)

| kernel | stress | result |
|---|---|---|
| baseline `#0` (uninstrumented) | v1 rigger 30s | clean, 4 ENOENT races, guest up |
| rig `#1` (poison+panic checks in `mount_drop`/`kern_getcwd`) | v3 rigger 60s | 73M walks, 2612 frees, **0 DF5788 triggers**, guest up |
| rig `#3` (rebuilt control) | v3 rigger 300s | 437M walks, 9596 frees, **0 triggers**, guest up |
| fix-v1 `#2` (token-based fix) | v3 rigger 300s ×2 | **guest WEDGED both times** (ssh+console dead, no panic text; `fixkernel_stress{,2}_serial.log`) — fix rejected: taking `lwkt_gettoken_shared(&mp->mnt_token)` at every mount crossing livelocks under ~1.4MHz crossings vs. dounmount's seconds-long exclusive hold |
| fix-v2 `#4` (final `fix.diff`) | v3 rigger 300s | 335M walks, 12491 frees, clean exit, guest healthy, getcwd functional across tmpfs mounts |

Detection rig (guest-only, `instrumentation.diff`): `mount_drop()` poisons
`mnt_ncmountpt.ncp = 0xdf5788d0` / `mnt_ncmounton.ncp = 0xdf5788d1` before
`kfree`; `kern_getcwd()` panics if the walk ever reads either value. Poison
values cannot false-positive: new mounts are `M_ZERO` and `dounmount`
`cache_zero()`s both nchandles before the drop.

## Fix

`fix.diff` (repo tree, never applied there): snapshot `fd_ncdir` under
`fd_spin` + `_cache_mntref` for the walk; at mount crossings ref the new
mount before releasing the old, with NULL guards for the torn mid-unmount
read of `mnt_ncmounton` (a state the original code only "survived" by
reading freed memory — the guards also close a latent NULL-deref).
Contention-free (no tokens — deliberately, see fix-v1's wedge). Validated:
builds, boots, functionally correct, survives the full stress.

## Notable incidental observation

The `umount -f` churn storm itself logs heavily
(`"forced umount of (null) - 0 namecache refs, 2 mount refs still
present"`, `"nlookup: warning umount race avoided"`, `EXDEV case 2`) on
both instrumented and control kernels without ill effect — normal
forced-unmount noise under this workload, not a finding.

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