# DF-0927 — Verdict

**Verdict: REPRODUCED (panic + hard-hang DoS, three variants). FIX VALIDATED.**

## Mechanism (root cause, with `path:line`)

`hpfs_genlookupbyname` (`sys/vfs/hpfs/hpfs_lookup.c:55-108`) reads a 2048-byte
(`D_BSIZE`, `hpfs.h:133`) directory block via `hpfs_breaddirblk`
(`hpfs_subr.h:78` → `hpfs_breadstruct`, `hpfs_subr.c:835`, which checks only
the 4-byte magic) and then walks the on-disk dirent chain inside that buffer:

```c
dep = D_DIRENT(dp);                                            /* hpfs_lookup.c:80 */
while(!(dep->de_flag & DE_END)) {                              /* :82 */
    ...
    res = hpfs_cmpfname(hpmp, name, namelen,
                        dep->de_name, dep->de_namelen, ...);   /* :87 */
    if (res == 0) { ...; return (0); }                         /* :89 */
    else if (res < 0) break;                                   /* :93 */
    dep = (hpfsdirent_t *)(((caddr_t)dep) + dep->de_reclen);   /* :96 */
}
if (dep->de_flag & DE_DOWN) {                                  /* :99 */
    lsn = DE_DOWNLSN(dep);                                     /* :100 */
    brelse(bp); goto dive;                                     /* :101-102 */
}
```

There is **no bound** of `(caddr_t)dep` against `(caddr_t)dp + D_BSIZE`, no
minimum on `dep->de_reclen` (`u_int16_t`, `hpfs.h:117`), no maximum on
`dep->de_namelen` (`u_int8_t`, `hpfs.h:127`), and **no depth counter** on
the `dive` loop. By contrast the sibling `hpfs_readdir` carries an
`int level` (`hpfs_vnops.c:776,821,886,913,922-927`) and `hpfs_validateparent`
carries an `int level` (`hpfs_subr.c:532,549,601,616,624-627`) — both lack
only the buffer bound, but `hpfs_genlookupbyname` lacks both.

Four attacker-controllable conditions follow directly:

| Variant | Poisoned bytes                                                    | Effect |
|---------|-------------------------------------------------------------------|--------|
| A (OOB read)     | first dirent `de_reclen=0xFFFF`, `DE_END` clear, name "A"   | `dep` advances 64 KiB past the 2 KiB buffer; next `dep->de_flag` read is far OOB → page fault panic, OR (if mapped) kernel heap disclosure via the readdir/`hpfs_de_uiomove` name copy. |
| B (infinite loop)| first dirent `de_reclen=0`, `DE_END` clear, name "A"         | `cmpfname("zzz","A")>0` → loop body runs; `dep += 0` → no advance; spin forever. |
| C (depth cycle)  | two dirblks whose first dirent has `DE_END\|DE_DOWN` and `down_lsn` referencing each other | the `dive` loop follows `DE_DOWNLSN` A→B→A→B… forever; no depth/visited guard. |
| (DE_DOWNLSN underflow) | first dirent `de_reclen<4`                            | `DE_DOWNLSN(dep) = *(lsn_t *)((caddr_t)dep + dep->de_reclen - 4)` underflows; far OOB read. Closed implicitly by the fix's `de_reclen >= sizeof(struct hpfsdirent)` minimum. |

## Reproduction on `#0` baseline GENERIC (`with-src`, INVARIANTS ON)

### Variant A — kernel OOB-read panic
```
$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'   # uid 1001, post root-mount
[guest down]
--- dfbsd-qemu/boot.log ---
Fatal trap 12: page fault while in kernel mode
cpuid = 1; lapic id = 1
fault virtual address = 0xfffff80058e36015
fault code = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff826032f6
current process = 910 (stat)
kernel: type 12 trap, code=0
Stopped at hpfs_validateparent+0x146: movzwl 0x2(%r15),%edx
db>
```
The faulting instruction `movzwl 0x2(%r15),%edx` reads `dep->de_flag`
(offset 0x2 of `struct hpfsdirent`) at the poisoned `%r15 = dep` after
`dep += de_reclen(0xFFFF)`. The fault address `0xfffff80058e36015` is
~0xFFFF past a kernel heap address — exactly the unbounded-stride OOB.

The panic fires in `hpfs_validateparent` (`hpfs_subr.c:522`) — a sibling
of `hpfs_genlookupbyname` sharing the same unbounded `dep += dep->de_reclen`
pattern at `hpfs_subr.c:576, 588, 610`. The finding text explicitly notes
this is the same bug class. `stat` triggers
`VOP_GETATTR → hpfs_getattr → hpfs_validateparent` (when `H_PARVALID` is
clear; `hpfs_vnops.c:466-467`) *before* it reaches
`lookup → hpfs_lookup → hpfs_genlookupbyname`, so the OOB lands in
`hpfs_validateparent` first. Both functions need the same bound.

### Variant B — de_reclen=0 hard hang
```
$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'
[12 s timeout — guest wedged, ssh dies]
--- dfbsd-qemu/boot.log shows no panic; pure kernel spin ---
```

### Variant C — DE_DOWN cycle hard hang
```
$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'
[12 s timeout — guest wedged, ssh dies]
```

### Deterministic userspace harness (transcribes `hpfs_genlookupbyname`)
```
[A] BUG  de_reclen=0xFFFF: oob=63507B past buf, steps=1, dives=1, oob_at_step=1
        expected OOB = 0xFFFF - (D_BSIZE - 20) = 63507 B
[B] BUG  de_reclen=0: rc(oob/spin)=-3, steps=1001 (capped; kernel = infinite loop)
[C] BUG  D0<->D1 cycle: rc=-4 (expect -4 cycle), steps=0, dives=6 (capped; kernel = infinite)
DF_0927_BUG_CONFIRMED=1
DF_0927_FIX_REJECTS_ALL_VARIANTS=1
```
The harness reads `D_BSIZE`, `struct dirblk`, `struct hpfsdirent` straight
from `sys/vfs/hpfs/hpfs.h`. It places the 2 KiB dirblk at the start of a
256 KiB 0xAA-poisoned mmap and runs the exact loop body. Variant A reports
**63 507 B OOB past the buffer** (matching `0xFFFF - (2048 - 20)`), variant
B hits the 1000-iteration cap (kernel would spin forever), variant C hits
the 5-dive cap (kernel would cycle forever).

## Threat model / reachability

- HPFS mount is root-gated (`vfs.usermount=0`). The PoC assumes an admin
  has mounted (or made mountable) the attacker's crafted image — the
  standard filesystem-image threat model (USB auto-mount, jail/multi-tenant
  images, kiosk systems, `vfs.usermount` per-user grants). The finding's
  CVSS `PR:L` (not `PR:N`) reflects this.
- **Post-mount, the trigger is fully unprivileged.** Once root mounts the
  image at a world-traversable path (the PoC uses `/mnt/df0927` with
  `chown maxx:maxx`), `maxx` (uid 1001, NOT in wheel) issues
  `stat /mnt/df0927/zzz` and triggers the bug. The reproduction in this
  report uses exactly that path.

## Impact

- **OOB read (variant A):** kernel page-fault panic → reliable DoS. If the
  OOB read lands in mapped kernel memory instead, the bytes flow to
  userspace via the readdir/`getattr` name-copy path
  (`hpfs_vnops.c:745-747` `hpfs_de_uiomove` and `:1068-1070`
  `bcopy(dep->de_name, hp->h_name, ...)`), yielding a kernel heap
  disclosure. The page-fault vs info-leak outcome depends on kernel heap
  layout (the 64 KiB stride crosses many slab pages); both are bug
  manifestations.
- **Hangs (B, C):** deterministic local DoS. The kernel thread spinning in
  `hpfs_validateparent`/`hpfs_genlookupbyname` holds VFS locks and wedges
  the guest (ssh stops responding).

## The fix (`fix.diff`)

Two files patched:

1. **`sys/vfs/hpfs/hpfs_lookup.c`** (the finding's primary target): adds
   - `dlimit = (caddr_t)dp + D_BSIZE` hard buffer bound,
   - per-iteration check `(caddr_t)dep + sizeof(struct hpfsdirent) > dlimit ||
     dep->de_reclen < sizeof(struct hpfsdirent) ||
     (caddr_t)dep + dep->de_reclen > dlimit ||
     dep->de_namelen > dep->de_reclen - sizeof(struct hpfsdirent) + 1` →
     `goto bad` (returns `EINVAL`),
   - re-validation of the terminator dirent before `DE_DOWN`/`DE_DOWNLSN`,
   - `int depth` counter on the `dive` loop with `HPFS_DIRDEPTH_MAX 64`
     cap → defeats the DE_DOWN cycle.

2. **`sys/vfs/hpfs/hpfs_subr.c`** (`hpfs_validateparent`, where the panic
   actually fires on the stat path): adds the same `dlimit` buffer bound
   and per-iteration check at all three `dep += dep->de_reclen` sites, plus
   a `level > HPFS_DIRDEPTH_MAX` cap at the top of the `dive` loop (this
   function already had a `level` counter, but no maximum; without this the
   DE_DOWN cycle would still spin here).

Both files gain `#define HPFS_DIRDEPTH_MAX 64` (B-tree depth sanity bound;
real HPFS B-trees are <10 deep). The fix matches the finding's `## Recommended fix`
proposal in `hpfs_lookup.c` and extends the same pattern to
`hpfs_validateparent` (which the finding notes is the same bug class).

## Phase 8 — fix validation

Built the patched `hpfs.ko` as a KLD module (`HPFS is loadable, not built
into the GENERIC kernel; verified via `kldstat -v` and `nm /boot/kernel/kernel`
showing no hpfs symbols`). Single command:
```
cd /usr/src/sys/vfs/hpfs && make -DNO_MODULES    # rc=0, all 6 TUs + link
```
Installed: `cp /usr/obj/.../hpfs.ko /boot/kernel/hpfs.ko`
(sha256 `8846085ddedce20bac8f80369a75cd3a14b178555c99df7f8fcd6f3c05f504da`,
44 160 B).

| Variant | Baseline `#0` (BUGGY)        | Patched (FIXED)                                |
|---------|------------------------------|------------------------------------------------|
| A       | `Fatal trap 12` panic        | `stat: Invalid argument` (EINVAL), guest up, dmesg: `hpfs_validateparent: corrupt dirblk` |
| B       | hard hang (ssh dies)         | `stat: Invalid argument` (EINVAL), guest up, dmesg: `hpfs_validateparent: corrupt dirblk` |
| C       | hard hang (ssh dies)         | `stat: Invalid argument` (EINVAL), guest up, dmesg: `hpfs_validateparent: corrupt dirblk` / `too deep at lsn 0x40` |

The patch closes all three variants deterministically. `git apply --check`
passes cleanly against the unmodified `sys/` tree.

## PoC changes (vs the reviewer-supplied scaffolding)

- The original `mkimg.py` expected a `base.hpfs` that does not exist in the
  evidence pack. Replaced with `craft_img.py` which **builds a complete
  valid HPFS image from scratch** (super/spare/bitmap/root-fnode/dirblk),
  mirroring the proven DF-0857 layout, and emits all three variants
  (`--oob`, `--hang1`, `--cycle`).
- Added `harness.c`: a deterministic userspace transcription of
  `hpfs_genlookupbyname:82-102` against the exact `hpfs.h` struct layouts,
  proving all three variants byte-for-byte and demonstrating the fix
  rejection. This gives a reproducible signal that does not depend on
  kernel heap layout (the live-kernel OOB fault-vs-leak outcome varies by
  run, but the harness always reports `oob=63507`).
- Added `fix.diff` extending the finding's proposed fix to also patch
  `hpfs_validateparent` (the actual panic path on this guest) with the same
  bound check + depth cap.

## Non-corruption note (no escalation chain)

This is a pure OOB-read + DoS finding (CWE-125/835/400). The primitive is
**read-only** — the loop only reads `dep->de_flag`, `dep->de_name`,
`dep->de_reclen`, `dep->de_namelen`, `dep->de_cpid`; it does not write the
buffer. There is no write/UAF/double-free primitive to convert into a
credential corruption. The realistic impact ceiling is therefore:
(1) reliable kernel panic / hard hang (DoS), confirmed on all three
variants; and (2) potential kernel heap info-leak if the OOB read lands in
mapped memory and the bytes flow to userspace via the readdir name copy.
No `uid=0` escalation is derivable; this is not a memory-corruption
primitive that can be groomed into a write.
