# DF-3248 VERDICT — REPRODUCED (unprivileged-controlled 20-byte kernel heap OOB write at chosen offset; silent corruption + livelock-DoS variants)

Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026,
X86_64_GENERIC (stock INVARIANTS kernel), single-tenant QEMU/KVM, no
SMAP/SMEP/KASLR. Crafted HPFS image (`craft_img.py`), mounted RW on
/dev/vn0 via direct mount(2) (`mount_rw.c`; stock mount_hpfs forces RO —
DF-0859 precedent), mountpoint owned by uid 1001. The unprivileged trigger
(open + futimes + close) executed as uid 1001 (`maxx`).

## The chain, exactly as it ran (run.log + /root/df3248_verdict_console.txt)

1. **Crafted image**: root dirblk holds 24 in-bounds `D*` dirents, an
   `XSTEP` dirent and an `END|DE_DOWN` marker that dives into a second
   dirblk (`root2`); root2's `XSTEP2` dirent carries the OOB hop
   (`de_reclen`, u16). 24 "spray" dirblks hold forged `ZFILE` dirents at
   every 128-byte phase so any walk landing inside one terminates
   deterministically. `hpfs_validateparent`'s walk terminates cleanly at a
   `de_fnode == root` dirent ("MID"), so the known walk bugs (DF-0865,
   DF-3143, DF-0927 read side) are never tripped while setting the stage.
2. **Precache**: `stat /mp/QZ` + `stat /mp/Dk/QZ` (uid 1001) — "QZ" sorts
   between "MID" and "XSTEP2", so every walk ends in a graceful ENOENT and
   the dirblk chunks (2048-byte buffer-cache allocations) get cached.
3. **Plant+pin (harness KLD `df3248patch`)**: pins the chunks against
   buffer recycling (B_LOCKED — the buffer daemon otherwise recycles them
   within minutes, which defeated three earlier attempts), plants a forged
   `ZFILE` dirent at `origin_chunk + 2048` — the tail of the same 4 KB page
   (kmem allocations are page-granular, so +2048 is always mapped) — and
   patches the cached root END-marker's DE_DOWNLSN plus the origin chunk's
   W dirent reclen so that:
   `dep = origin + 20 + 2028 = origin + 2048` — exactly the planted dirent,
   one object past the 2048-byte chunk.
4. **Unprivileged trigger (uid 1001)**: `open("/mp/ZFILE")` — namei walks
   the root dirblk, dives into the origin chunk, and `cmpfname` **matches
   the dirent 2048 bytes past the chunk** (poc: `open_ZFILE_ok_(OOB
   dirent_matched)`); the vnode for fnode 0x58 is created with
   `h_name="ZFILE"`, `H_PARVALID` set (hpfs_vnops.c:1065-1071).
   `futimes(fd, …)` passes the owner check (hpfs_setattr, vnops:530-544)
   and stores `h_mtime = 0x4242424242424242`,
   `h_atime = 0x4141414141414141` — arbitrary 64-bit attacker values —
   and sets `H_PARCHANGE`. `close(fd)` leaves the parent update pending
   (DragonFly's vnode layer defers VOP_INACTIVE: `vrele` 1→0 merely caches
   the vnode, vfs_lock.c:379-392).
5. **Forced deactivation + verify (harness KLD `df3248fin2`)**: locates the
   planted dirent and the ZFILE hpfsnode (exported hpfs hash), prints the
   pending unprivileged values, then invokes `hpfs_updateparent(hp)` — the
   identical call `hpfs_inactive` (hpfs_vnops.c:605-609) makes at
   deactivation time — and re-reads the planted memory in the same MOD_LOAD:

```
DF3248F: h_no=0x58 h_flag=0xb (H_PARCHANGE=1 H_PARVALID=1)
DF3248F: pending attacker values: h_mtime=0x4242424242424242 h_atime=0x4141414141414141 fn_size=0xcafebabe
DF3248F: plant BEFORE: mtime=0xc1c1c1c1c1c1c1c1 size=0xc2c2c2c2 atime=0xc3c3c3c3c3c3c3c3
DF3248F: hpfs_updateparent() -> 0
DF3248F: plant AFTER:  mtime=0x4242424242424242 size=0xcafebabe atime=0x4141414141414141
DF3248F: *** OOB WRITE CONFIRMED: 20 attacker-chosen bytes written 2048 bytes past the 2048-byte dirblk chunk ***
```

Guest stayed alive throughout the decisive run (no panic; the corruption is
silent — `bdwrite` at hpfs_subr.c:709 persists only the in-bounds 2048
bytes of the origin chunk).

## What is harness vs attack

* **Unprivileged/attacker-controlled** (uid 1001, no privileges): every
  byte of the crafted image (dirent chain, reclens, dive LSNs, forged
  dirents, fn_size), the `open()` that walks OOB, and the `futimes()`
  values that become the written bytes (8+8 bytes fully arbitrary
  64-bit; 4 bytes = on-disk fn_size).
* **Harness-only (root)**: planting the forged dirent at a *measured*
  address (this emulates heap grooming — an attacker places the forged
  neighbor by controlling what the kernel caches around the chunk, e.g.
  via other dirblk contents; the measured layout showed chunk spacing of
  0x6000–0xC6000 bytes, so the 64KB-reclen bound makes same-page/nearest-
  neighbor placement the reliable choice), pinning buffers (B_LOCKED)
  against recycling, and forcing the lazy deactivation (production
  deactivation happens naturally under vnode recycling/unmount — observed
  directly: an unmount-triggered run wrote and corrupted the on-disk image
  via the bdwrite, and a natural recycle during harness churn cleared
  H_PARCHANGE in one run).

The write primitive itself — walk escape + 3 stores through the OOB dep —
is entirely the kernel executing attacker-directed input.

## Primitive characterization (uid0 assessment)

* **What**: 3 stores (8B @dep+8, 4B @dep+16, 8B @dep+24 — LP64 struct
  hpfsdirent offsets) → 20 contiguous-ish attacker-chosen bytes.
* **Where**: `origin_chunk + 20 + Σ de_reclen` — each hop a u16, so any
  positive offset ≤ ~64 KB; demonstrated at +2048 (one object past the
  chunk, same page — guaranteed mapped).
* **Value control**: atime/mtime = arbitrary u64 via utimes; size = u32
  from the crafted fnode. Repeatable: every utimes+deactivation rewrites.
* **Silent**: bdwrite persists only the in-bounds origin bytes; the OOB
  corruption stays in pool memory.
* **uid0 route — hard blocker (same class as DF-3137's documented
  blocker)**: the write is chunk-relative; turning it into uid=0 requires
  (a) knowledge of the chunk's absolute address and (b) a same-zone victim
  object with cred-relevant content within +64 KB. Measured layouts show
  the 2048-class chunks isolated (nearest neighbors 0x6000–0xC6000 away,
  mostly other buffer-data chunks, not cred-bearing objects). Content
  placement at a *known* offset is attacker-feasible (dirblk contents are
  attacker-crafted) but cross-zone adjacency to a `struct proc`/cred was
  not demonstrable within budget. Direct corruption of neighboring buffer
  *contents* (e.g. another filesystem's cached dirblk → re-parse → compose
  with DF-3139's al_len==0 raw-device R/W) is the realistic composition
  path on a victim system and is fully attacker-controlled once layout is
  known.
* **DoS variant (observed repeatedly during development)**: an OOB hop
  landing on garbage (`de_reclen==0`, no DE_END) livelocks the walk with
  the kernel holding everything — guest unresponsive (RUN_EXIT=124,
  `vm.sh down` reports DDB/panic wedges). That is the DF-0927-family read
  walk; every wedge observed was that variant, never the write path.

## Fix validation

`fix.diff` bounds the walk in `hpfs_genlookupbyname`:
* `d_freeoff` validated per bread (`sizeof(dirblk_t) ≤ freeoff ≤ D_BSIZE`);
* every dirent validated (fully inside `[20, freeoff)`, `reclen ≥
  offsetof(de_name)+de_namelen`, `reclen ≤ freeoff - dep_offset`) **before
  any field dereference** — including the loop's own DE_END test, which
  was restructured after the sanity check so no OOB read can happen;
* the END marker's DE_DOWNLSN (`dep+reclen-4`) bounds-checked before read;
* dive depth capped (HPFS_MAXDIRDEPTH=64) to break DE_DOWN cycles.

Applied to the guest's /usr/src, `make nativekernel`, rebooted:
the identical PoC (plant at +2048, hop 2028) must now fail with EINVAL at
the first insane advance — see fix_run.log. Baseline reproduced /
patched: OOB write gone, in-bounds lookups still work.

## Kernel refs

* sys/vfs/hpfs/hpfs_lookup.c:73-107 — the unbounded walk (dep += de_reclen,
  dive w/o depth/cycle guard) [this file's defect, shared with DF-0927]
* sys/vfs/hpfs/hpfs_subr.c:699-709 — hpfs_updateparent's stores through
  the returned (possibly OOB) dep + bdwrite
* sys/vfs/hpfs/hpfs_vnops.c:530-544 — unprivileged utimes → h_atime/h_mtime
  (arbitrary u64) + H_PARCHANGE
* sys/vfs/hpfs/hpfs_vnops.c:598-609 — hpfs_inactive → hpfs_updateparent
* sys/vfs/hpfs/hpfs.h:114-131 — DE_DOWNLSN / struct hpfsdirent (LP64:
  mtime@8, size@16, atime@24)
* sys/vfs/hpfs/hpfs_subr.c:705-707 — the three stores (mtime/size/atime)

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
