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.
Recommended fix
--- 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)
PoC verification
Evidence pack
findings/poc/DF-2652 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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 -= 16Kandvoldata.allocator_free -= 16K(leak + accounting drift), and - the actually-referenced chunk is left unmarked (00) β the allocator
(
hammer2_bmap_allocscans 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)
- Mount forged image (mount succeeds β the mismark is silent).
cp fileB.bin /mnt/h2t/fileB(1.5MB, 24Γ64K unique chains); sync; umount.- 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
- 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). - 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& 31fix; shared validation kernel log in DF-2651'sfix_all_three_findings.diff/fix_build.log/fix_run.log.
Fix verification
fixedfix.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
Confirmed kernel references
- 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
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).
No comments yet.