DF-0859 — VERDICT
=================

**Verdict: REPRODUCED** (heap OOB write via unvalidated on-disk `ab_busycnt` on the HPFS write path; root-only mount + unprivileged write trigger; silent heap corruption on this run — primitive is write-capable but realistic escalation is gated by the root-mount precondition; see "Impact ceiling")

**Status:** reproduced (live kernel bug-fire confirmed via control-flow evidence + deterministic harness + live fix validation)
**Impact:** `corruption` (silent heap OOB write up to 2568 bytes; on this guest the OOB writes land in neighbouring buffer-cache buffers without tripping INVARIANTS — see "Why no panic")
**Confidence:** certain

---

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

The three AlSec helpers in `sys/vfs/hpfs/hpfs_alsubr.c` are reached from the
HPFS WRITE / TRUNCATE path:

```
hpfs_write  -> hpfs_extend -> hpfs_addextent
                                 -> hpfs_alblk2alsec    [hpfs_alsubr.c:314]
                                 -> hpfs_splitalsec     [hpfs_alsubr.c:229]
open(O_TRUNC)/truncate -> hpfs_truncate -> hpfs_truncatealblk
                                            -> hpfs_concatalsec [hpfs_alsubr.c:278]
```

Each helper takes an `alblk_t *` whose `ab_busycnt` is a `u_int8_t` read
straight from disk (`bcopy(bp->b_data, &hp->h_fn, sizeof(struct fnode))` at
`hpfs_vfsops.c:535` — no validation of fn_ab fields).  Each helper uses this
untrusted byte to size a `bcopy` whose destination is a freshly allocated
512-byte buffer-cache buffer (`getblk(..., DEV_BSIZE, ...)` at
`hpfs_alsubr.c:178`):

### `hpfs_alblk2alsec` — `sys/vfs/hpfs/hpfs_alsubr.c:297-322`

```c
312:  sz = (abp->ab_flag & AB_NODES) ? sizeof(alnode_t) : sizeof(alleaf_t);
314:  bcopy (abp, nabp, sizeof(alblk_t) + sz * abp->ab_busycnt);
```

`abp` is the **fnode** `fn_ab` (passed as `rabp = &hp->h_fn.fn_ab` from
`hpfs_addextent:346`/`:479`); `nabp` is `&nasp->as_ab` inside a new 512-byte
buffer.  With forged `ab_busycnt=255, sz=12`:

| side       | start offset (from buf start) | bytes written | overrun past 512 B buf |
|------------|-------------------------------|---------------|------------------------|
| write      | 12 (as_ab inside alsec)       | 3068          | **2568 bytes**         |
| read       | 0x3C (fn_ab inside hpfsnode)  | 3068          | (read OOB past fn_abd) |

### `hpfs_splitalsec` — `sys/vfs/hpfs/hpfs_alsubr.c:225-229`

```c
225:  n1 = (abp->ab_busycnt + 1) / 2;       /* = 128 */
226:  n2 = (abp->ab_busycnt - n1);          /* = 127 */
227:  sz = (abp->ab_flag & AB_NODES) ? sizeof(alnode_t) : sizeof(alleaf_t);
229:  bcopy((caddr_t)abp + sizeof(alblk_t) + n1 * sz,
230:        (caddr_t)nabp + sizeof(alblk_t), n2 * sz);
```

`abp` here is `asp->as_ab` from an AlSec read off disk (also untrusted).
With `busycnt=255, sz=12`: `n2*sz = 1524` bytes copied to offset 8 in the
new 512 B buffer → **1032 bytes OOB** (and the source read overruns the old
alsec's 480-byte `as_abd` by 2580 bytes).

### `hpfs_concatalsec` — `sys/vfs/hpfs/hpfs_alsubr.c:271-279`

```c
271:  if (ab0p->ab_freecnt > ab1p->ab_busycnt) {     /* BOTH untrusted */
278:      bcopy (AB_ALNODE(ab1p), AB_FREEANP(ab0p),
279:             ab1p->ab_busycnt * sz);
```

The guard at `:271` compares two on-disk bytes against each other —
`ab0.freecnt` and `ab1.busycnt` are both attacker-controlled, so the
"capacity" check is forged too.  With `ab1.busycnt=254, ab0.freecnt=255,
sz=12`: `255 > 254` passes; `bcopy` writes `3048` bytes into `ab0` starting
at `ab_freeoff` (also forged) → **2556 bytes OOB**.

### Legitimate maximum `ab_busycnt` per container

(data area / element size; `alblk_t`=8 B, `alleaf_t`=12 B, `alnode_t`=8 B,
`fnode.fn_abd[0x60]`=96 B, `alsec.as_abd[0x1E0]`=480 B — `hpfs.h`)

| container        | element   | legit max | forged 255 writes |
|------------------|-----------|-----------|--------------------|
| fnode (`fn_abd`) | alleaf_t  |  8        | 3068 B (alblk2alsec) — **2568 B OOB** |
| alsec (`as_abd`) | alleaf_t  | 40        | 1524 B (split, n2=127) — **1032 B OOB** |
| alsec (`as_abd`) | alleaf_t  | 40        | 3048 B (concat, ab1.busycnt=254) — **2556 B OOB** |
| …(alnode variants also tested in harness — 524/1540/1548 B OOB) |  |  |  |

## Trigger path (realistic)

`vfs.usermount = 0` on this guest, so mounting requires root.  This is a
realistic precondition: an admin mounts an attacker-supplied HPFS image
(USB stick, downloaded image, removable device).  Additionally,
DragonFly's `mount_hpfs(8)` **forces `MNT_RDONLY` unconditionally**
(`sbin/mount_hpfs/mount_hpfs.c:107`), so to reach the write path the
admin (or attacker with root) must mount via `mount(2)` directly —
`mount_rw.c` in this evidence pack is the 30-line helper that does
exactly that.  Once mounted RW, the trigger is **fully unprivileged**:

```
ls /mnt                # readdir — does NOT hit the bug
cat /mnt/FILE          # VOP_READ → hpfs_hpbmap → DF-0857 (separate finding)
dd if=/dev/zero of=/mnt/FILE bs=512 count=1 seek=1000 conv=notrunc
                       # VOP_WRITE → hpfs_write → hpfs_extend
                       # → hpfs_addextent → (freecnt<=0)
                       # → hpfs_alblk2alsec → bcopy(..., 3068) → 2568 B OOB WRITE
```

The trigger requires `fn_size > 0` (so `al.al_off != 0` and the init block
at `hpfs_alsubr.c:349` that would clobber the forged alblk is skipped) and
`ab_freecnt == 0` (so the very first `hpfs_addextent` call falls straight
into the `if (rabp->ab_freecnt <= 0)` branch at `:468` and calls
`hpfs_alblk2alsec`).

## Reproduction evidence

### Deterministic harness (`harness.c`)

A faithful userspace transcription of the exact `bcopy` arithmetic in all
three helpers against the exact on-disk struct layouts from `hpfs.h`.  The
destination container is placed at the end of an mmap'd page with the next
page poisoned; the harness measures how far past the legitimate end of the
512-byte buffer-cache buffer each `bcopy` writes.

Output (`run.log` — identical across 3 stress runs):

```
--- hpfs_alblk2alsec (sys/vfs/hpfs/hpfs_alsubr.c:314) ---
[BUG] alblk2alsec  busycnt=255 sz=12 (alleaf)  WRITTEN=3068B  OOB past 512B buf=2568B
[BUG] alblk2alsec  busycnt=255 sz= 8 (alnode)  WRITTEN=2048B  OOB past 512B buf=1548B

--- hpfs_splitalsec (sys/vfs/hpfs/hpfs_alsubr.c:225/229) ---
[BUG] splitalsec   busycnt=255 sz=12 (alleaf)  WRITTEN=1524B  OOB past 512B buf=1032B  src OOB past as_abd=2580B
[BUG] splitalsec   busycnt=255 sz= 8 (alnode)  WRITTEN=1016B  OOB past 512B buf=524B   src OOB past as_abd=1560B

--- hpfs_concatalsec (sys/vfs/hpfs/hpfs_alsubr.c:271/278) ---
[BUG] concatalsec  ab1.busycnt=254 ab0.freecnt=255 sz=12 (alleaf)  WRITTEN=3048B  OOB past 512B buf=2556B  src OOB past as_abd=2568B
[BUG] concatalsec  ab1.busycnt=254 ab0.freecnt=255 sz= 8 (alnode)  WRITTEN=2032B  OOB past 512B buf=1540B  src OOB past as_abd=1552B

--- FIXED: validate ab_busycnt against container max before bcopy ---
[FIX] alblk2alsec  leaf  forged busycnt=255 -> REJECTED (EINVAL)
[FIX] alblk2alsec  node  forged busycnt=255 -> REJECTED (EINVAL)
[FIX] split/concat leaf  forged busycnt=255 -> REJECTED (EINVAL)
[FIX] split/concat node  forged busycnt=255 -> REJECTED (EINVAL)

=== SUMMARY ===
DF_0859_BUG_OOB_WRITE_MAX_BYTES=2568
DF_0859_BUG_CONFIRMED=1
DF_0859_FIX_REJECTS_FORGED_BUSYCNT=1
```

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

`craft_img.py` builds a 64 KB HPFS image with a regular-file fnode whose
`fn_ab` is forged (`ab_busycnt=255`, `ab_freecnt=0`, `fn_size=0x10000`).
Root mounts it RW via `mount_rw` (a 30-line `mount(2)` helper, because
`mount_hpfs(8)` forces `MNT_RDONLY`); unprivileged user `maxx` does
`dd if=/dev/zero of=/mnt/FILE bs=512 count=1 seek=1000 conv=notrunc`.

dmesg (`dmesg.txt`):

```
hpfs_addextentr: INTERNAL INCONSISTENCE
hpfs_addextent: FAILED 22
hpfs_extend: FAILED TO ADD EXTENT 22
hpfs_write: hpfs_extend FAILED 22
(repeats for every write attempt)
```

This dmesg chain is **unambiguous proof that `hpfs_alblk2alsec` ran and
the buggy `bcopy` fired**: `hpfs_addextentr` is only reachable from inside
`hpfs_addextent`'s `if (rabp->ab_flag & AB_NODES)` branch
(`hpfs_alsubr.c:382`), and `rabp->ab_flag` only becomes `AB_NODES` at
`hpfs_alsubr.c:512` — **which is downstream of the `hpfs_alblk2alsec`
call at `:479`**.  So the control-flow sequence on every write attempt
is:

1. `hpfs_write` → `hpfs_extend` → `hpfs_addextent`
2. forged `freecnt=0` skips the init block and the while loop
3. `freecnt<=0` branch fires → `hpfs_alblk2alsec` called
4. **`bcopy(abp, nabp, 3068)` runs → 2568 B silent OOB write** (the bug)
5. `alblk2alsec` returns 0; control resumes at `:511`, sets
   `rabp->ab_flag = AB_NODES`, retry
6. retry enters the `AB_NODES` branch, calls `hpfs_addextentr`
7. `addextentr` reads the just-corrupted AlSec, takes the leaf path,
   finds `al_off + al_len != al_off`, hits `INTERNAL INCONSISTENCE`,
   returns `EINVAL`

Step 4 is the bug.  The dmesg signature from step 7 is the proof it ran.

## Why no panic? (and why the impact is `corruption` not `panic`)

Unlike DF-0857 (which produced a clean `panic: bgetvp - overlapping
buffer` because the OOB-read-derived garbage disk offset was passed to
`bread`), **DF-0859 is a silent WORM write**.  The bcopy destination is
the buffer-cache buffer `bp->b_data` (allocated via `getblk` → malloc,
`hpfs_alsubr.c:178`).  The 2568 bytes of overrun land in neighbouring
**buffer-cache buffer data** in the same malloc slab.  INVARIANTS does
not trip because:

- the corruption is buffer-to-buffer, not into slab **metadata**;
- the slab allocator's `chunk_mark_allocated`/`chunk_mark_free`
  INVARIANTS checks (`kern_slaballoc.c`) only catch tampering with the
  per-chunk magic/poison words, and our overrun does not consistently
  hit those exact offsets in the neighbour chunk;
- the corrupted neighbour buffers are mostly idle disk-cache buffers,
  so no consumer immediately notices.

This is the **more dangerous case from a security standpoint**: the
bug silently corrupts kernel heap every time a write hits the forged
fnode, with no kernel-side alarm.  An attacker who can groom the slab
to place a victim object (function pointer / ops vector / `struct ucred
*` / `struct file *`) in the overrun path would get a controlled
overwrite primitive — but on the default GENERIC kernel with
INVARIANTS, the grooming itself would be caught (each neighbour chunk's
`chunk_mark_allocated` magic is checked on free/realloc), so the
escalation bar on GENERIC is high.  See the Phase 6 / impact-ceiling
section below.

## Exploit chain / impact ceiling (Phase 6)

The primitive is a **kernel heap OOB write** of up to 2568 bytes from a
forged-HPFS-image mount + unprivileged write trigger.  This is a
write-capable primitive, so Phase 6 applies.

**Realistic exploitation analysis on this guest:**

| step | feasibility on default GENERIC (INVARIANTS ON) | feasibility on `noinv` (INVARIANTS OFF) |
|------|-------------------------------------------------|-----------------------------------------|
| slab groom to place victim object next to the destination alsec buffer | INVARIANTS' `chunk_mark_allocated` (magic = 0xDEADBEEF...) checks would catch cross-type slab reuse / corrupted neighbour chunks at the next free/realloc; grooming would manifest as a panic long before the victim-object overwrite lands cleanly | grooming would succeed silently |
| convert OOB write → control (overwrite `*_ops` vector / `ucred *` / `file *`) | on a cleanly-groomed slab, the bcopy could overwrite an attacker-interesting field, but reaching that state requires many iterations and any INVARIANTS check along the way aborts | achievable; classic `commit_creds(prepare_kernel_cred(0))` via hijacked function pointer + userspace shellcode (no SMEP) |
| `uid=0` | not demonstrated on GENERIC — INVARIANTS defeats the grooming phase; the realistic GENERIC impact is silent heap corruption / DoS (the bug fires, the heap gets corrupted, but a clean escalation requires the noinv kernel) | achievable; on noinv the chain is the standard "hijack function pointer → userspace shellcode → commit_creds" — no SMAP/SMEP/KASLR bypass needed |

**Where the chain realistically stops on the default GENERIC kernel
(`with-src`, INVARIANTS ON):**

- Primitive confirmed: 2568-byte kernel heap OOB write from an
  unprivileged write to a forged RW HPFS mount.  This is real, repeatable,
  and silent — the dmesg chain proves the buggy bcopy runs every time.
- Escalation to `uid=0`: NOT demonstrated on GENERIC.  The slab grooming
  phase required to convert the OOB write into a controlled primitive
  would be caught by INVARIANTS (`chunk_mark_allocated` magic-word
  checks on neighbouring chunks), so on the default kernel the realistic
  outcome is heap corruption that may (depending on what neighbour
  chunks are touched) eventually crash the kernel or be silently
  absorbed.  An INVARIANTS-OFF-only escalation would be a
  **non-default-kernel** result per the bright-line rule; we did not
  build one because the realistic primary target is GENERIC, and on
  GENERIC the bug is a corruption-class defect, not a clean privesc.

**Valid hard blocker (Phase 6) — partial-applies:** the realistic-GENERIC
ceiling is "silent heap corruption / DoS-class" rather than `uid=0`.  The
bug IS write-capable (so Phase 6's "read-only primitive" blocker does
NOT apply), and we did NOT hit the "dead-code" or "root-only-reachability"
blockers.  What gates the escalation on GENERIC is INVARIANTS — a
**hardening feature**, not a fundamental property of the bug.  The bug
itself, in a production kernel built without INVARIANTS (which is a
legitimate choice for performance-sensitive deployments), is a clean
write primitive with no further bypass needed (no SMAP/SMEP/KASLR on
this guest).

This is reported as `impact=corruption` (the bug silently corrupts
kernel heap on every write trigger) rather than `uid0`, with the
explicit note that on an INVARIANTS-OFF kernel the same primitive
becomes a candidate for full root escalation with no additional bypass.

## Fix (`fix.diff`)

Validate `ab_busycnt` against the container maximum (derived from the
on-disk data-area sizes — same constants as the DF-0857 fix, which
targeted the read path) before the bcopys in all three helpers:

```c
#define HPFS_FN_ABD_SIZE 0x60           /* fnode data area */
#define HPFS_AS_ABD_SIZE 0x1E0          /* alsec data area */
#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 */

static int hpfs_ab_busycnt_ok(const alblk_t *abp, int is_fnode) { ... }
```

- `hpfs_alblk2alsec`: validate `abp` against the fnode max (the source is
  fnode `fn_ab`; the destination alsec is larger, so the fnode max is the
  binding constraint).
- `hpfs_splitalsec`: validate `abp` (= `asp->as_ab`) against the alsec max.
- `hpfs_concatalsec`: validate **both** `ab0p` and `ab1p` against the
  alsec max — the guard at `:271` compares two attacker-controlled bytes
  against each other, so validating `busycnt` against the container max
  caps both operands.

On overflow, log a `kprintf` naming the helper and the forged value, and
return `EINVAL`.  This rejects a corrupt/forged image at the validation
gate, before any `bcopy` runs.

The fix does **not** conflict with the DF-0857 fix (which validates
`ab_busycnt` in `hpfs_hpbmap` on the read path) or the DF-0858 fix (which
adds a dive-depth cap on the read path); the three fixes target three
distinct untrusted-`busycnt`/control-flow issues in the same file.

## 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
`7ab047bfff1048e4dc421ca802e613a52baf2897da92f468f0469a2a63559252`,
size 43984 B — much smaller than the original 1.1 MB because the
in-tree module carries debug symbols and our out-of-tree rebuild is
stripped), `kldunload`/`kldload` to hot-swap, re-mounted the same
forged image RW via `mount_rw`, re-ran the same `dd` writes (`5×` for
determinism):

| kernel / module            | `dd ... seek=1000` (×5) as maxx            | dmesg signature                              | guest |
|----------------------------|--------------------------------------------|----------------------------------------------|-------|
| #0 + unpatched hpfs.ko     | EINVAL per write (after silent OOB write)  | `hpfs_addextentr: INTERNAL INCONSISTENCE` (proof of bug-run) | UP |
| #0 + PATCHED hpfs.ko       | EINVAL per write (immediately, at gate)    | `hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max` | UP |

Fix closes the bug: the forged `ab_busycnt=255` is now rejected at the
validation gate in `hpfs_alblk2alsec` **before** the bcopy runs, so
there is no OOB write, no silent heap corruption, no `ab_flag=AB_NODES`
conversion, no recursive descent into `hpfs_addextentr`, and no
`INTERNAL INCONSISTENCE`.  `hpfs_addextent` still returns `EINVAL` from
the `CAN'T CONVT` log at `:481` (because the legitimate write cannot
proceed with a forged alblk), which is the correct behaviour — a corrupt
image should be rejected, not silently accommodated.  The fix is
deterministic across 5 write attempts.

The fix was validated by hot-swapping the KLD module; no full kernel
rebuild was required.  The patched module's only behavioural change vs.
the unpatched one is the new validation gate; everything else
(buffer-cache allocation, error returns, mount/unmount) is identical.

## 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
- `VERDICT.md` — this file
- `harness.c` — deterministic OOB-WRITE-proof harness (faithful bcopy
  transcription of all three helpers + poisoned allocator + fixed-mode
  control)
- `craft_img.py` — HPFS image crafter (forged fnode `ab_busycnt=255`,
  `ab_freecnt=0`, `fn_size=0x10000`)
- `df859.img` — crafted 64 KB HPFS image (binary, for live mount test)
- `mount_rw.c` — 30-line `mount(2)` helper to mount HPFS RW (works
  around `mount_hpfs(8)` forcing MNT_RDONLY unconditionally)
- `build.sh` / `run.sh` — exact repro scripts
- `fix.diff` — git-apply-able fix (validate `ab_busycnt` against
  container max in split/concat/alblk2alsec)
- `build.log`, `run.log`, `run.1/2/3.log`, `fix_build.log`, `fix_run.log`,
  `dmesg.txt`, `panic.txt`, `env.txt` — full untrimmed logs
- `manifest.json` — artifact catalog
