sub_key-derived bitmap index overflows the 64-bit element in hammer2_bmap_alloc() for non-radix-aligned crafted DATA bref keys β KKASSERT panic (INVARIANTS) / truncated mask with overlapping 64K allocations (noinv)
| Field | Value |
|---|---|
| ID | DF-2653 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-20 Improper Input Validation |
| File | sys/vfs/hammer2/hammer2_freemap.c |
| Lines | 663-679 |
| 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
hammer2_bmap_alloc()'s DATA fast path (:660-687) derives bitmap
indexes i/j from the chain bref's own key (sub_key) with only an
after-the-fact KKASSERT that j+bmradix<=64 at :679. The kernel only
ever creates radix-16 DATA chains at 64K-aligned keys
(hammer2_calc_logical, subr.c:267-281), but a crafted image can supply a
DATA bref with a 16K-aligned key (0x7C000): j becomes 62 while
bmradix=8 (radix 16), so j+bmradix=70 β KKASSERT panic on INVARIANTS
kernels; on production kernels bmmask=(0xFF)<<62 truncates to 2 bits,
the 64K allocation marks only one 16K pair, avail -= 64K, and the
returned data_off (element base + 62*8K, 64K long) crosses into
bitmapq element i+1 whose chunks stay 00 β subsequent allocations
overlap this one (DF-2652 class).
Threat model & preconditions
Crafted hammer2 image: a DATA bref {key=0x7C000, keybits=16, radix=16} planted in a file's indirect block (covering a hole) is found by the write path for lbase 0x80000 and COW-modified. Any user of the mounted fs writing into that range triggers the panic/corruption. DoS on default kernels; allocation-overlap corruption on production noinv kernels.
Proof of concept
VERIFIED on the stock guest (findings/poc/DF-2653/): forge_2653.py
inserts the poison bref at ascending-key slot 4 of f2653's indirect
block (methods=0x30 iscsi32 β 0x00/CHECK_NONE takes the
overwrite-in-place path and never reallocates, discovered in iteration
1). Mount, then dd if=<64K urandom> of=/mnt/h2t/f2653 bs=64k seek=8
conv=notrunc β async panic:
assertion "j + bmradix <= HAMMER2_BMAP_BITS_PER_ELEMENT" failed in
hammer2_bmap_alloc at hammer2_freemap.c:679 with backtrace
hammer2_bmap_alloc β hammer2_freemap_alloc β hammer2_chain_modify β
hammer2_assign_physical β hammer2_xop_strategy_write; guest down.
Fixed kernel #1: same write completes, readback md5 matches, guest up.
Recommended fix
Turn the assertion into a gate and fall back to the (safe, self-bounded) general element scan:
--- a/sys/vfs/hammer2/hammer2_freemap.c
+++ b/sys/vfs/hammer2/hammer2_freemap.c
@@ -676,7 +676,11 @@
default:
break;
}
- if (i >= 0) {
+ /*
+ * DF-2653: reject sub_key-derived indexes that do not
+ * leave room for bmradix bits inside the 64-bit element
+ * (crafted DATA bref key that is not radix-aligned gives
+ * j=62 with bmradix=8). Fall back to the general scan.
+ */
+ if (i >= 0 && j + bmradix <= HAMMER2_BMAP_BITS_PER_ELEMENT) {
KKASSERT(i < HAMMER2_BMAP_ELEMENTS &&
j < 2 * HAMMER2_BMAP_BLOCKS_PER_ELEMENT);
- KKASSERT(j + bmradix <= HAMMER2_BMAP_BITS_PER_ELEMENT);
References
- DF-2652 (the sibling mask defect in adjust), DF-2616 family
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_freemap.c (GLM 5.3); verified reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2653 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge_2653.py | β | 4.5 KB | view raw | |
| h2common.py | β | 3.8 KB | view raw | |
| forge2653b.img.gz | β | 2.1 MB | β download | |
| f3lo.bin.gz | β | 256.1 KB | β download | |
| f3hi.bin.gz | β | 256.1 KB | β download | |
| panic.txt | β | 831 B | view raw | |
| panic2653_full.txt | β | 3.1 KB | view raw | |
| run.log | β | 2.5 KB | view raw | |
| env.txt | β | 195 B | view raw | |
| fix.diff | β | 747 B | view raw | |
| fix_build.log | β | 254 B | view raw | |
| fix_run.log | β | 572 B | view raw | |
| build.sh | β | 907 B | view raw | |
| run.sh | β | 623 B | view raw | |
| verdict.json | β | 4.5 KB | view raw |
Fix verification
fixedfix.diff gates the sub_key fast path with a real (j + bmradix <= 64) check and falls back to the general element scan: on kernel #1 the same forged image + 64K write at f2653+0x80000 completes, readback md5 matches the written bytes, guest stays up; on #0 the same operation panicked at freemap.c:679 in hammer2_xop_strategy_write. Baseline panic gone.
fix_run.log (MOUNT_OK, md5 match, guest alive on #1); panic.txt for the #0 baseline assertion + backtrace
Confirmed kernel references
Detail
Exploit chain
crafted hammer2 image (misaligned-key DATA bref in indirect block) -> mount -> unprivileged write into the covered hole range -> chain found + COW modify -> freemap_alloc -> bmap_alloc j+bmradix=70>64 -> KKASSERT panic (INVARIANTS) / truncated mask + overlapping allocation (noinv)
Evidence (decisive lines)
['panic.txt: exact assertion text + backtrace through hammer2_xop_strategy_write', 'run.log iteration 2: MOUNT_OK, WRITE_DONE, then guest down; serial console panic block', 'run.log fix validation: patched kernel MOUNT_OK, readback md5 f1fdf21f00ddb16c35b1e77fac5eaeac == written data, guest alive', 'forge_2653.py: poison insertion with ascending-key slot shifting']
PoC changes
Two iterations: first attempt used CHECK_NONE (0x00) methods on the poison bref and did not panic - hammer2's overwrite-in-place optimization (chain.c:1495-1521) skipped freemap_alloc entirely; fixed by using methods=0x30 (iscsi32) to force the COW realloc. Panic fires asynchronously in the strategy xop, so success must be checked via vm.sh status/log, not the dd exit.
Verified recommended fix
Gate the sub_key fast path with 'if (i >= 0 && j + bmradix <= HAMMER2_BMAP_BITS_PER_ELEMENT)' and fall back to the general element scan otherwise.
Verdict
REPRODUCED on stock INVARIANTS kernel #0: a crafted DATA bref with a 16K-aligned key (0x7C000) and radix 16, planted in a file's indirect block covering a hole, is found by the write path for lbase 0x80000 (hammer2_assign_physical, strategy.c:733) and COW-modified; hammer2_chain_modify -> hammer2_freemap_alloc -> hammer2_bmap_alloc computes j=(0x7C000&0x7FFFF)/163842=62 with bmradix=8, and the KKASSERT at freemap.c:679 fires: 'panic: assertion "j + bmradix <= HAMMER2_BMAP_BITS_PER_ELEMENT" failed in hammer2_bmap_alloc at hammer2_freemap.c:679' with backtrace hammer2_bmap_alloc <- hammer2_freemap_alloc <- hammer2_chain_modify <- hammer2_assign_physical <- hammer2_xop_strategy_write. Panic is async (strategy xop thread). On no-INVARIANTS production kernels the same arithmetic truncates bmmask=(0xFF<<62) to 2 bits: the 64K allocation marks one 16K pair, avail -= 64K, and data_off (element base + 628K, 64K long) crosses into the next bitmapq element whose chunks stay 00 -> subsequent allocations overlap (same corruption class as DF-2652). Fixed by turning the KKASSERT into a real gate on the fast path (misaligned sub_key falls back to the general element scan); validated on kernel #1: same forged image + write completes, readback md5 matches, guest stays up.
No comments yet.