DF-3003 / verdict.json
{ "finding_id": "DF-3003", "status": "reproduced", "reproduced": 1, "impact": "panic", "confidence": "certain", "verdict": "Reproduced on the stock INVARIANTS guest. A crafted HAMMER1 inode record (data_offset at the tail of a 16KB buffer, xoff=16272; data_len=32768; data_crc=0 which hammer_crc_test_leaf() accepts because hammer_crc_get_leaf() returns 0 for wrong-length INODE records) drives hammer_update_itimes()'s in-place atime/mtime stores at cursor->data+112/+120, i.e. 8..136 bytes past the record's 16KB block. Stage A (atime-only via utimensat+UTIME_OMIT): silent 8-byte store of the attacker-chosen value 1740000000*10^6=0x00062e8551e8c000 observed exactly at image offset target+16392 after sync+umount (no panic, no UNDO, no CRC change, INVARIANTS kernel). Stage B (mtime-only): panic 'assertion (rel_offset & ~(intptr_t)HAMMER_BUFMASK) == 0 failed in hammer_modify_buffer at hammer_io.c:931' on the flusher via hammer_sync_inode, rel_offset=16384 proving the out-of-block target; on production kernels that assert is compiled out leaving a silent 16-byte write plus a misdirected UNDO entry (undo_offset crosses into the next 16KB block, corrupting unrelated metadata at crash recovery). Small-data_len variant page-faults at the buffer-end kva boundary (observed in hammer_get_inode, the DF-0771 read). Honest ceiling: on DFly every buffer-cache buffer owns a private MAXBSIZE=64KB kva reservation (vfs_bio.c:638) so the store can never leave the buffer's own reservation - no cross-allocation heap overflow and no uid0 route is claimed; impact = chosen-value persistent on-disk metadata corruption in the block following the crafted record's data + kernel panic, from mounting attacker-supplied media.", "exploit_chain": "craft HAMMER1 image (btree leaf: rec_type=INODE, data_len=32768, data_crc=0, data_offset=<free buffer>+16272; node CRC32C recomputed) -> victim mounts image -> file lookup loads inode (struct copy reads past 16KB block, in-bounds of the 32KB fetched buffer) -> utimensat(atime=chosen, mtime=UTIME_OMIT) dirties only HAMMER_INODE_ATIME -> backend flush -> hammer_update_itimes ATIME branch -> hammer_modify_buffer_noundo (no assert, no undo) + in-place 8-byte store at ondisk+16392 -> buffer flushed to media: chosen 8 bytes persisted into the 16KB block following the crafted record (verified exact value match in the image). MTIME variant panics INVARIANTS kernels at hammer_io.c:931 with rel_offset=16384.", "evidence": [ "findings/poc/DF-3003/panic_updatetimeimes_runB3.txt: panic assertion in hammer_modify_buffer via hammer_sync_inode/hammer_flusher_slave_thread", "findings/poc/DF-3003/README.md + VERDICT.md: stage A exact-value image verification (0x00062e8551e8c000 at target+16392, was 0x4242424242424242)", "findings/poc/DF-3003/panic_getinode_run1.txt: small-data_len variant page-fault at hammer_get_inode+0x3f5 (buffer-end kva boundary)", "findings/poc/DF-3003/fix_run.log: patched kernel rejects record with EIO, image bytes unchanged, no panic, legit FS regression-free", "guest console: 'HAMMER: bad inode record data_len 32768' from the fix" ], "kernel_refs": [ "sys/vfs/hammer/hammer_inode.c:1410", "sys/vfs/hammer/hammer_inode.c:1446", "sys/vfs/hammer/hammer_inode.c:1460", "sys/vfs/hammer/hammer_btree.c:728", "sys/vfs/hammer/hammer_crc.h:281", "sys/vfs/hammer/hammer_vnops.c:2344", "sys/kern/vfs_bio.c:638" ], "poc_changes": "Replaced the seeded sketch with: (1) host-side image crafter (craft_image.py) that walks the B-Tree from vol0_btree_root, repoints the file's INODE record data_offset to a free 16KB buffer tail (xoff=16272), sets data_len=32768/data_crc=0 and recomputes the btree node CRC32C (verified against the original node); (2) trig.c using utimensat with UTIME_OMIT (utimes with tv_sec=-1 returns EINVAL on DFly, and touch -a still sets the MTIME dirty flag); (3) discovered that data_len must exceed 16KB so hammer_bread_ext fetches a >=32KB buffer whose kva covers the past-block store - with small data_len the past-end page is unmapped by construction (per-buffer MAXBSIZE reservations) and the load faults first.", "attempts": 6, "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 (baseline); #1: Sat Sep 5 03:05:41 UTC 2026 (patched, fix validated)", "runtime_sec": 4200, "guest_dirty": 0, "build_cmd": "guest: cc -o /root/trig /root/trig.c ; host: python3 craft_image.py h1.img h1_patched2.img ; fix: cd /usr/src && patch -p0 < fix3003.diff && make nativekernel && make installkernel KERNCONF=X86_64_GENERIC", "run_cmd": "guest root: vnconfig -c vn0 h1p2.img; mount_hammer -o atime /dev/vn0 /mnt; /root/trig a /mnt/f (or b); sync; umount /mnt; vnconfig -u vn0", "code_hash": "c4f696d7530bab565c44fe068e44058aa79f4229b962aa38f3f034c0d8172970 (trig.c); e6185d2d551bd9a1f3256a3804194fcca5ccfd00bea6526dd9fecaf5dd7556b5 (craft_image.py)", "notes": "Root-cause chain: hammer_crc_get_leaf() returns 0 for wrong-length INODE records and hammer_crc_test_leaf() compares it to the stored data_crc (0==0 passes for ANY length); hammer_btree_extract() sets cursor->data=buffer->ondisk+xoff with no data_len/bounds validation; hammer_update_itimes() is the only in-place record-data writer and writes fixed struct offsets through that pointer. Same-family read sinks (hammer_get_inode:525 DF-0771, hammer_load_pseudofs:1033 clamped to sizeof(pfsd)) are bounded by the identical missing check; fix.diff covers them at the choke point. Two earlier guest runs faulted in hammer_get_inode+0x3f5 (movq 0x70(%rax),%rdx) at the buffer-end page boundary with small data_len - consistent with the DFly buffer-cache kva layout, not a flake.", "recommended_fix": "Reject INODE records whose data_len != sizeof(struct hammer_inode_data) and any record whose data range crosses out of its aligned buffer block in hammer_btree_extract(); make hammer_crc_test_leaf() fail wrong-length INODE records (see fix.diff, built and validated on the guest).", "fix_status": "fixed", "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 5 03:05:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "Patched kernel (fix.diff applied in guest /usr/src, make nativekernel + installkernel, reboot) rejects the crafted record at extraction: mount OK, /mnt/f and utimensat return EIO, console prints 'HAMMER: bad inode record data_len 32768', the target image bytes are byte-identical before/after (no write), no panic, and a legitimate HAMMER mount (h1.img: ls/cat/touch/sync/umount) works unchanged. Baseline reproduced both the silent write (exact chosen value in image) and the hammer_modify_buffer panic.", "fix_evidence": [ "findings/poc/DF-3003/fix_build.log (make nativekernel output)", "findings/poc/DF-3003/fix_run.log (patched-kernel stage A/B + regression run)", "image byte comparison: [target+16384,+16400) == (0x4141414141414141, 0x4242424242424242) before AND after the patched run (baseline run had overwritten atime to 0x00062e8551e8c000)" ] } |