# DF-3208 — VERDICT

**status: not_reproduced** (dynamic) / static chain certain end-to-end by
inspection and live partial demonstration.

## The static chain (every link cited)

1. **Publish before validate.** `cd9660_vget_internal()`:
   - vfsops.c:745 `ihashget` miss → :749 `getnewvnode` (returns VX-locked,
     ref=1, `v_type=VNON`, `v_data=NULL` — kern/vfs_mount.c:194-221) →
     :754 `kmalloc(M_ZERO)` → **:763 `cd9660_ihashins()` — node now
     globally visible** → :769 `vp->v_data = ip`.
   - :771-823 (isodir==NULL: NFS fhtovp via VFS_VGET) validations, and
     :829-842 (relocated: `cd9660_devblkatoff(vp,0)`) can all fail **after**
   the publish; each error path does `vx_put(vp)` (vfs_lock.c:761-768)
   without touching the hash.
2. **Hash removal only at reclaim.** `cd9660_ihashrem()` is called only
   from `cd9660_reclaim()` (cd9660_node.c:228), which requires the vnode
   refcount to hit 0 (vnode_terminate → VOP_INACTIVE → vrecycle → vclean →
   VOP_RECLAIM, kern/vfs_subr.c:1284-1421).
3. **The waiter's ref blocks the reclaim.** A concurrent
   `cd9660_ihashget(dev, ino)` (cd9660_node.c:107-136) finds the node
   under the ihash token (:114-116), and `vget()` **references before
   locking** (kern/vfs_lock.c:589) then blocks on the creator's VX
   lockmgr. While blocked it holds a ref → creator's `vx_put` drops
   refcount 2→1 → **no terminate, no reclaim, node stays in the hash**.
4. **The waiter receives the orphan as success.** Creator's unlock wakes
   the waiter; `vn_lock` sees VRECLAIMED clear, `vget` returns 0; the
   post-block re-walk (:124-130) still finds the node (nobody removed it)
   → `ihashget` returns the half-initialized vnode → `vget_internal`
   returns 0 at :745-746 with `*vpp` = VNON / `i_mnt==NULL` /
   `i_devvp==NULL` / all-zero inode fields.
5. **Detonation.** Lookup hands it to namei as the resolved component
   (cd9660_lookup.c:360/375, vop_compat_nresolve kern/vfs_default.c:231-235);
   the first `stat()` → `cd9660_getattr()` reads `ip->i_mnt->im_fmask`
   (cd9660_vnops.c:158) → NULL-deref page fault → panic. Any other VOP
   (read, lookup-as-dir) is equally fatal.

## Live partial demonstration (what was proven on the guest)

Stock INVARIANTS kernel #0, 6 vCPU, QEMU/KVM; crafted `df3208.iso`
(sectors: PVD@16 with volume_space_size=0x200000, root@32, X@33, Y@34;
X and Y each contain "PWN" — a directory record (flags=2) carrying
RRIP PX + **CL lba=0x1000**, beyond the 40-sector device):

- `CL → in-device` (earlier 2GB-sparse image): `stat /mnt/x/X/PWN`
  **succeeds** with ino = 0x800000 (= 0x1000<<11) — proving the
  `rrip_pclink → dp->i_ino → vget_internal(relocated=1) → devblkatoff`
  path executes (it read the CL target and built the node from it).
- `CL → beyond-device` (80KB device): `stat /mnt/x/X/PWN` fails with
  **EINVAL from vfsops.c:837-838** (devblkatoff bread of 0x800000 →
  dscheck) — i.e. the creator publishes the node and then fails on the
  intended path, ~45,000 times/second under the two-directory hammer.

## Why it did not reproduce (the honest core)

cd9660 is **not MPSAFE** (`VFS_SET(cd9660_vfsops, cd9660, VFCF_READONLY)`,
cd9660_vfsops.c:97 — no VFCF_MPSAFE). Every entry into `vget_internal`
(lookup via `vop_old_lookup`, NFS VFS_VGET, VFS_ROOT) is wrapped by
`VFS_MPLOCK()` (kern/vfs_vopops.c:185-187) = `lwkt_gettoken(&mp_token)`
(sys/sys/mplock2.h). Two facts close the window:

- **Tokens are only released when the holder blocks** (`lwkt_switch` →
  `lwkt_relalltokens`, kern/lwkt_thread.c:lwkt_switch). Interrupt
  preemption does *not* release them (`lwkt_preempt()` refuses to preempt
  into/out of token-holding threads — kern/lwkt_thread.c).
- **None of the reachable failure windows contains a voluntary block.**
  The failing bread at vfsops.c:837/:789 fails *synchronously* in the vn
  driver's dscheck (out-of-range I/O is rejected before any strategy I/O —
  sys/dev/disk/vn/vn.c:292-296); an in-range read never fails (so the
  relocated/validation paths either sleep-and-succeed or
  fail-without-sleeping). The only theoretical in-window sleep is
  `getblk`'s buffer-allocation wait under free-list starvation.

Attempted window-openers, all negative:

| config | attempts | result |
|---|---|---|
| 4 threads, idle guest | 200k | no hit |
| 6 threads × 10M + ping flood + 3 churn readers | ~60M | no hit |
| 2 threads timed (rate measurement) | 400k (45k/s) | no hit |
| `kern.nbuf=1024` (boot-tuned), 4 threads | 1.2M | no hit |
| `kern.nbuf=1024` + 8×1MB dd churn readers, 6 threads | ~6M | no hit |
| `kern.nbuf=256` + 40×1MB dd churn readers, 6 threads × 2M | 12M | no hit |

Total ≈ 80M races. The buffer-churn configurations were meant to force
`geteblk` waits inside the window; either the free list never empties at
the failing getblk (virtio I/O is too fast; error buffers may also short-
circuit subsequent breads) or the resulting block is too short for the
handshake. Guest never panicked; every attempt returned EINVAL cleanly
(and the transient node was reclaimed synchronously each time — no leak:
`vmstat`-stable vnode counts across runs).

**Conclusion:** the defect is structural and provable by inspection; on a
stock system the non-MPSAFE mp_token serializes all creators so the
half-initialized node can never be observed by a second thread. The window
opens whenever the in-window code blocks: genuinely failing-but-slow
device I/O (real media errors, network-backed stores), an MPSAFE
conversion of cd9660 (VFCF_MPSAFE), or any future sleep added between
ihashins and the error paths. DF-3194 (NFS twin, not_reproduced for the
same class of reason) is the precedent.

## PoC notes (what had to be fixed vs the obvious sketch)

- `-S 2048` in the DF-3146-era vnconfig recipe means **2048 MB device
  size**, which silently *grew the backing file to 2GB sparse* and made
  every "beyond-device" offset in-device (reads succeed, zero-filled).
  Attached without `-S` (device = file size = 80KB).
- RRIP lookup derives a non-directory entry's ino from
  `bp->b_bio1.bio_offset + entryoffsetinblock` (cd9660_lookup.c:255-259),
  not `isodirino` — so the trigger entry must be a **directory (flags=2)
  with a CL entry** to steer `dp->i_ino` at an attacker-chosen LBA.
- Directory records need even-padded length ≥ 34+namelen (readdir rejects
  otherwise at cd9660_vnops.c:524) and RRIP-mount wants PX on entries to
  avoid "RRIP without PX" noise.
- Two different parent directories (X, Y) are required: same-directory
  lookups serialize on the parent vnode lock in `vop_compat_nresolve`
  (kern/vfs_default.c:200) before the mp_token is even taken.

## Fix

`fix.diff`: `cd9660_ihashpurge(vp, ip)` on all five post-publish error
paths (unlink from hash, vrele i_devvp if taken, v_data=NULL, kfree);
`vput(vp)` added to `cd9660_ihashget()`'s ripped-out branch, which becomes
reachable once purges exist (a successful vget can observe the node gone
and must drop the lock+ref before retrying). Not applied to sys/; not
kernel-built (baseline never reproduced, so fix validation is vacuous —
fix_status: not_testable).

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