DF-2647 / verdict.json
{ "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" } |