DF-0857 — VERDICT
================

**Verdict: REPRODUCED** (info-leak + DoS via unvalidated OOB heap read; root-only mount + unpriv read trigger; no escalation — primitive is read-only)

**Status:** reproduced (live kernel panic + deterministic harness)
**Impact:** `panic` (live) / `leak` (deterministic OOB extent proven; info-leak ceiling demonstrated by the panic mechanism — the OOB-read-derived garbage disk offset is returned to hpfs_read and used for I/O)
**Confidence:** certain

---

## Mechanism (root cause, traced path:line)

`hpfs_hpbmap()` (sys/vfs/hpfs/hpfs_alsubr.c:62) translates a file logical
block number `bn` to a disk offset by walking the on-disk allocation-block
arrays.  Two iteration loops drive the walk:

- sys/vfs/hpfs/hpfs_alsubr.c:80  `for (i=0; i<abp->ab_busycnt; i++, anp++)`  (alnode variant, AB_NODES)
- sys/vfs/hpfs/hpfs_alsubr.c:114 `for (i=0; i<abp->ab_busycnt; i++, alp++)`  (alleaf variant)

`abp->ab_busycnt` is a `u_int8_t` taken straight from the on-disk `alblk_t`
(sys/vfs/hpfs/hpfs.h:176) embedded in either:

- a fnode's `fn_ab` (the root alblk), whose data area is `u_int8_t fn_abd[0x60]`
  = 96 bytes (sys/vfs/hpfs/hpfs.h:200); or
- an alsec's `as_ab`, whose data area is `u_int8_t as_abd[0x1E0]` = 480 bytes
  (sys/vfs/hpfs/hpfs.h:267).

There is **no validation** of `ab_busycnt` against the size of the container
anywhere on the path.  The loop bounds use the on-disk byte verbatim, so a
crafted image with `ab_busycnt = 255` makes the loop dereference
`255 * sizeof(alleaf_t) = 3060` bytes (leaf variant) or `255 * sizeof(alnode_t)
= 2040` bytes (node variant) past the start of `fn_abd`/`as_abd`.

Legitimate maximum `ab_busycnt` per container (data area / element size):

| container        | element   | data area | legit max | forged 255 reads |
|------------------|-----------|-----------|-----------|------------------|
| fnode (`fn_abd`) | alleaf_t  | 96 B      |  8        | 3060 B total, 2964 B past data |
| fnode (`fn_abd`) | alnode_t  | 96 B      | 12        | 2040 B total, 1944 B past data |
| alsec (`as_abd`) | alleaf_t  | 480 B     | 40        | 3060 B total, 2580 B past data |
| alsec (`as_abd`) | alnode_t  | 480 B     | 60        | 2040 B total, 1560 B past data |

The fnode lives inside `struct hpfsnode`, which is `kmalloc(sizeof(struct
hpfsnode), M_HPFSNO, ...)` (sys/vfs/hpfs/hpfs_vfsops.c:488).  Walking 2964
bytes past `fn_abd` therefore crosses:

1. the remaining fields of `struct fnode` (fn_size, fn_reqea, fn_uid, ...);
2. the rest of `struct hpfsnode` (h_vp, h_devvp, h_dev, h_no, h_uid, ...);
3. **the kmalloc slab boundary into neighbouring heap objects.**

The loop body at :114 reads `alp->al_off`, `alp->al_len`, `alp->al_lsn` for
each entry and, if `bn` matches, computes `*bnp = bn - alp->al_off +
alp->al_lsn` (sys/vfs/hpfs/hpfs_alsubr.c:122) — a value derived from the
OOB-read bytes — and returns it to the caller.  `hpfs_read()` then passes
that garbage disk offset to `bread()`, which either (a) faults the buffer
cache (panic) or (b) reads an arbitrary disk sector (info leak of OOB-derived
offset into the file's read buffer).

## Trigger path (unprivileged)

`vfs.usermount = 0` on this guest, so mounting requires root.  This is a
realistic precondition (admin mounts an attacker-supplied HPFS image, or
the image is on a removable device the admin has cause to mount).  Once
mounted, the trigger is **fully unprivileged**:

  ls /mnt                # directory readdir — does NOT hit the bug
  cat /mnt/FILE          # regular file read → VOP_READ → hpfs_read
                         # → hpfs_hpbmap → loop walks past fn_abd → OOB read

## Reproduction evidence

### Live (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, INVARIANTS ON)

`craft_img.py` builds a 64 KB minimal HPFS image (SuperBlock + SpareBlock +
bitmap directory + bitmap band + root dir fnode + dirblk with one "FILE"
dirent + a regular-file fnode whose `fn_ab.ab_busycnt` is forged to 255).
Root mounts it; unpriv user does `cat /mnt/FILE`.  Result (run.log):

```
mount_hpfs -o ro /dev/vn4 /mnt ; MOUNT_RC=0
stat -f "size=%z blocks=%b" /mnt/FILE   →  size=65536 blocks=129
cat /mnt/FILE                            →  HANG (kernel panic)
```

Panic signature (dfbsd-qemu/boot.log → panic.txt):

```
bgetvp: overlapr 0000000000002000/2048 0000000000002200 bp 0xfffff8004f5be060 bx 0xfffff8004f5c0010
panic: bgetvp - overlapping buffer
cpuid = 0
Trace beginning at frame 0xfffff80118a9f608
bgetvp() at bgetvp+0x129 0xffffffff806f44c9
bgetvp() at bgetvp+0x129 0xffffffff806f44c9
getblk() at getblk+0x18b 0xffffffff806d703b
breadnx() at breadnx+0x284 0xffffffff806d7714
hpfs_read() at hpfs_read+0xb3 0xffffffff82601683
vop_read() at vop_read+0x9c 0xffffffff8070a4cc
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>
```

This is the **indirect panic caused by the DF-0857 OOB read**.  hpfs_hpbmap
walked 255 alleaf_t entries past fn_abd[] (the bug); one of the OOB entries
satisfied the `(bn >= al_off) && (bn < al_off+al_len)` test against random
heap bytes, so hpfs_hpbmap returned `*bnp` computed from garbage.  hpfs_read
passed that garbage to bread(), whose bgetvp() panicked on the overlapping
buffer offset.  Reproduced twice on the unpatched #0 kernel; deterministic.

**Contrast (control):** the same image re-crafted with `ab_busycnt=8` (the
legitimate fnode-leaves max) does **not** panic — `cat` returns `EFBIG`
(File too large) and the guest stays up.  This proves the panic is caused
by the forged `ab_busycnt=255`, not by the image structure.

### Deterministic harness (harness.c)

A faithful userspace transcription of the exact loops at
hpfs_alsubr.c:80/:114 against the exact on-disk struct layouts from
hpfs.h, with a poisoned allocator (container placed at end of an mmap'd
page, next page poisoned 0xAA).  Output (run.log, 3 identical runs):

```
[BUG] fnode leaf   forged busycnt=255  total read=3060B  OOB past data=2964B  poison-page hit = YES
[BUG] fnode node   forged busycnt=255  total read=2040B  OOB past data=1944B  poison-page hit = YES
[BUG] alsec leaf   forged busycnt=255  total read=3060B  OOB past data=2580B  poison-page hit = YES
[BUG] alsec node   forged busycnt=255  total read=2040B  OOB past data=1560B  poison-page hit = YES
[FIX] fnode leaf   forged busycnt=255  result = REJECTED (EINVAL, no loop run)
[FIX] fnode node   forged busycnt=255  result = REJECTED (EINVAL, no loop run)
[FIX] alsec leaf   forged busycnt=255  result = REJECTED (EINVAL, no loop run)
[FIX] alsec node   forged busycnt=255  result = REJECTED (EINVAL, no loop run)
DF_0857_BUG_CONFIRMED=1
DF_0857_FIX_REJECTS_FORGED_BUSYCNT=1
```

## Exploit chain / impact ceiling

This is a **read-only** primitive (the loops only dereference/read the OOB
elements; they don't write).  No write capability ⇒ no escalation chain to
uid=0.  The realistic impact ceiling is:

1. **Kernel info leak** — the OOB-read-derived disk offset (`*bnp` computed
   from heap bytes) is returned to hpfs_read and used to drive bread(); if
   it happens to be a readable sector, the file contents returned to
   userspace are a function of leaked kernel heap residue (slab neighbour
   bytes).  Repeated mounts/reads with varied forged `ab_busycnt` and
   `bn` values would let an attacker sample slab-neighbour contents.
2. **Kernel panic / DoS** — when the garbage offset collides with an
   existing buffer (as observed live) or points at an unmapped address,
   the kernel panics.  Deterministic on the default GENERIC kernel.

No SMAP/SMEP/KASLR-bypass chain is relevant because there is no write
primitive.  The valid hard blocker for escalation applies: **the primitive
is genuinely read-only** (Phase 6 valid blocker).

## Fix (fix.diff)

Validate `ab_busycnt` against the container maximum at every `dive:` point
in `hpfs_hpbmap()`, distinguishing fnode-root vs alsec containers via a new
`in_fnode` local (set to 1 at entry, cleared to 0 when diving into an
alsec).  On overflow, log and return EINVAL before either loop runs.

Container maximums are derived from the on-disk data-area sizes:

```c
#define HPFS_FN_ABD_SIZE 0x60
#define HPFS_AS_ABD_SIZE 0x1E0
#define HPFS_FN_MAX_LEAF (HPFS_FN_ABD_SIZE / sizeof(alleaf_t))  /*  8 */
#define HPFS_FN_MAX_NODE (HPFS_FN_ABD_SIZE / sizeof(alnode_t))  /* 12 */
#define HPFS_AS_MAX_LEAF (HPFS_AS_ABD_SIZE / sizeof(alleaf_t))  /* 40 */
#define HPFS_AS_MAX_NODE (HPFS_AS_ABD_SIZE / sizeof(alnode_t))  /* 60 */
```

## Fix validation (Phase 8)

Applied fix.diff to in-guest `/usr/src` (`patch -p1`), rebuilt only the
`hpfs.ko` KLD module (`make` in `/usr/src/sys/vfs/hpfs`), installed to
`/boot/kernel/hpfs.ko` (sha256 6ab83db0d69ad8fca933ed6baaaa46f7d41db46cb63260192ab4dccffa89a9ec), rebooted, re-mounted the same
forged-busycnt=255 image, re-ran `cat /mnt/FILE`:

| Kernel / module            | cat /mnt/FILE                | dmesg                                | guest  |
|----------------------------|------------------------------|--------------------------------------|--------|
| #0 + unpatched hpfs.ko     | **HANG → kernel panic**      | `panic: bgetvp - overlapping buffer` | DOWN   |
| #0 + PATCHED hpfs.ko       | `EINVAL: Invalid argument`   | `hpfs_hpbmap: forged ab_busycnt 255 > max 8` | UP |

Fix closes the bug: the forged `ab_busycnt=255` is now rejected at the
dive-point bounds check, no OOB walk occurs, no garbage disk offset is
returned, and `cat` returns EINVAL cleanly with the guest remaining up.
The fix is minimal (one logical change: bounds-check `ab_busycnt` against
the container max before the loops) and targeted at the confirmed root
cause.

## PoC changes

The runner created the entire evidence pack from scratch (the finding
folder did not exist).  Files authored:
- `README.md` — finding summary, build/run, expected vs fixed behaviour
- `harness.c` — deterministic OOB-proof harness (faithful loop transcription + poisoned allocator + fixed-mode control)
- `craft_img.py` — HPFS image crafter (SuperBlock/SpareBlock/bitmap/dirblk/fnode with forged ab_busycnt)
- `df857.img` — crafted 64 KB HPFS image (forged busycnt=255)
- `build.sh` / `run.sh` — exact repro scripts
- `fix.diff` — git-apply-able fix (validate ab_busycnt at dive-point)
- `panic.txt`, `run.log`, `run.1/2/3.log`, `build.log`, `fix_build.log`, `fix_run.log` — full untrimmed logs
- `env.txt` — guest environment
- `manifest.json` — artifact catalog
- `VERDICT.md` — this file
