# DF-0863 — hpfs_cpload OOB read of cpdsec via unchecked d_cpcnt past fixed d_cpdblk[3] array

**Verdict:** REPRODUCED (panic / OOB read via crafted HPFS image). **Fix VALIDATED.**
**Severity:** Medium.  **Impact:** kernel panic at mount (local DoS); OOB read of kernel heap.

## What the bug is
`hpfs_cpload` (`sys/vfs/hpfs/hpfs_subr.c:228`) reads a Code-Page Data Sector
(`struct cpdsec`) off disk into a 512-byte `bp->b_data` buffer and then walks
its `d_cpdblk[]` array using `d_cpfirst`/`d_cpcnt` — two `u_int16_t` values
taken **verbatim from the attacker-controlled image with no validation**:

```c
for (i=cpdsp->d_cpfirst; i<cpdsp->d_cpcnt; i++) {
    if (cpdsp->d_cpdblk[i].b_cpid == cpibp->b_cpid) {     /* OOB READ */
        bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk));
```

But `d_cpdblk[]` is a **fixed-size array of 3 entries**
(`sys/vfs/hpfs/hpfs.h:289` — `struct cpdblk d_cpdblk[3];`). Setting
`d_cpcnt = 0xFFFF` (and `d_cpfirst = 0`) makes the loop walk past the array and
past the 512-byte `bp->b_data` buffer, eventually crossing a page boundary →
`Fatal trap 12, page fault, supervisor read data, page not present`. The
faulting instruction is the `b_cpid` comparison at line 229.

Layout: `d_cpdblk` occupies bytes [26..434) of the on-disk `cpd_sec` inside the
512-byte buffer. Reading `d_cpdblk[i].b_cpid` (struct offset 26 + i*136 + 2):
i=0,1,2 in-bounds; i=3 still inside the buffer; **i>=4 reads OOB past
`bp->b_data`**. The same path's `bcopy` (line 230) can also leak up to 136
bytes of out-of-buffer kernel memory into the `hpm_cpdblk[]` case-conversion
tables (exfiltrable via filenames) if a tighter `d_cpcnt` is crafted.

## Threat model / reachability
HPFS is `optional hpfs` (not in X86_64_GENERIC) but shipped as
`/boot/kernel/hpfs.ko`. An admin enables it with `kldload hpfs` (standard
FS-enable action). The attacker supplies a crafted image; the mounter (root,
or an unprivileged user after `vfs.usermount=1` + an owned memory disk) mounts
it → kernel panic in `hpfs_cpload` at mount. Classic filesystem-image-parsing
memory-safety bug. No unpriv→root escalation (read-only primitive, valid
Phase-6 hard blocker). Impact ceiling = local DoS / heap info-leak.

## Reproduce
```sh
./build.sh                       # cc -O2 -Wall -o craft_img craft_img.c
./craft_img crafted.img 0xFFFF
# as root (the victim mounting the attacker image):
kldload hpfs
DEV=$(vnconfig -c vn $(pwd)/crafted.img | grep -oE 'vn[0-9]+' | head -1)
mount -t hpfs -o ro /dev/$DEV /mnt/df0863   # UNPATCHED: Fatal trap 12 in hpfs_cpload+0xa7
```
Expected on the **unpatched** kernel (serial `boot.log`):
```
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff800679c700c
fault code = supervisor read data, page not present
Stopped at hpfs_cpload+0xa7: cmpw %si,-0x6c(%rdx)
```
Expected on the **fixed** module: `mount_hpfs: /dev/vnN: No such file or
directory` (ENOENT — the existing "no CP match" return propagated cleanly out
of `hpfs_cpload` → `hpfs_cpinit` → `hpfs_mountfs`), guest stays up.

## The fix (`fix.diff`)
Single hunk in `sys/vfs/hpfs/hpfs_subr.c`, immediately after the buffer is
cast to `struct cpdsec *`:
1. Reject any CPD sector whose `d_magic != CPD_MAGIC` (defense in depth — the
   on-disk magic was previously ignored).
2. If `d_cpfirst >= nitems(d_cpdblk)`, return ENOENT.
3. Clamp `d_cpcnt` to `nitems(d_cpdblk)` (== 3) so the loop can never read
   past the fixed-size array.

`nitems()` is already in `sys/sys/param.h:399` (NELEM). Minimal, one logical
change; preserves existing return values for legitimate images.

Validated: built the single-fix `hpfs.ko`, `kldload`'d it, re-ran the **same**
PoC — panic is gone, mount returns ENOENT promptly, guest stays up
(deterministic over 2 runs). See `VERDICT.md`, `fix_run.log`, `panic.txt`.
