DF-2624 / verdict.json
{ "finding_id": "DF-2624", "status": "reproduced", "reproduced": 1, "impact": "panic", "confidence": "certain", "verdict": "CONFIRMED two ways. hammer2_pfsalloc (vfsops.c:495) kstrdup's ripdata->filename with no NUL-termination bound: the on-disk filename is a 256-byte array (disk.h:1012) inside the 1KB inode block and nothing on the ingestion path checks it (the length field meta.name_len is not consulted; hammer2_update_pmps at :1552-1571 pfsallocs EVERY PFS under the sroot on any mount, so a poison PFS need not even be the mounted label - and a NUL-less name can never match a label anyway because the :1394 scan uses strcmp). Forge: poison PFS inode relocated to the tail of a fresh 64KB window (data_off=WIN|0xFC00|10, block ending exactly at the buffer end), [0x100,0x400) filled with 0x50, mirror_tid=0, CHECK_NONE ancestors, volhdr CRCs recomputed. Observed: (a) instrumented kernel, mapped case: 'DF2624: ... kstrdup result len=772 (516 bytes past array)' - the walk read 768 in-block bytes plus 4 bytes PAST THE 64KB DIO BUFFER END and copied them into pmp->pfs_names[0]; (b) instrumented kernel, unmapped case: 'Fatal trap 12 ... panic: page fault' with backtrace 'strlen() at strlen+0x14 <- hammer2_pfsalloc() at +0x157', fault VA page-granular at the DIO buffer end - the faulting strlen is the STOCK kstrdup (the instrumentation only reports after kstrdup returns), so the panic is stock behavior under that heap layout; (c) stock kernel: mount succeeds silently (mapped case), pfs-list shows the poison PFS. Userspace observability of the overread bytes: none found - thread names truncate to MAXCOMLEN=16, pfs-list prints the on-disk name, the vol-list ioctl reads only the mounted pmp's pfs_names[0]. Impact therefore: mount-time kernel panic (layout-dependent, ~1 in 2 on this guest) + silent bounded adjacent-heap overread into a kernel string. fix.diff bounds the copy with strnlen+kmalloc+bcopy and warns; kernel #2: 3/3 mounts succeed, no panic, warning fires each time, no regression.", "exploit_chain": "root (or vfs.usermount=1 + owned vn) mounts crafted image -> hammer2_update_pmps scans sroot children -> hammer2_pfsalloc(poison PFS) -> kstrdup(ripdata->filename) with no NUL in the 256-byte array -> strlen walks the inode block (blockset filler) and off the end of the 64KB DIO buffer into adjacent kernel heap -> adjacent bytes copied into pmp->pfs_names[0] (silent overread; no userspace consumer found) OR the first past-buffer byte lies in an unmapped page -> fatal trap 12 in strlen (mount-time DoS). No write primitive, no userland disclosure demonstrated.", "evidence": [ "console_excerpts.txt [C] - instrumented: 'DF2624: pfsalloc filename has NO NUL in 256-byte array; kstrdup result len=772 (516 bytes past array)' (772 = 256 array + 512 blockset + 4 past the 64KB DIO buffer)", "console_excerpts.txt [B] - 'Fatal trap 12 ... strlen() at strlen+0x14 / hammer2_pfsalloc() at hammer2_pfsalloc+0x157', fault VA 0xfffff80055af6000 page-granular (end of the DIO buffer)", "run_stock.log - stock kernel: mount succeeds, hammer2 pfs-list shows MASTER ... PPPPP (poison PFS present, silent overread)", "console_excerpts.txt [D] - fix kernel #2: 3/3 mounts, no panic, 'hammer2_pfsalloc: PFS filename not NUL-terminated (truncated to 256)' x3", "forge_2624.py output - poison inode 0x1400400 -> 0x220fc00 (window tail), [0x100,0x400)=0x50*0x300, mirror_tid=0" ], "kernel_refs": [ "sys/vfs/hammer2/hammer2_vfsops.c:495", "sys/vfs/hammer2/hammer2_vfsops.c:465-499", "sys/vfs/hammer2/hammer2_vfsops.c:1552-1571", "sys/vfs/hammer2/hammer2_disk.h:912-916", "sys/vfs/hammer2/hammer2_disk.h:1012" ], "poc_changes": "No seed code. The seed sketch assumed the poison PFS could be mounted by label - impossible (label scan strcmps against the NUL-less name and can never match); the working trigger mounts the HEALTHY testvol PFS while hammer2_update_pmps pfsallocs the poison one during the same mount. pfs-create in-guest produced a PFS named 'LOCAL' (its cluster-role default label), used as-is. The 4 bytes actually read past the buffer (result[768..771]) were beyond the hook's 96-byte hexdump window; the length evidence (772) proves the past-buffer read.", "attempts": 2, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "runtime_sec": 900, "guest_dirty": 0, "build_cmd": "sh build.sh (base image + hammer2 pfs-create in guest; forge_2624.py on host)", "run_cmd": "vnconfig -c vn0 craft2624.img && mount -o ro -t hammer2 /dev/vn0@testvol /mnt/h2x && hammer2 pfs-list /mnt/h2x && umount /mnt/h2x && vnconfig -u vn0", "code_hash": "forge_2624.py sha256 343cba656143afc20a640eb985a1a4f95996bf50e09fd91badfb90871c3fb787; run_2624.sh sha256 3483d4ef7f606fdf48d12d05e35a8fc7e928bdc535a620b1374e356ca3a28c39", "notes": "Impact recorded as panic (demonstrated mount-time fatal trap from the stock kstrdup path, layout-dependent) with the silent 4-byte past-buffer overread as the other face; no userland disclosure of the overread bytes was found on stock (thread-name truncation at MAXCOMLEN and pfs-list's on-disk view both mask it). Real-world trigger without a crafted image: on-media corruption wiping the NUL of a PFS inode filename.", "recommended_fix": "Bound the ingestion: nlen = strnlen(ripdata->filename, HAMMER2_INODE_MAXNAME); kmalloc(nlen+1) + bcopy + warn when nlen == HAMMER2_INODE_MAXNAME (never kstrdup an unbounded on-disk string).", "fix_status": "fixed", "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Sat Aug 29 00:17:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "fix.diff (strnlen-bounded copy + truncation warning) included in kernel #2 (fix_build.log). Exact PoC image mounted three times: 3/3 succeed with NO panic (baseline: silent on stock, panic on the instrumented boot - both faces eliminated by construction since strlen can no longer leave the array), the warning 'hammer2_pfsalloc: PFS filename not NUL-terminated (truncated to 256)' fires on every mount, pfs-list output unchanged (PPPPP), and the healthy testvol PFS mounts normally.", "fix_evidence": [ "fix.diff", "console_excerpts.txt [D]", "fix_build.log / fix_install.log" ] } |