# DF-2717 — VERDICT

**Finding:** `nlookup_init()`/retry path read the shared `fd_ncdir`/`fd_nrdir`
nchandle pairs without `fd_spin` while sibling threads swap them — torn
(ncp,mount) pairs and hold-after-drop race. sys/kern/vfs_nlookup.c:149-161.
Filed Medium, "static protocol violation; not live-verified".

**Verdict:** The locking-protocol violation is **confirmed by inspection,
line-accurate** — every reader and writer site was mapped and the two
non-atomic loads plus the delayed `_cache_hold` in `cache_copy` were
verified in source. The two race *primitives* claimed by the finding
(torn two-word pair; `_cache_hold` after last-ref `kfree_obj`) **did not
land live** within a quantified dedicated budget on the 6-vCPU
INVARIANTS guest. However, the prescribed storm (chdir/fchdir swap +
relative ".." lookups + mkdir/rmdir churn) **panicked the guest 3/3
times** in ≤90 s each — crash-dump analysis attributes the panic to a
**distinct adjacent bug** in the same `".."` machinery (unguarded NULL
`nc_parent` at vfs_nlookup.c:756 dereferenced at :767), for which the
DF-2717 unlocked reads are the delivery vehicle but not a necessary
condition. Honest bottom line for DF-2717 itself: protocol violation
real and certain at the source level; exploit window measured narrow
(10^8-scale opportunities without a hit); no privilege escalation
demonstrated. Recommend: keep as Medium hardening with the budget
documented; file the NULL-parent panic separately (unpriv reliable
local DoS — see below).

## 1. Protocol analysis (all path:line verified)

* Readers with NO lock: `nlookup_init` sys/kern/vfs_nlookup.c:149-166
  (both `/` and relative branches read `fd_nrdir`/`fd_ncdir`/`fd_njdir`
  via `cache_copy`); `nlookup_init_at` :225-228,242; `nlookup()`
  generation-retry :1376-1378 re-dereferences `nl_basench` (a pointer
  into `fdp->fd_ncdir`/`fd_nrdir`) after an arbitrarily long walk.
* Writers: `kern_chdir` vfs_syscalls.c:1906-1911 and `kern_fchdir`
  :1900-1915 hold `fd_spin` around the two-word store and drop the old
  nchandle after unlock; mount-reload callback :572-593 holds
  `fd_spin`; `fdcopy`/`fdshare` kern_descrip.c:2411-2431/2528-2543 hold
  `fd_spin`. **`kern_chroot` vfs_syscalls.c:2059-2066 updates
  `fd_nrdir`/`fd_njdir` under `p_token` only — no `fd_spin`, so even
  writer-vs-writer protocol is asymmetric.**
* `cache_copy` sys/kern/vfs_cache.c:996-1026: `ncp = nch->ncp` (:1006)
  and `mp = nch->mount` (:1007) are two adjacent non-atomic loads; the
  reference is taken only later (`_cache_hold(ncp)` :1023, guarded by
  `KKASSERT(ncp->nc_refs > 0)` at :735) or stolen from the per-CPU
  mntcache. `_cache_drop` at 1→0 sets `nc_refs = -1` (:763) and calls
  `kfree_obj` immediately (:766).

Consequences as filed are therefore source-certain: (a) a chdir/fchdir
store can interleave between :1006 and :1007 producing a mismatched
(ncp,mount) handle that flows into the mount-boundary and jail `..`
gates (vfs_nlookup.c:716-718, 745-746); (b) the gap between the load at
:1006 and the hold at :1023 admits hold-after-free.

## 2. Live race budget (unprivileged user, stock INVARIANTS kernel)

| Run | Mode | Time | Volume | Result |
|---|---|---|---|---|
| S1 swap-only | 0x9 | 150 s | 156,107,562 cross-mount fchdir swaps (1.04 M/s) vs 161,845,152 reader nlookups | **clean** |
| S2 dance-only | 0xc | 120 s | 399,820 last-ref-drop dance cycles vs 122,075,241 reader opens | **clean** |
| S3 no-fchdir | 0xe | 60 s | 8,186,689 relchdirs + 365,732 dances + 57,102,855 opens | **clean** |
| S3 full | 0xf | ≤90 s | — | **panic 3/3** |

The DF-2717 windows are real but narrow: 1.6×10^8 swap-vs-copy
opportunities (torn pair) and 1.2×10^8 copy-vs-drop opportunities
(hold-after-drop) produced zero hits. On this x86-64 guest the
two-adjacent-loads/store interleaving effectively requires same-cycle
co-residency that the scheduler rarely yields; likewise the freed chunk
is typically re-used before the delayed hold. No KKASSERT in
`_cache_hold`, no refcount corruption, no escalation. DF-2717 alone:
**not reproduced** (impact of its own primitive: none within budget).

## 3. The panic that DID reproduce — adjacent bug, root-caused

Signature (3/3, identical): `panic: page fault`,
`nlookup() at nlookup+0x66c` (sys/kern/vfs_nlookup.c:767) from
`kern_chdir()`, fault VA 0x84, `%rax = 0`.

kgdb on /var/crash/vmcore.0 (logs in pack):
`nch = {ncp = 0x0}`, `nctmp = {ncp = 0x0}`,
`nd->nl_nch.ncp` = node with `nc_parent = 0`, `nc_head = 0`,
`nc_name = 0`, `nc_flag = 0x1210`, `nc_refs = 30`, path
`"../../../tmp/df2717/a"`, element `".."`, `last_element = 0`;
`nl_basench`'s value sat on the hammer2 mount while the walk base sat
on tmpfs (walk re-based mid-syscall through the init copy / retry
re-deref).

Mechanism (each step source-verified):

1. `rmdir(v)` while `v` is the process cwd defers the zap
   (`NCF_DEFEREDZAP`) because `nc_refs != refcmp` — the fd-table ref is
   held. Single-threaded `rmdir(cwd); ls ..` works fine (verified on
   the guest) because the topology stays connected.
2. Under full-storm churn the deferred zap completes while `fd_ncdir`
   still holds the last reference: the node is unlinked
   (`nc_parent = NULL`, unhashed, unnamed) yet stays alive, referenced
   almost solely through `fd_ncdir`.
3. Reader/walker threads adopt the disconnected node as walk base via
   the DF-2717 sites (`cache_copy(&fdp->fd_ncdir)` at
   vfs_nlookup.c:159-160, retry re-deref at :1376-1378) — 30
   references had accumulated by panic time.
4. The `".."` handler runs `nctmp.ncp = nctmp.ncp->nc_parent`
   (vfs_nlookup.c:756) with **no NULL check**, the NULL flows through
   `cache_copy` (NULL-safe) into `nch`, and `nch.ncp->nc_generation`
   at **:767** faults (NULL+0x84). The intended NULL-parent handling
   (`(par.ncp = nch.ncp->nc_parent) != NULL` at :831) exists but runs
   after the unguarded deref.

Attribution to DF-2717: step 3 uses the finding's unlocked read sites
as the delivery vehicle, but a correctly locked reader would copy the
same disconnected handle (the value in `fd_ncdir` is legitimate between
the deferred-zap completion and the chdir-away). The panic therefore
does not depend on the missing lock; its root causes are the
deferedzap-vs-cwd disconnect and the missing NULL guard. It should be
filed as its own finding: unprivileged, reliable local DoS (panic)
with a trivial trigger program (this pack's `df2717_storm 90 3 0xf`).

## 4. Fix and A/B validation

`fix.diff` has two parts:

* **Part A (DF-2717):** `fd_spin` around the fd_nchandle reads in
  `nlookup_init` (both branches incl. `fd_njdir`), guarded `fd_spin`
  in `nlookup_init_at` and in the `nlookup()` retry when `nl_basench`
  points into the caller's `fdp`; `kern_chroot` now takes `fd_spin`
  around its `fd_nrdir`/`fd_njdir` updates with `vrele`/`cache_drop`
  deferred outside the spinlock (matches `kern_chdir`'s pattern).
  `cache_copy` is spinlock-safe (atomics only; the source comment at
  vfs_cache.c:725-732 explicitly blesses spinlock-holding callers).
* **Part B (the reproduced panic):** after `nctmp.ncp =
  nctmp.ncp->nc_parent`, recover with `nctmp = nd->nl_rootnch` — the
  same recovery the function already uses for the jail-break NULL case
  — instead of dereferencing NULL. (Semantics note: `".."` from a
  topology-disconnected cwd then resolves relative to the process
  root; jailed processes get their jail root, so no escape is
  introduced.)

Validation performed on the guest (patched kernel `#1: Tue Oct 20
08:31:33 UTC 2026` built from the guest's /usr/src with exactly the
diff below — verified byte-identical to the validated sources;
full kernel build log: buildkernel-fixed.log, RC=0):

* Full-mix storm re-run **twice**, 150 s each: **no panic, guest up**
  (run-fixed-storm.log; run 1: 114,908,038 swaps + 3,144,309 dance
  cycles + 138,997,980 opens; run 2: 162,930,640 swaps + 2,326,609
  dance cycles + 131,940,060 opens). Baseline: 3/3 panics in ≤90 s.
  No "..' traverse disconnected" guard messages fired in these two
  runs (the underlying disconnect chain is probabilistic; the guard
  is defense-in-depth for the crash case).
* Microbench (2,000,000 relative-path `open()` best-of-3): stock
  458,786 ops/s (2.18 µs/op) vs patched 450,273 ops/s (2.22 µs/op) —
  ≈1.9 % delta, within the guest's run-to-run noise band (stock
  itself varied 454,552–458,786 across its two runs).

## 5. Notes

* Jail variant not run: `fd_njdir` has no concurrent writer once a jail
  exists (only `kern_chroot` before jail setup writes it), so the
  torn-read surface for the jail nch copy is nil; the `".."` gate
  itself (vfs_nlookup.c:745-749) was exercised by every storm run via
  the tmpfs/hammer2 mount crossings.
* The `debug.nlookup_debug` sysctl bits are kprintf verbosity only
  (vfs_nlookup.c:969,1043,1153,1177,1256,1314,1368) — they cannot
  force the retry path; retries occurred naturally from the dance's
  generation churn.
* Guest was returned to a clean state (`vm.sh reset with-src`) after
  validation.

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