β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2652

Wrong bitmap-pair index (& 15 instead of 5-bit mask) in hammer2_freemap_adjust() DORECOVER β€” live blocks left marked free, overlapping allocations and silent cross-file data corruption

Field Value
ID DF-2652
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N
CWE CWE-681 Incorrect Conversion between Numeric Types
File sys/vfs/hammer2/hammer2_freemap.c
Lines 1073 (mask), 1100 (use)
Area vfs
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

freemap.c:1073 computes the bit position of a 16KB chunk inside its 512KB bitmapq[] element as start = ((data_off >> 14) & 15) * 2, but each element holds 32 chunks β€” the allocator (freemap.c:636 j=(offset/8192)&62) and bulkfree staging (bulkfree.c:921-924) both correctly use all 5 bits. For chunks 16..31 of every 512KB element (data_off bit 18 set), every DORECOVER fixup marks the pair of the chunk 256KB lower and leaves the actually-referenced chunk marked 00-free; the allocator's 00-pair scan (freemap.c:694-705) then hands the still-live block to the next allocation.

Reachable with no crafted image from mount-time crash recovery (vfsops.c:2234/:2325 β€” freemap updates lag flushes by one transaction by design) and from the default-enabled dedup re-registration path (chain.c:1610-1627, dedup_enable=1). Also drifts bmap->avail and voldata.allocator_free.

Threat model & preconditions

Multi-user system: after any crash whose recovery re-marks blocks in the upper 256KB of a 512KB element (~50% of recovered blocks), or after dedup adjustments on affected offsets, a live file's 64K block is left marked free; the next allocation (another user's write) lands on top of it. Demonstrated: victim fileA's block 0x1c40000 overwritten by fileB β€” fileA reads EIO (data destroyed, default iscsi32 CRCs) or serves fileB's bytes verbatim (CHECK_NONE filesystems, cross-file content exposure). Unprivileged trigger paths need no mount privilege; a crafted image makes it deterministic.

Proof of concept

VERIFIED deterministically (findings/poc/DF-2652/): forge_2652.py clears X0=0x1c40000's 4 bit-pairs 11β†’00 in the on-disk freemap leaf (leaf icrc32 recomputed into volhdr freemap_blockset[0]) and bumps fileA's ancestry mirror_tids above freemap_tid so mount recovery re-adjusts all 24 fileA DATA brefs. Mount β†’ adjust marks the wrong pairs (start=((0x1c40010>>14)&15)*2=0 β†’ chunks 0-3, already 11, no-op) and X0 stays 00. cp fileB.bin (24Γ—64K unique chains): fileB chain 0 is allocated exactly at 0x1c40000. Host-side proof: FILEB-CHUNK-000000 at disk 0x1c40000, fileA chunk 16-19 headers NOT FOUND, fileA md5 β†’ Input/output error; CHECK_NONE variant: fileA chunks 16..19 read back FILEB-CHUNK-000000..000003. Fixed kernel: fileA md5 4511730a267863df == pristine. No uid=0 route β€” on-disk corruption class, not kernel memory corruption.

--- a/sys/vfs/hammer2/hammer2_freemap.c
+++ b/sys/vfs/hammer2/hammer2_freemap.c
@@ -1070,7 +1070,12 @@
    /*
     * Calculate the bitmask (runs in 2-bit pairs).
     */
-   start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) & 15) * 2;
+   /*
+    * DF-2652: each bitmapq[] element covers 32 x 16KB blocks
+    * (HAMMER2_BMAP_BLOCKS_PER_ELEMENT), so the block number within
+    * the element is 5 bits, not 4.
+    */
+   start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) &
+        (HAMMER2_BMAP_BLOCKS_PER_ELEMENT - 1)) * 2;

References

  • freemap.c:636 and bulkfree.c:921-924 (the two correct 5-bit users), DF-2653 (sibling index-arithmetic defect in bmap_alloc)

Timeline

  • 2026-08-29 Discovered during pass-2 audit of hammer2_freemap.c (GLM 5.3); deterministic forged PoC + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2652 Β· 14 files
FileTypeDescriptionSize
forge_2652.py β€” 4.9 KB view raw
h2common.py β€” 3.8 KB view raw
forge2652.img.gz β€” 2.1 MB ↓ download
forge2652b.img.gz β€” 2.1 MB ↓ download
after2652.img.gz β€” 3.5 MB ↓ download
fileA.bin.gz β€” 1.5 MB ↓ download
fileB.bin.gz β€” 1.5 MB ↓ download
run.log β€” 2.9 KB view raw
fix.diff β€” 803 B view raw
build.sh β€” 956 B view raw
run.sh β€” 786 B view raw
verdict.json β€” 5.7 KB view raw
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw

DF-2652 β€” freemap recovery marks the wrong 16K chunk (bit 18 dropped)

hammer2_freemap_adjust() (DORECOVER β€” mount-time crash recovery and the default-enabled dedup re-registration path) computes the bit-pair index of a 16KB chunk inside its 512KB bitmapq[] element with a 4-bit mask (& 15, freemap.c:1073) although elements hold 32 chunks. For every chunk in the upper 256KB of a 512KB element the fixup marks the chunk 256KB lower and leaves the real chunk marked FREE β€” the allocator then hands the still-live block to the next writer, producing overlapping allocations and silent cross-file data corruption.

Reproduce

Host:

python3 forge_2652.py base2651.img forge2652.img          # pair-clear + mtid bumps
python3 - <<'EOF'                                          # bleed variant (optional)
# set methods=0x00 (CHECK_NONE) on fileA's 24 DATA brefs in the indirect
# block at 0x2000000, recompute volhdr CRCs  (see build.sh for the exact code)
EOF

Guest (root):

vnconfig -c vn0 /tmp/forge2652.img
mount -t hammer2 /dev/vn0@testvol /mnt/h2t     # mounts fine (silent bug)
cp /tmp/fileB.bin /mnt/h2t/fileB; sync; umount /mnt/h2t; vnconfig -u vn0
vnconfig -c vn0 /tmp/forge2652.img             # remount
md5 /mnt/h2t/fileA                             # -> I/O error (default iscsi32 brefs)
dd if=/mnt/h2t/fileA bs=16k skip=16 count=4 | grep -ao "FILE[AB]-CHUNK-[0-9]*"
                                               # bleed variant: FILEB-CHUNK-000000..

Expected on stock kernel: fileA's 64K at 0x1c40000 overwritten by fileB (host-side: FILEB-CHUNK-000000 at disk offset 0x1c40000, fileA chunk 16-19 headers gone), fileA read β†’ EIO (or FILEB content on the no-check variant). On the fixed kernel: fileA md5 matches the pristine fileA.bin, chunks 16..19 read FILEA-CHUNK-000016..19.

VERDICT.md
↓ download raw

DF-2652 β€” wrong bitmap-pair index (& 15 vs 32 blocks) in hammer2_freemap_adjust()

status: reproduced | impact: dos (silent cross-file data corruption / file destruction; content bleed on no-check fs) | confidence: certain

Root cause

hammer2_freemap_adjust() computes the bit position of a 16KB chunk inside its 512KB bitmapq[] element as (freemap.c:1073):

start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) & 15) * 2;

Each element holds 32 chunks (5 bits: HAMMER2_BMAP_BLOCKS_PER_ELEMENT; see the allocator's own math at freemap.c:636 j = (offset / 8192) & 62 and bulkfree's staging at bulkfree.c:921-924 ((data_off & HAMMER2_BMAP_INDEX_MASK) >> BLOCK_RADIX) << 1, both of which use all 5 bits). The & 15 truncates the 5-bit block number to 4 bits, so for chunks 16..31 of every 512KB element (data_off bit 18 set) every DORECOVER fixup marks the pair of chunk (n-16) β€” 256KB lower, same element β€” instead of chunk n:

  • the wrong (possibly free) chunk is set to 11 with bmap->avail -= 16K and voldata.allocator_free -= 16K (leak + accounting drift), and
  • the actually-referenced chunk is left unmarked (00) β†’ the allocator (hammer2_bmap_alloc scans for 00 pairs, freemap.c:694-705) hands the still-live block to the next allocation β†’ overlapping allocations.

Reachable with no crafted image at all from either of the two DORECOVER callers: * mount-time crash recovery (vfsops.c:2234/:2325) β€” "Updates to the free block table are allowed to lag flushes by one transaction" (vfsops.c comment): any crash with unflushed allocations whose blocks sit in the upper 256KB of a 512KB element (~50% of blocks) are mismarked on the next mount; * the dedup re-registration path (chain.c:1610-1627, dedup_enable=1 by default, vfsops.c:71).

The PoC forges the crash-recovery condition deterministically for one fileA chain (X0 = 0x1c40000): its 4 bit-pairs are cleared 11β†’00 in the on-disk leaf and fileA's ancestry mirror_tids are bumped above freemap_tid so recovery re-adjusts them.

Decisive run (stock INVARIANTS kernel #0)

  1. Mount forged image (mount succeeds β€” the mismark is silent).
  2. cp fileB.bin /mnt/h2t/fileB (1.5MB, 24Γ—64K unique chains); sync; umount.
  3. Host-side inspection of the post-run image (after2652.img):
disk@X0 (0x1c40000): b'FILEB-CHUNK-000000-...'        <- fileB chain 0 landed ON X0
FILEA chunk 16..19 header on disk: NOT FOUND           <- fileA's block overwritten
FILEB chunk 0..3 header on disk: 0x1c40000..0x1c4c000  <- the overlapping allocs
leaf: bmap[7] avail 0x200000 -> 0x80000                <- accounting follows the lying bitmap
  1. Remount + md5 /mnt/h2t/fileA β†’ I/O error (fileA's iscsi32 CRC no longer matches because its live block was overwritten by fileB) β€” the victim's file is silently destroyed (data loss).
  2. Content-bleed variant (forge2652b.img: fileA's DATA brefs CHECK_NONE, as on a no-check hammer2 fs): remounting and reading fileA chunks 16..19 returns FILEB-CHUNK-000000..000003 β€” fileA serves another file's bytes:
=== fileA chunks 16..19 after remount (cross-file bleed?):
FILEB-CHUNK-000000
FILEB-CHUNK-000001
FILEB-CHUNK-000002
FILEB-CHUNK-000003

Fix validation (kernel #1 with hunk 3 of fix_all_three_findings.diff)

start = ((int)(data_off >> BLOCK_RADIX) & (HAMMER2_BMAP_BLOCKS_PER_ELEMENT - 1)) * 2

kernel forged image + fileB write fileA after remount
#0 stock fileB chain 0 allocated at 0x1c40000 (over fileA) chunks 16-19 destroyed β†’ EIO / FILEB content
#1 patched fileB allocates fresh space md5 4511730a267863dfcb88504c3068b2f2 == pristine fileA.bin; chunks read FILEA-CHUNK-000016..19

No panic in either run (this bug is silent corruption, not a crash).

Threat model

Unprivileged multi-user system: user B's writes (after any crash-recovery mismark of user A's blocks, or a dedup-adjust on an affected offset) are allocated on top of user A's live data β†’ destruction/disclosure of A's file content; no mount privilege required for the crash-recovery trigger. Crafted-image mount makes it fully deterministic (this PoC).

Artifacts

  • forge_2652.py (pair-clear + mtid-bump forge), forge2652.img.gz, forge2652b.img.gz (CHECK_NONE-bref bleed variant, see build.sh), after2652.img.gz (post-corruption image), fileA.bin.gz/fileB.bin.gz.
  • run.log β€” full session (corruption + EIO + bleed).
  • fix.diff β€” the & 31 fix; shared validation kernel log in DF-2651's fix_all_three_findings.diff / fix_build.log / fix_run.log.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

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.

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
↓ fix.diffDragonFly 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

Confirmed kernel references

Detail

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 (decisive lines)

['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']

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.

Verified recommended fix

start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) & (HAMMER2_BMAP_BLOCKS_PER_ELEMENT - 1)) * 2;

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).