DF-2652 / verdict.json
{ "finding_id": "DF-2652", "status": "reproduced", "reproduced": 1, "impact": "dos", "confidence": "certain", "verdict": "REPRODUCED on stock kernel #0 as silent cross-file data corruption. hammer2_freemap_adjust() DORECOVER (mount crash-recovery vfsops.c:2234/:2325 and default-enabled dedup re-registration chain.c:1627) computes the 16K-chunk bit-pair index inside its 512K bitmapq[] element with '& 15' (freemap.c:1073) instead of the correct 5-bit '& 31' used by both the allocator (freemap.c:636) and bulkfree staging (bulkfree.c:921-924). For chunks 16..31 of every 512K element (data_off bit 18 set) the fixup marks the chunk 256K lower and leaves the actually-referenced chunk marked 00-free; the allocator (hammer2_bmap_alloc 00-pair scan) then hands the still-live block to the next writer. PoC: forged single-block crash-recovery condition for fileA chain at 0x1c40000 (pairs cleared in on-disk leaf, ancestry mirror_tids bumped above freemap_tid); after mount+fileB write, fileB's first 64K chain is allocated exactly at 0x1c40000 (on-disk: FILEB-CHUNK-000000 at that offset, FILEA chunk 16-19 headers gone); fileA read returns EIO (iscsi32 CRC destroyed) and with CHECK_NONE brefs fileA serves FILEB's bytes verbatim (content bleed). bmap avail drifted 0x200000->0x80000 following the lying bitmap. Fixed by masking with HAMMER2_BMAP_BLOCKS_PER_ELEMENT-1; on the rebuilt kernel #1 the same PoC leaves fileA byte-identical to the pristine copy (md5 4511730a267863dfcb88504c3068b2f2) and fileB allocates fresh space. Impact ceiling: silent data destruction/disclosure between local users' files on multi-user systems after any crash-recovery that touches affected offsets, or via dedup adjustments; requires no privileges in those paths (crafted-image mount makes it deterministic as demonstrated).", "exploit_chain": "(no-precondition variant) crash with topology-flushed/freemap-unflushed allocations in upper 256K of a 512K element -> remount recovery adjust() mismarks -> live block left 00 -> victim user's block reallocated to another user's file -> victim file destroyed (EIO) or serves foreign content; (deterministic PoC variant) crafted image with cleared pair + bumped mirror_tids -> same mismark -> fileB lands on fileA's live block", "evidence": [ "run.log RUN A: FILEB-CHUNK-000000 at disk 0x1c40000; FILEA chunk 16-19 NOT FOUND; fileA md5 -> Input/output error while fileB reads fine", "run.log RUN B (CHECK_NONE brefs): fileA chunks 16..19 return FILEB-CHUNK-000000..000003 (cross-file content bleed)", "run.log FIX VALIDATION: patched kernel keeps fileA md5 4511730a267863dfcb88504c3068b2f2 == pristine, chunks read FILEA-CHUNK-000016..19", "after2652.img.gz: post-corruption image (leaf rotated to 0x60000, avail 0x80000, X0 re-marked by fileB's own alloc)", "forge_2652.py: the deterministic crash-condition forge" ], "kernel_refs": [ "sys/vfs/hammer2/hammer2_freemap.c:1073", "sys/vfs/hammer2/hammer2_freemap.c:1100", "sys/vfs/hammer2/hammer2_freemap.c:1102", "sys/vfs/hammer2/hammer2_freemap.c:1113", "sys/vfs/hammer2/hammer2_freemap.c:636", "sys/vfs/hammer2/hammer2_bulkfree.c:921", "sys/vfs/hammer2/hammer2_vfsops.c:2234", "sys/vfs/hammer2/hammer2_chain.c:1627" ], "poc_changes": "Seed plan adjusted twice: (1) hammer2 compresses compressible file data by default, collapsing 16K-marker tracking - fileA/f2653 rewritten with urandom-backed incompressible chunks so chains stay raw 64K radix-16 blocks; (2) the recovery recursion is gated per-bref by mirror_tid > freemap_tid along the WHOLE ancestry (volhdr sroot bref included) - bumping only in-sroot-block brefs did nothing; also zeroing freemap_tid globally instead would hit unrelated embedded DIRENT brefs with data_off=0 (KKASSERT radix!=0 panic, different bug class), so only fileA's ancestry was bumped.", "attempts": 4, "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": 210, "guest_dirty": 0, "build_cmd": "python3 forge_2652.py base2651.img forge2652.img; python3 (see build.sh) for the CHECK_NONE bleed variant forge2652b.img", "run_cmd": "vm.sh run_root 'vnconfig -c vn0 /tmp/forge2652.img; mount -t hammer2 /dev/vn0@testvol /mnt/h2t; cp /tmp/fileB.bin /mnt/h2t/fileB; sync; umount /mnt/h2t; ...remount...; md5 /mnt/h2t/fileA; dd if=/mnt/h2t/fileA bs=16k skip=16 count=4 | grep -ao FILE[AB]-CHUNK-*'", "code_hash": "412cf898a1652769cba5b011f69bd1c34257e0750c6d515bce49fffea4e9dccd", "notes": "avail/allocator_free accounting also drifts (leak of free chunks marked 11 at wrong positions; bmap[7] avail 0x200000 -> 0x80000 across the PoC while fileB only truly consumed fresh space beyond the overlap). Impact enum maps the silent data destruction to dos; the CHECK_NONE variant demonstrates cross-file content exposure (integrity/confidentiality), not kernel memory corruption.", "recommended_fix": "start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) & (HAMMER2_BMAP_BLOCKS_PER_ELEMENT - 1)) * 2;", "fix_status": "fixed", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Aug 30 01:17:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "fix_verdict": "fix.diff masks the block-in-element with HAMMER2_BMAP_BLOCKS_PER_ELEMENT-1 (& 31): on kernel #1 the same forge+fileB run leaves fileA byte-identical to the pristine copy (md5 4511730a267863dfcb88504c3068b2f2, chunks 16..19 read FILEA-CHUNK-000016..19) while on #0 fileA's live block was handed to fileB and destroyed. Baseline corruption gone.", "fix_evidence": "run.log FIX VALIDATION section (patched-kernel md5 + chunk markers); DF-2651/fix_build.log for the shared kernel build; run.log RUN A/RUN B for the #0 baseline corruption" } |