⬢ DragonFlyBSD Kernel Audit
DF-2652 / verdict.json
← back to finding ↓ download raw
{
  "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, fileBs 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 FILEBs 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 users block reallocated to another users 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 fileAs 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 fileAs 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 fileAs 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"
}