{
  "finding_id": "DF-2647",
  "status": "reproduced",
  "reproduced": 1,
  "impact": "panic",
  "confidence": "certain",
  "verdict": "hammer2_ioctl_pfs_get trusts the on-media uint16 meta.name_len with only a KKASSERT (INVARIANTS-only) as bound; on production kernels HAMMER2IOC_PFS_GET performs a kernel heap OOB write of up to 65279 bytes with attacker-chosen length and content past the 256-byte pfs->name inside a 320-byte M_IOCTLOPS kmalloc. Proven on the guest: stock INVARIANTS kernel panics deterministically at hammer2_ioctl.c:494 (full trace); rebuilt non-INVARIANTS kernel executes the smear silently (40x 0x300, guest alive), detonates the allocator (trap 9 in _kmalloc) after ~65 smears, and stops inside the copy instruction itself (memmove) on a single 0xFFFF scan. Root-gated ioctl + hostile-image mount (vfs.usermount=0) so uid=0-from-unprivileged does not apply; the ceiling is ring-0 code execution via heap grooming on production kernels. fix.diff (real bound check) validated on a rebuilt kernel: clamp fires, 100 full-length scans survive, clean unmount.",
  "exploit_chain": "crafted hammer2 image (name_len=0xFFFF on PFS 'zz_pwn', markers in filename window, CHECK_NONE ancestors, volhdr CRC32C recomputed) -> root mounts vn0@testvol -> root (or any pfs-list-style tooling) issues HAMMER2IOC_PFS_GET scan -> bcopy of 65535 attacker bytes into the 320-byte ioctl heap buffer -> linear heap overwrite (repe movsq stops only at unmapped KVA) -> allocator metadata corruption -> GPF (trap 9 _kmalloc) or, with heap grooming to place a function-pointer-bearing 257-512B allocation above the buffer and stamps replaced by gadget-bearing content, ring-0 execution on this no-SMAP/SMEP/KASLR guest; not executed to code exec because the trigger path is already privileged (root->ring0 only).",
  "evidence": [
    "panic_inv.txt: stock kernel 'panic: assertion \"ripdata->meta.name_len < sizeof(pfs->name)\" failed in hammer2_ioctl_pfs_get at .../hammer2_ioctl.c:494' with syscall2->mapped_ioctl->vn_ioctl->vop_ioctl->hammer2_ioctl trace",
    "panic_ffff_noinv.txt: non-INVARIANTS kernel stopped inside the copy - 'Stopped at memmove+0x10a: repe movsq (%rsi),%es:(%rdi)' right after mount+scan of the 0xFFFF image",
    "panic_noinv.txt: non-INVARIANTS kernel after ~65 smears of 0x300 - 'Fatal trap 9: general protection fault', 'Stopped at _kmalloc+0x4b4' (smeared freelist metadata)",
    "run.log: 40-iteration surgical run completes silently with clean unmount (controlled non-crashing corruption), 3.1M victim ioctls no false marker",
    "fix_run.log: patched kernel prints 'hammer2: pfs_get: corrupt name_len 65535, clamped', 100x 0xFFFF scans + 0x300 survive, clean unmount, FIX_VALIDATED",
    "forge_df2647.py walk output: PFS inode @0x1800000, name_len patched u16 @+0x80, read stays in the 64KB DIO window (fully controlled content)"
  ],
  "kernel_refs": [
    "sys/vfs/hammer2/hammer2_ioctl.c:494",
    "sys/vfs/hammer2/hammer2_ioctl.c:495",
    "sys/vfs/hammer2/hammer2_ioctl.c:496",
    "sys/vfs/hammer2/hammer2_ioctl.h:103",
    "sys/vfs/hammer2/hammer2_disk.h:957",
    "sys/vfs/hammer2/hammer2_disk.h:1012",
    "sys/kern/sys_generic.c:674"
  ],
  "poc_changes": "Seed was a two-line lead. Built: guest base-image script (newfs_hammer2 + hammer2 pfs-create zz_pwn), host forger (volhdr->sroot->PFS-inode walk, u16 name_len patch, DF2647!! stamping capped at the next metadata block -- first 0xFFFF forge stamped across the sroot block and mount read 'F'(70) as a blockref type, fixed by capping), C trigger pfsget_scan.c, victim programs (PFS_LOOKUP loops + 120 parked PFS_SNAPSHOT threads), two custom kernels (DF2647_NOINV baseline; +fix.diff). Hammer2(8) syntax corrected (pfs-create via -s; 'pfs-list' not 'pfs-ls').",
  "attempts": 5,
  "guest_uname": "stock: DragonFly dfbsd 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS on); builds: DF2647_NOINV #0 Sat Aug 29 22:31:29/22:47:34/23:07 UTC 2026 + fix build; all x86_64",
  "runtime_sec": 5400,
  "guest_dirty": 0,
  "build_cmd": "guest: sh mkbase2647.sh; host: python3 forge_df2647.py base2647.img h2_2647_0300.img 0x300 (and 0xffff); guest: cc -O2 -o pfsget_scan pfsget_scan.c; kernels: cd /usr/src && make -j6 nativekernel KERNCONF=DF2647_NOINV && make installkernel KERNCONF=DF2647_NOINV",
  "run_cmd": "vnconfig -c vn0 h2_2647_ffff.img && mount -t hammer2 /dev/vn0@testvol /mnt/h2 && ./pfsget_scan /mnt/h2 1",
  "code_hash": "pfsget_scan.c sha256 db8f5c5f6d587df25594e5ec41af55ae37f9396db12969b674f0251d901754e4; forge_df2647.py sha256 597057087c39e69d9058ccb2df612a8de241e364b38b64eb2ea67911f8554670 (full list in code_hashes.txt)",
  "notes": "Trust boundary recorded honestly: caps-gated ioctl + root-gated mount (vfs.usermount=0); threat model hostile-media + routine root administration. Cross-object marker round-trip not achieved from userland (per-CPU kmalloc magazines + LIFO reuse + upward-only smear); allocator GPF and in-copy stop stand as the corruption proof. Adjacent observation (not filed, vfsops.c is another file): mount itself kstrdup()s ripdata->filename (hammer2_vfsops.c:495) -- a fully-stamped filename would over-read there too; my forge keeps an embedded NUL so mount stays clean. Guest reset with-src performed; fix.diff NOT applied to the repo sys/ tree.",
  "recommended_fix": "Replace the KKASSERT with a real bound check clamping name_len to sizeof(pfs->name)-1 (see fix.diff); the same treatment fits any other on-media length feeding bcopy.",
  "fix_status": "fixed",
  "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #0 DF2647_NOINV+fix.diff x86_64 (non-INVARIANTS build, ~23:12 UTC 2026)",
  "fix_baseline_reproduced": 1,
  "fix_patched_reproduced": 0,
  "fix_verdict": "fix.diff applied cleanly in-guest (hunk @491); rebuilt non-INVARIANTS kernel: dmesg shows 'hammer2: pfs_get: corrupt name_len 65535, clamped' at the exact site; 100 iterations of the 0xFFFF scan plus a 0x300 run complete with no crash and a clean unmount, versus baseline single-scan memmove stop / _kmalloc trap 9. KKASSERT line no longer exists so the stock INVARIANTS panic is structurally removed.",
  "fix_evidence": "fix_run.log (baseline-vs-patched transcript), fix_build.log (full build), fix.diff"
}
