# DF-3253 — VERDICT

**Status: REPRODUCED** (poisoned H_INVAL hpfsnode handoff via `hpfs_hphashvget`,
unprivileged, crafted HPFS image) — plus a demonstrated secondary consequence:
**kernel panic (`unmount: dangling vnode`) on the subsequent umount in 2 of 4
racing runs.** Fix validated on the same instrumented kernel: handoffs 0,
960/960 lookups fail EINVAL, umount clean.

## Root cause (line-accurate)

`hpfs_vget` (sys/vfs/hpfs/hpfs_vfsops.c:456-552):

- :488 `hp = kmalloc(sizeof(struct hpfsnode), M_HPFSNO, M_WAITOK)` — **not zeroed**
- :506 `hp->h_flag = H_INVAL;`
- :525 `hpfs_hphashins(hp);` — **published into the global inode hash**
- :527 lockmgr release
- :529 `error = bread(...)` — fails (truncated image) → :532 `vx_put(vp); return (error);`
  — **node stays hashed, `h_fn` is uninitialized kernel heap**
- :538 `if (hp->h_fn.fn_magic != FN_MAGIC)` → :540 `vx_put(vp); return (EINVAL);`
  — **node stays hashed with the rejected fnode**
- `H_INVAL` is cleared only at :545 on the success path; `vp->v_type` is set
  only at :544 — failed creators leave `v_type == VNON`.

`hpfs_hphashvget` (sys/vfs/hpfs/hpfs_hash.c:104-139 — the audited file):

- :111-113 a concurrent lookup finds the published node,
- :116 `vget(vp, LK_EXCLUSIVE)` blocks on the creator's VX lock — the waiter's
  reference is taken at sys/kern/vfs_lock.c:589 BEFORE `vn_lock`, so the
  creator's `vx_put` cannot reclaim the node under it,
- :122-129 post-wake re-walk verifies **identity only — `h_flag & H_INVAL` is
  never checked** — and the half-initialized vnode is returned as a successful
  lookup result at :135.

`hpfs_lookup` (hpfs_vnops.c:1056) hands it to namei without further checks.
`hpfs_getattr` (hpfs_vnops.c:463) then computes `va_size = hp->h_fn.fn_size`
from the rejected/uninitialized fnode.

## Race shape (empirically established)

namei holds the directory vnode lock across VOP_LOOKUP, so lookups within ONE
directory are serialized and can never race (measured: 1451 publishes, 0
waiter hash-hits with a single-directory PoC). The handoff requires two
concurrent `VFS_VGET()`s for the SAME fnode through DIFFERENT directories —
provided directly by a crafted image with two dirents (A/ZFILE, B/WFILE)
both pointing at fnode 0x58. Nothing in HPFS cross-locks those two paths.

## Demonstration (guest, stock INVARIANTS kernel #2 + labeled instruments)

Instruments (guest-only, never in the audit tree): 10ms `tsleep` in the two
failure paths (simulating cold-fnode bread latency; real-world 5-20ms on
rotating/stalled media) + counters/prints in `hphashvget` (`DF3253H`) and at
publish (`DF3253P`).

PoC: 8 threads × 120 `stat()`s alternating between the two paths, **as uid
1001** on a vn(4)-mounted crafted image (mount by root via `mount_rw`).

Baseline results (3 independent runs):

- `DF-3253 SUMMARY: wins=40 einval=0 ebadf(poisoned handoffs seen by
  stat)=40` and `wins=19 ... ebadf=19` — every stat that did not become a
  fresh creator received a poisoned vnode (kernel counter `INVAL HANDOFF`
  matches; console shows `vget OK` → `*** INVAL HANDOFF #N ***`).
- `UTIMES WIN tidN pathX: utimes()=0 on a fnode the kernel ALWAYS rejects`
  (27 printed in one run) — full syscall SUCCESS (`hpfs_setattr` utimes path,
  hpfs_vnops.c:530-544, has no v_type check) on a fnode whose lookup can only
  ever fail — it also sets `H_PARCHANGE` on the poisoned node (kernel state
  mutated through the rejected fnode).
- `stat()` itself returns **EBADF**: the poisoned vnode reaches
  `cvn_stat`'s `vp->v_type` switch (sys/kern/vfs_vnops.c:894-895 `default:
  return (EBADF)`) because `v_type` was never initialized — i.e. the EBADF is
  itself the signature that a VNON vnode was returned by a SUCCESSFUL lookup
  (the normal failure is EINVAL from vget).
- **Panic**: 2 of 4 racing runs ended with `panic: unmount: dangling vnode`
  (dounmount → vflush unable to drain the poisoned/mutated vnodes) — local
  kernel DoS. Console evidence: `panic.console.txt`, `panic2.console.txt`
  (both show `INVAL HANDOFF #N` immediately before the panic), plus one
  clean-umount baseline (`run.baseline3`).

Uninitialized-heap nuance (trunc variant, bread fails at :529): same publish
window and same handoff mechanics (identical code path); `h_fn` holds stale
kernel heap that `hpfs_getattr` reads (`fn_size`, `fn_parent` walk). Direct
byte disclosure to userspace is blocked on this path by the VNON abort in
`cvn_stat`; the heap bytes still drive kernel-side metadata interpretation
(validateparent walk, va_size/va_bytes computation). Not separately
re-run — the badmagic variant exercises the identical two error paths.

## Fix validation (same instrumented kernel #2, module swap only)

`fix.diff` = (a) `hpfs_hphashrem(hp)` on BOTH post-publish error paths in
`hpfs_vget` (load-bearing half — validated alone: forced full module rebuild,
0 handoffs) + (b) `H_INVAL` re-check/retry in `hpfs_hphashvget` after the
identity re-walk (defense-in-depth).

| build | poisoned handoffs (EBADF) | utimes wins | umount |
|---|---|---|---|
| baseline (instr) run1 | 40 | 40 | **panic** |
| baseline run3 | 19 | 19 | OK |
| unhash-only fix | **0** (960/960 EINVAL) | **0** | OK |

Identical PoC, identical 10ms window: with the fix the previously observed
behavior is GONE — every lookup fails EINVAL (the only legitimate outcome),
no handoffs (kernel counter 0), unmount clean.

## Honest limits

- The stat()==0-with-attacker-size observable is NOT achievable on the stock
  failure paths (v_type is never set on failure → cvn_stat aborts EBADF);
  the demonstrated syscall-success observable is utimes()==0 on the rejected
  fnode, which is strictly stronger as an integrity signal.
- The dangling-vnode panic reproduced 2/4 times (timing-dependent vnode
  lifecycle after handoff + PARCHANGE mutation); root-cause of which vnode
  dangles was not further dissected (ddb session not interactive through
  this harness).
- Attack preconditions: crafted HPFS image mounted (root or usermount) +
  local unprivileged user racing lookups; the 10ms validation window is
  instrument-supplied in the demo but is the natural bread latency of any
  cold/stalled device (and grows without bound on a slow/erroring reader).

## Impact

Unprivileged local user on a system with an attacker-influenced HPFS mount:
(1) successful VFS lookups returning half-initialized/rejected inodes —
rejected-metadata integrity bypass (utimes/metadata mutations on inodes the
kernel itself refuses) and uninitialized-kernel-heap-driven metadata walks;
(2) kernel panic at umount (local DoS). No user→root chain demonstrated
(read-side/type-confusion primitive; no controlled write). Severity Medium
(matches the DF-3251 ext2 twin class), bucket base:vfs.

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