# DF-0861 — hpfs_cpinit unbounded code-page inner loop → heap OOB write

## Verdict

**REPRODUCED.** The heap OOB write in `hpfs_cpinit` is real and confirmed
both by a deterministic userspace harness (4080 bytes, attacker-controlled)
and by live mounting of a crafted HPFS image on the DragonFly `#0` GENERIC
kernel (slab corruption → `Fatal trap 9` panic in `kqueue_register`). The
authored `fix.diff` is **VALIDATED** by hot-swapping a rebuilt `hpfs.ko`:
the crafted image mounts cleanly (1 in-bounds write, no OOB, guest survives
3 cycles) and a forged `sp_cpinum=1000` is now rejected at the validation
gate with `EINVAL`.

## Root cause (confirmed `path:line`)

`hpfs_cpinit()` runs at mount time (`hpfs_vfsops.c:305`) and loads code-page
data into a kernel heap array:

| line | code | role |
|------|------|------|
| `sys/vfs/hpfs/hpfs_subr.c:274` | `cpicnt = hpmp->hpm_sp.sp_cpinum;` | total CP count, **unvalidated on-disk u32** |
| `sys/vfs/hpfs/hpfs_subr.c:276` | `kmalloc(cpicnt * sizeof(struct cpdblk), …)` | array sized by `cpicnt` |
| `sys/vfs/hpfs/hpfs_subr.c:282` | `while (cpicnt > 0)` | outer loop bound = `cpicnt` |
| `sys/vfs/hpfs/hpfs_subr.c:292` | `for (i=0; i<cpisp->s_cpicnt; i++, cpicnt--, cpdbp++, cpibp++)` | inner loop bound = **per-sector** `s_cpicnt` (separate on-disk u32), NOT the remaining `cpicnt` |
| `sys/vfs/hpfs/hpfs_subr.c:297` | `hpfs_cpload(hpmp, cpibp, cpdbp)` | per-entry loader |
| `sys/vfs/hpfs/hpfs_subr.c:230-231` | `bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk))` | writes 136 B of attacker disk data into `cpdbp` |

The outer `while` decrements `cpicnt` (the total), but the inner `for` is
bounded by `cpisp->s_cpicnt` (a **per-sector** count) and runs to completion
even after `cpicnt` has reached 0 (it goes negative; `cpicnt` is `int`).
`cpdbp` is advanced on every inner iteration, so a single code-page-info
sector that advertises `s_cpicnt > sp_cpinum` writes
`sizeof(struct cpdblk)` (= 136 B) through `cpdbp` for `s_cpicnt` iterations,
overshooting the `cpicnt`-sized `hpm_cpdblk` array.

### Trigger values & primitive

| forged field | value | effect |
|--------------|-------|--------|
| `sp_cpinum` (SpareBlock) | `1` | `cpicnt=1` → `kmalloc(1×136 = 136 B)` |
| `cpisec.s_cpicnt` (code-page-info sector) | `0x1F` (31) | inner `for` runs 31 times |

- iteration 0: `cpdbp = &hpm_cpdblk[0]` — in-bounds (136 B)
- iterations 1..30: `cpdbp = &hpm_cpdblk[1..30]` — **30 OOB writes × 136 B = 4080 B**

Each write is a `bcopy` of a `struct cpdblk` (136 B) from a crafted
code-page-data sector, of which `b_upcase[0x80]` (128 B) is freely forgeable
→ the OOB content is **fully attacker-controlled**.

## Reproduction

### 1. Deterministic harness (`harness.c`)
A faithful userspace transcription of the loop + `bcopy` against the exact
on-disk struct layouts from `sys/vfs/hpfs/hpfs.h`, with a poisoned allocator
 modelling the slab neighbourhood. Output (decisive):

```
--- BUG MODE (unpatched hpfs_cpinit) ---
  sp_cpinum=1 -> cpicnt=1 -> kmalloc(hpm_cpdblk)=136 bytes
  cpisec.s_cpicnt=31 -> inner for() runs 31 times
  result: in-bounds writes=1  OOB writes=30  OOB bytes=4080
  first-OOB cpdblk b_upcase[0..1] = 0x41 0x42 (marker) -> ATTACKER-CONTROLLED
  DF_0861_BUG_CONFIRMED=1
  DF_0861_BUG_OOB_WRITE_BYTES=4080
```

### 2. Live trigger on `#0` GENERIC (INVARIANTS ON)
Crafted `df861.img` (SpareBlock `sp_cpinum=1`, cpisec `s_cpicnt=0x1F`),
`vnconfig`'d and mounted read-only. `hpfs_cpinit` runs unconditionally at
mount time (`hpfs_vfsops.c:305`) regardless of mount flags, so an RO mount
suffices. The mount returns success (cpinit completes), then the 4080-byte
OOB write has corrupted a neighbouring slab object; a subsequent kernel
operation panics:

```
Fatal trap 9: general protection fault while in kernel mode
instruction pointer = 0x8:0xffffffff8063b1c6
current process     = 996
Stopped at      kqueue_register+0x526:  movq    (%r15),%rdx
db>
```

(The cascade timing is probabilistic, as is normal for slab corruption:
run 1 panicked in `kqueue_register`; runs 2 & 3 silently corrupted and the
guest survived until reset. The deterministic harness proves the 4080-byte
OOB write happens on **every** mount; the live runs confirm the code path
is exercised and the corruption is real.)

## Impact / exploitability

- **Primitive:** 4080-byte, attacker-controlled heap OOB write at mount
  time, into the slab neighbourhood of a 136-byte `kmalloc(…, M_HPFSMNT)`.
- **Trigger:** mount-time only. Mounting requires root by default; the
  realistic unprivileged path requires `vfs.usermount=1` plus a root-created
  + chowned image (an acceptable precondition per the threat model, but not
  the default config).
- **GENERIC (INVARIANTS ON):** the write itself does not trip INVARIANTS
  (slab checks fire on alloc/free, not per-`bcopy`); the corruption silently
  damages neighbours and **probabilistically** panics (DoS) when a victim
  object is next touched. A clean `uid=0` on GENERIC would require slab
  grooming that evades INVARIANTS — the demonstrated, realistic impact on
  the default kernel is **heap corruption → panic (DoS)**.
- **Non-default (`noinv`, INVARIANTS OFF):** the corruption is silent and
  the primitive is a strong candidate for slab grooming → function-pointer /
  `ucred` overwrite. That is a non-default-kernel characterization, not a
  default-GENERIC `uid0`.

Realistic impact ceiling: **local DoS / heap corruption** from a mount
operation (root, or usermount precondition); a credible privesc primitive
only under the usermount precondition on a non-INVARIANTS kernel.

## Fix

`fix.diff` (git-apply-able, validated):
1. **Bound the inner loop** — `for (i=0; i<cpisp->s_cpicnt && cpicnt > 0; …)`
   so `cpdbp` can never advance past the allocated array (`sys/vfs/hpfs/hpfs_subr.c:307`).
2. **Cap `s_cpicnt` per sector** to `CPIS_NCPI` (0x1F, the `s_cpi[]` array
   bound) so `cpibp` reads stay in-bounds (`:305-306`).
3. **Validate `sp_cpinum`** against `HPFS_SP_CPINUM_MAX` (256), returning
   `EINVAL` for forged superblocks (`:283-287`) — also prevents the
   `cpicnt * sizeof(struct cpdblk)` kmalloc from wrapping/huge on a
   hostile `sp_cpinum`.

The constants are added to `sys/vfs/hpfs/hpfs.h`.

## Fix validation (Phase 8 — module hot-swap)

Applied `fix.diff` to in-guest `/usr/src`, rebuilt only the `hpfs.ko` KLD
module (`make` in `/usr/src/sys/vfs/hpfs`, ~25 s), installed to
`/boot/kernel/hpfs.ko` (sha256
`01434d8ffb77c5963649a3236f117e79535ccb39369e76d566987875c892cb65`),
`kldunload`/`kldload` to hot-swap on the running `#0` kernel, then:

| image | unpatched hpfs.ko | patched hpfs.ko |
|-------|-------------------|-----------------|
| `df861.img` (`sp_cpinum=1`, `s_cpicnt=0x1F`) | 4080-B OOB write → slab corruption → panic in `kqueue_register` (run 1) | **clean mount**, 1 in-bounds write, 3 mount/unmount cycles OK, guest UP |
| `df861-huge.img` (`sp_cpinum=1000`) | 136 000-B kmalloc + 30 OOB writes | **EINVAL rejected** at the gate; dmesg: `hpfs_cpinit: forged sp_cpinum 1000 > max 256`; guest UP |

Fix closes the bug: the inner loop can no longer overshoot the array, and
forged superblocks are rejected before any allocation/loop runs. No full
kernel rebuild was required (KLD module hot-swap).

## PoC changes

The runner created the entire evidence pack from scratch (the finding folder
did not exist). Files authored:

- `craft_img.py` — HPFS image crafter (forged SpareBlock `sp_cpinum=1` +
  cpisec `s_cpicnt=0x1F` + a cpdsec sector with attacker marker content)
- `harness.c` — deterministic OOB-write proof (faithful loop+bcopy
  transcription + poisoned allocator + fixed-mode control)
- `df861.img` / `df861-huge.img` — crafted 64 KB HPFS images (binary)
- `mount_hpfs_simple.c` — `mount(2)` helper (RO mount is enough; cpinit
  runs regardless of mount flags)
- `build.sh` / `run.sh` — exact repro scripts
- `fix.diff` — git-apply-able fix (bound inner loop + cap s_cpicnt +
  validate sp_cpinum)
- `panic.txt`, `dmesg.txt`, `env.txt`, `build.log`, `run.log`,
  `fix_build.log`, `fix_run.log`, `manifest.json`, this `VERDICT.md`
