# DF-3208 — cd9660 ihash publish-before-validate: race loser receives an
# orphaned half-initialized VNON vnode (i_mnt == NULL) as SUCCESS → NULL-deref
# panic in any subsequent VOP

## What (root cause)

`cd9660_vget_internal()` (sys/vfs/isofs/cd9660/cd9660_vfsops.c:732-909)
publishes the freshly allocated `iso_node` into the global inode hash
(`cd9660_ihashins()`, cd9660_node.c:143-160) at vfsops.c:763 — **before**
the on-media record is read and validated, and before `i_mnt`/`i_devvp`
are set (vfsops.c:825-827). Every post-publish error path

- :775-779  lbn >= volume_space_size → ESTALE
- :781-787  off+34 > logical_block_size → ESTALE
- :789-797  bread() error → error
- :800-809  record crosses block boundary → ESTALE
- :837-840  relocated-path `cd9660_devblkatoff()` error → error

does `vx_put(vp)` **without removing the node from the hash**. Hash removal
only happens in `cd9660_reclaim()` (cd9660_node.c:228), which runs at
refcount 0 — but a concurrent `cd9660_ihashget()` for the same (dev, ino)
already took a reference inside `vget()` (vfs_lock.c:589, ref before lock)
and is blocked on the VX lock the creator holds. When the creator fails and
releases, the waiter wakes, re-walks the hash (cd9660_node.c:124-130),
**finds the orphaned node still there, and returns it as a SUCCESS**
(cd9660_node.c:131-132). `cd9660_vget_internal()` then returns 0 with
`*vpp` = a half-initialized vnode: `v_type == VNON`, `i_mnt == NULL`,
`i_devvp == NULL`, `iso_extent/i_size/inode.* == 0` (kmalloc M_ZERO,
init at :825+ never ran).

The first VOP on that vnode dereferences NULL: `cd9660_getattr()` reads
`ip->i_mnt->im_fmask` at cd9660_vnops.c:158 → kernel page fault → panic;
`read()` likewise via `blksize(imp=ip->i_mnt,...)` in cd9660_read /
cd9660_devblkatoff.

This is the cd9660 twin of the DF-3194 (NFS) "race-loser gets an
unconnected VNON" class — but the failure mode is different and worse in
shape: in DF-3194 the loser's own error path panicked on `v_data == NULL`;
here `cd9660_inactive()`/`cd9660_reclaim()` explicitly tolerate `ip == NULL`
(cd9660_node.c:199/205/227) and defuse that twin, while the *handoff* of
the orphan returns SUCCESS and pushes the detonation into any later VOP.

## Threat model

Crafted ISO9660/RRIP image, mounted (root setup or `vfs.usermount=1`
self-mount — same precondition family as DF-0878/DF-3146/DF-3156).
After the mount, any unprivileged user with traverse permission races two
lookups that resolve to the same crafted ino through two different parent
directories (defeats the per-parent vnode lock taken by
`vop_compat_nresolve`, kern/vfs_default.c:200) — e.g. directory entries
"X/PWN" and "Y/PWN" both carrying an RRIP **CL** entry pointing beyond the
device: `rrip_pclink` (cd9660_rrip.c:310-315) sets `dp->i_ino = lba << 11`,
`dp->i_ino != ino` makes lookup pass `relocated=1` (cd9660_lookup.c:366-367)
and `cd9660_devblkatoff(vp,0)` at vfsops.c:837 fails (dscheck EINVAL/EIO on
the beyond-device offset) → the :838 error path → poison window.

Panic (local DoS) when it fires; no memory corruption (all derefs are NULL
reads at small offsets).

## Dynamic verdict (see VERDICT.md)

**not_reproduced** — the static chain is proven link-by-link, and the
failing path demonstrably executes on every attempt (~45k/s live:
`stat /mnt/x/X/PWN` → EINVAL from vfsops.c:837-838 while the same CL
pointing in-device makes stat *succeed*, proving the vget ran the
relocated branch), but the race window cannot be opened on a stock
system: cd9660 is not MPSAFE (`VFS_SET(..., VFCF_READONLY)` — no
VFCF_MPSAFE), so every entry into `vget_internal` is serialized by the
global `mp_token` (an LWKT token, sys/sys/mplock2.h). The token is only
dropped when the holder blocks; `lwkt_preempt()` never releases a running
thread's tokens (kern/lwkt_thread.c — preempt into/out of token holders is
refused), and **none of the reachable failure windows contains a voluntary
block** — the beyond-device bread fails synchronously via dscheck. ~80M
attempted races across 6 configurations (up to 6 CPUs/6 threads, network
interrupt pressure, 8-40 concurrent buffer-churn readers, and boot-tuned
`kern.nbuf` of 1024 and 256 buffers to force `geteblk` allocator waits)
produced no hit.

The window would open (and the chain become near-deterministic) whenever
the in-window code blocks: genuinely failing-but-slow device I/O (real
media errors, NFS-backed stores), an MPSAFE conversion of cd9660, or any
future sleep added between ihashins and the error paths.

## Reproduce (baseline attempt; guest must be up)

Host:

    python3 craft_df3208.py df3208.iso
    scp -F dfbsd-qemu/config df3208.iso trigger.c dfbsd:/root/
    scp -F dfbsd-qemu/config craft_df3208.py dfbsd:/root/

Guest (root setup; trigger as nobody):

    cc -O2 -pthread -o /root/trigger /root/trigger.c
    chmod 755 /root/trigger
    vnconfig -c /dev/vn0 /root/df3208.iso     # NOTE: no -S (see VERDICT.md)
    mkdir -p /mnt/df3208 && mount_cd9660 /dev/vn0 /mnt/df3208
    su -m nobody -c "/root/trigger /mnt/df3208 5000000 6"

Expected on a vulnerable+open window: kernel panic (Fatal trap 12, page
fault in cd9660_getattr — NULL i_mnt). Observed on stock guest #0: EINVAL
per attempt, no panic (window closed — see VERDICT.md).

## Fix

`fix.diff` (authored post-verification, never applied to sys/): a new
`cd9660_ihashpurge(vp, ip)` helper (unlink from hash, drop the devvp ref if
taken, disconnect v_data, free the node) called on all five post-publish
error paths, plus the now-mandatory `vput(vp)` before the `goto loop` retry
in `cd9660_ihashget()` (with the purge, a successful vget can legitimately
observe the node gone from the hash and must drop the lock+ref it holds).
