# DF-2647 VERDICT — on-media name_len heap OOB write in hammer2_ioctl_pfs_get

## Bottom line

**REPRODUCED.** `hammer2_ioctl_pfs_get()` (HAMMER2IOC_PFS_GET, the ioctl
behind `hammer2 pfs-list`) trusts the on-media `meta.name_len` — a
**uint16_t** (`sys/vfs/hammer2/hammer2_disk.h:957`) — with no bound check
other than a `KKASSERT` (`sys/vfs/hammer2/hammer2_ioctl.c:494`), which
compiles out on production (non-INVARIANTS) kernels:

```c
494:		KKASSERT(ripdata->meta.name_len < sizeof(pfs->name));
495:		bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len);
496:		pfs->name[ripdata->meta.name_len] = 0;
```

`pfs->name` is 256 bytes (`hammer2_ioctl.h:103`) inside a 320-byte
`M_IOCTLOPS` kmalloc (`sys/kern/sys_generic.c:674-676`), so a crafted image
with `name_len >= 256` yields a **kernel heap OOB write of up to 65279
bytes** past `pfs->name[]` whose content is the PFS inode block plus the
rest of its 64KB media window — i.e. fully attacker-controlled when the
inode is placed low in the window (the forger stamps `DF2647!!`).

## Observed evidence (all runs captured in this pack)

1. **Stock INVARIANTS guest kernel** (X86_64_GENERIC, INVARIANTS on):
   forged image (`name_len=0x300`), mount, one PFS_GET scan →
   `panic: assertion "ripdata->meta.name_len < sizeof(pfs->name)" failed in
   hammer2_ioctl_pfs_get at /usr/src/sys/vfs/hammer2/hammer2_ioctl.c:494`
   with trace `syscall2 → mapped_ioctl → vn_ioctl → vop_ioctl →
   hammer2_ioctl` (`panic_inv.txt`). Deterministic; proves the attacker
   controls the length that reaches the site.

2. **Non-INVARIANTS kernel** (config `DF2647_NOINV` = X86_64_GENERIC minus
   `options INVARIANTS` + minus `DEBUG=-g`; assertion strings absent from
   the built kernel — verified via `strings`):
   * `name_len=0x300`, 40 scan iterations → scans complete **silently**,
     guest stays up, clean unmount (`run_surgical` output in run logs) —
     the write happens without immediate symptoms.
   * `name_len=0x300`, ~65+ scan iterations (first no-INV run) →
     `Fatal trap 9: general protection fault in kernel mode`, `Stopped at
     _kmalloc+0x4b4: movq (%r15),%rax` (`panic_noinv.txt`) — the smeared
     512-zone freelist metadata detonates in the allocator. The smear was
     the only mutating operation in that run.
   * `name_len=0xFFFF`, single scan → kernel stops **inside the copy
     itself**: `Stopped at memmove+0x10a: repe movsq (%rsi),%es:(%rdi)`
     (`panic_ffff_noinv.txt`) — the 65535-byte bcopy wrote past the end of
     the mapped heap region mid-instruction. In-context proof of the
     write primitive at full attacker-chosen length.

3. **Fix validation** (`fix.diff`: real bound check replacing the
   KKASSERT, clamps to 255 + kprintf): rebuilt no-INV kernel →
   `dmesg: hammer2: pfs_get: corrupt name_len 65535, clamped`; 100×
   0xFFFF scans + 0x300 run → **no crash, guest alive, clean unmount**
   (`fix_run.log`). Baseline vs patched behavior is exactly inverted.

## Exploit chain (honest boundary)

* The ioctl is root-gated (`caps_priv_check(SYSCAP_NOVFS_IOCTL)`,
  `hammer2_ioctl.c:117-120`) and mounting is root-gated on this guest
  (`vfs.usermount=0`), so this is **not** an unprivileged uid=0 path.
* Threat model: hostile hammer2 image inspected/administered by root
  (`hammer2 pfs-list`, backup/forensics tooling). Impact ceiling is
  ring-0 code execution on production kernels: the primitive is a linear
  kernel-heap overwrite with attacker-chosen length (≤65279 B) and
  attacker-chosen content (media window bytes), repeatable at will (each
  scan iteration re-fires it). On this permissive guest (no SMAP/SMEP/
  KASLR) converting it to a controlled RIP needs only heap grooming to
  place a function-pointer-bearing 257–512-byte allocation above the ioctl
  buffer; not executed here because the trigger path is already
  privileged — the escalation would only demonstrate root→ring0, not
  user→root.
* Cross-object-write demonstrations attempted (concurrent PFS_LOOKUP
  victim loops; 120 parked PFS_SNAPSHOT buffers — same 512-byte malloc
  zone, parked seconds on `hmp->bulklk`): no marker round-trip observed —
  DragonFly's per-CPU kmalloc magazines + LIFO block reuse + the smear's
  upward-only direction defeat easy adjacency from userland. The
  allocator GPF (2nd bullet above) stands as the cross-object corruption
  proof (free-block linkage overwritten by our media bytes).

## PoC changes vs the seed sketch

Seed was a two-line idea; everything here is new tooling: guest base-image
builder (newfs + `hammer2 pfs-create zz_pwn`), host-side forger walking
volhdr→sroot→PFS inodes (reusing DF-2616's CHECK_NONE-ancestor + volhdr
CRC32C machinery), C trigger/victim programs, two custom kernel builds
(non-INV baseline, non-INV+fix). Forge iteration learned: marker stamps
must stop at the next metadata block sharing the 64KB window (first 0xFFFF
forge stamped across the sroot block at 0x1800400 and mount itself read
`'F'` (70) as a blockref type — an unrelated mount-time parse failure,
fixed by capping the stamp range).

## Kernel references

* `sys/vfs/hammer2/hammer2_ioctl.c:487-497` — the vulnerable copy
* `sys/vfs/hammer2/hammer2_ioctl.h:92-106` — hammer2_ioc_pfs layout
  (name[NAME_MAX+1] = 256, last member, struct = 320 B)
* `sys/vfs/hammer2/hammer2_disk.h:957` — name_len is uint16_t (media)
* `sys/vfs/hammer2/hammer2_disk.h:1012` — filename[256] @ inode+0x100
* `sys/kern/sys_generic.c:668-697` — kmalloc'd M_IOCTLOPS ioctl buffer
* `sys/vfs/hammer2/hammer2_vfsops.c:495` — mount tolerates hostile
  name_len (kstrdup only needs the embedded NUL we preserve)

## Reproduce

See README.md. Guest left in clean state (`vm.sh reset with-src`).
