# DF-3253 — hpfs_hphashvget hands out half-initialized H_INVAL hpfsnodes (publish-before-validate race)

## What

`hpfs_vget` (sys/vfs/hpfs/hpfs_vfsops.c:456-552) publishes the freshly
`kmalloc`'d hpfsnode into the global inode hash — `hpfs_hphashins(hp)` at
**hpfs_vfsops.c:525** — BEFORE validating it:

```c
525:    hpfs_hphashins(hp);                       /* published */
527:    LOCKMGR(&hpfs_hphash_lock, LK_RELEASE);
529:    error = bread(hpmp->hpm_devvp, dbtodoff(ino), FNODESIZE, &bp);
530:    if (error) { kprintf(...); vx_put(vp); return (error); }   /* still hashed */
535:    bcopy(bp->b_data, &hp->h_fn, sizeof(struct fnode));
538:    if (hp->h_fn.fn_magic != FN_MAGIC) {
539:        kprintf("hpfs_vget: MAGIC DOESN'T MATCH\n");
540:        vx_put(vp); return (EINVAL);          /* still hashed */
```

Both error paths drop the vnode and return **without unhashing the node**.
The node is marked `H_INVAL` (set at :506, cleared only at :545 after
validation), and in the bread-failure case `h_fn` is **uninitialized kernel
heap** (kmalloc at :488, never zeroed).

`hpfs_hphashvget` (**sys/vfs/hpfs/hpfs_hash.c:104-139**, the audited file)
finds such a node, blocks in `vget(vp, LK_EXCLUSIVE)` on the creator's VX
lock, and — when the failing creator `vx_put()`s — wakes up and returns the
poisoned vnode as a successful lookup **without ever checking
`h_flag & H_INVAL`** (its re-walk at :122-129 verifies identity only).
`vget()` takes its reference before blocking (sys/kern/vfs_lock.c:589), so
the creator's `vx_put` cannot reclaim the node under the waiter.

`hpfs_lookup` (hpfs_vnops.c:1056) returns the poisoned vp to namei;
`hpfs_getattr` (hpfs_vnops.c:463) then exposes
`vap->va_size = hp->h_fn.fn_size` — bytes from the fnode the kernel itself
just rejected (badmagic) or from uninitialized kernel heap (bread failure)
— as a successful `stat()` of a file that can NEVER be looked up
sequentially (every non-racing lookup fails EINVAL).

Threat model: crafted HPFS image (attacker controls fnode magic / image
truncation makes bread fail) + any local user racing lookups on the
mounted fs. Unprivileged trigger (uid 1001). Same class as DF-3251
(ext2 publish-before-validate twin); hpfs_hash.c is the validation
omission site.

## PoC

- `craft_df3253.py <out.img> <badmagic|trunc>` — minimal crafted HPFS image
  (root fnode @0x20 -> dirblk @0x40 -> dirent "ZFILE" -> fnode @0x58).
  *badmagic*: fnode magic = 0xDEADBEEF, fn_size = 0x00BADF00D (marker).
  *trunc*: image ends before 0x58 so `bread` fails -> h_fn stays
  uninitialized heap.
- `poc.c` — N threads racing `stat()` of the always-rejected file.
  Sequentially stat can only return EINVAL. Any `stat()==0` (printed as
  `RACE WIN`, with `st_size=0x00BADF00D` for badmagic, or varying heap
  garbage for trunc) is the poisoned handoff.

## Run (exact)

```
./build.sh                       # compile poc + mount helper in guest
./run.sh badmagic                # or: ./run.sh trunc
# custom: NTHR=8 ITERS=300 REPS=3 ./run.sh badmagic
```

## Expected output (instrumented kernel, see VERDICT.md)

```
UTIMES WIN tid2 pathA: utimes()=0 on a fnode the kernel ALWAYS rejects (poisoned handoff confirmed)
DF-3253 SUMMARY: wins=19 einval=37 ebadf(poisoned handoffs seen by stat)=19 enoent=0 other=0
DF-3253 VERDICT: POISONED H_INVAL HPFSNODE HANDED OUT BY hpfs_hphashvget — RACE CONFIRMED
```

Note: `stat()` itself cannot return 0 on the stock failure paths — the failed
creator never sets `v_type`, so the handed-off VNON vnode reaches
`cvn_stat`'s type switch (sys/kern/vfs_vnops.c:894 `default: return (EBADF)`)
and stat fails **EBADF** (the normal failure is EINVAL from vget; EBADF is
the handoff signature). The syscall-success proof is `utimes()==0`
(`hpfs_setattr`'s utimes path has no v_type check), which also mutates the
rejected inode (`H_PARCHANGE`). Kernel console shows
`DF3253H: *** INVAL HANDOFF #N ***` per handoff; ~half the runs panic at the
cleanup umount (`panic: unmount: dangling vnode`). With fix.diff applied:
`wins=0 ... ebadf=0 einval=960`, UMOUNT_OK.

## Why an instrumented kernel

On the stock kernel the post-publish window is bread(cache-hit)+bcopy —
sub-microsecond — while every failed lookup costs two serial-console
kprintfs (~60ms), so the waiter almost never lands inside the window under
console-lock serialization (0 wins in ~750 real iterations). The real-world
window is the bread itself: a cold fnode read on rotating media / a stalled
device is 5-20ms. The guest kernel is therefore instrumented
(`instrument_applied.diff`, guest-only, never in the audit sys/ tree) with
a 10ms tsleep inside the two failure paths — labeled DF3253 — reproducing
cold-I/O latency. The fix validation runs on the SAME instrumented kernel:
the window stays 10ms, the fix must still yield zero handoffs.
