Sustained CPU-burn DoS via unchecked radix in hammer2_freemap_adjust count calculation
Summary
hammer2_freemap.c:977 radix=(int)data_off&MASK_RADIX (0..63 from on-disk bref). :978 KKASSERT(radix!=0) no-op. :980 KKASSERT(radix<=RADIX_MAX) no-op. :1089 count=1<<(radix-BLOCK_RADIX(14)). radix=44: count=1<<30=1 billion. radix=45: count=1<<31=UB/INT_MIN. :1107 while(count) loop runs count iterations. After ~17 iterations bmmask11=0 (shifted past 64). :1108 KKASSERT(bmmask11) no-op. Remaining iterations pure CPU burn. radix=45 count=INT_MIN ~2^31 iterations = mount hang 10-60+ seconds. Called during mount recovery (hammer2_vfsops.c:2235,2326) and dedup (hammer2_chain.c:1628). Attack: crafted HAMMER2 image blockref data_off radix=45 forged CRC mount = automatic hang. Fix: if(radix==0||radix>RADIX_MAX) return + clamp count shift.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0822 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| h2adj.c | trigger-source | kernel-module harness calling real hammer2_freemap_adjust() with crafted radix 16/30/44 | 3.2 KB | view raw |
| forge.c | trigger-source | HAMMER2 image forger: patches sroot_blockset + forges volume-header CRC-32C | 7.9 KB | view raw |
| dump_volhdr.c | diagnostic | volume-header field dumper for image analysis | 4.0 KB | view raw |
| Makefile.h2adj | build-config | bsd.kmod.mk build for the harness module | 100 B | β download |
| build.sh | build-script | builds forge + h2adj.ko | 796 B | view raw |
| run.sh | run-script | kldload h2adj.ko -> panic on unpatched / graceful on patched | 719 B | view raw |
| fix.diff | suggested-fix | validate radix range before use; graceful return for bad radix | 545 B | view raw |
| VERDICT.md | verdict | full narrative: root cause, reachability, reproduction, fix validation | 6.4 KB | β raw |
| README.md | readme | summary + reproduce instructions | 1.8 KB | β raw |
| panic.txt | panic-signature | baseline panic: KKASSERT(radix<=RADIX_MAX) at hammer2_freemap.c:980 | 668 B | view raw |
| fix_build.log | build-log | single-fix kernel nativekernel build output (rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched-kernel re-run: graceful 'ignoring bad radix' returns, no panic | 640 B | view raw |
| env.txt | environment | uname, cc version, INVARIANTS in X86_64_GENERIC config | 298 B | view raw |
| patched_kernversion.txt | environment | kern.version of the single-fix #1 kernel | 59 B | view raw |
| build.log | build-log | kernel build log excerpt proving -Werror clean compile of patched source | 5.5 KB | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0822 β Sustained CPU-burn DoS via unchecked radix in hammer2_freemap_adjust
Severity: Medium Β· Class: mount-time DoS (unchecked attacker-controlled radix) Β· Status: reproduced, fix validated
The bug
hammer2_freemap_adjust() (sys/vfs/hammer2/hammer2_freemap.c) extracts the
allocation radix from an on-disk blockref.data_off (low 6 bits, 0β63) and
uses it to compute a loop count without validation:
radix = (int)data_off & HAMMER2_OFF_MASK_RADIX; /* line 977 β 0..63, from disk */
KKASSERT(radix != 0); /* line 978 β no-op on production */
KKASSERT(radix <= HAMMER2_RADIX_MAX /*16*/); /* line 980 β PANIC on GENERIC, no-op on noinv */
...
count = 1 << (radix - HAMMER2_FREEMAP_BLOCK_RADIX); /* line 1089 β radix=44 => 1<<30 */
while (count) { ... } /* line 1107 β ~1 billion iterations */
A crafted HAMMER2 image whose blockref carries radix β₯ 17 triggers, at mount: - GENERIC (INVARIANTS ON): kernel panic at line 980. - Production (INVARIANTS OFF): sustained CPU-burn (~1Γ10βΉ iterations).
How to reproduce
The harness (h2adj.c) is a kernel module that calls the real exported
hammer2_freemap_adjust() with crafted radix values, simulating the on-disk
attack vector without needing to forge a multi-level CRC image.
./build.sh # builds forge + h2adj.ko
./run.sh # kldload h2adj.ko β panic on unpatched kernel
Expected on unpatched #0 GENERIC: panic
assertion "radix <= HAMMER2_RADIX_MAX" failed in hammer2_freemap_adjust at hammer2_freemap.c:980.
Expected on patched #1 kernel: graceful returns with ignoring bad radix N warnings, guest stays up.
See VERDICT.md for the full analysis and fix.diff for the validated fix.
DF-0822 β Sustained CPU-burn DoS via unchecked radix in hammer2_freemap_adjust
Verdict: REPRODUCED (panic on GENERIC / CPU-burn on no-INVARIANTS)
The bug is real and confirmed. hammer2_freemap_adjust() in
sys/vfs/hammer2/hammer2_freemap.c extracts the allocation radix from an
on-disk blockref.data_off field (low 6 bits, range 0β63) and uses it
without validation. The only guards are two KKASSERTs (lines 978, 980)
that are no-ops on production kernels (INVARIANTS OFF) and that panic
the machine on the default GENERIC kernel (INVARIANTS ON) before the
worst damage is done.
Root cause (line-by-line)
hammer2_freemap.c:977 radix = (int)data_off & HAMMER2_OFF_MASK_RADIX; // 0..63, attacker-controlled
hammer2_freemap.c:978 KKASSERT(radix != 0); // no-op on production
hammer2_freemap.c:980 KKASSERT(radix <= HAMMER2_RADIX_MAX /*16*/); // no-op on production; PANIC on GENERIC
hammer2_freemap.c:1089 count = 1 << (radix - HAMMER2_FREEMAP_BLOCK_RADIX /*14*/);
// radix=44 => count = 1<<30 = 1,073,741,824
hammer2_freemap.c:1107 while (count) { // 1-billion-iteration burn
hammer2_freemap.c:1108 KKASSERT(bmmask11); // no-op; bmmask11==0 after ~32 iters
...
hammer2_freemap.c:1183 --count;
hammer2_freemap.c:1186 bmmask11 <<= 2;
}
data_off is read directly from the on-disk hammer2_blockref_t (field at
offset 40, hammer2_disk.h:630). The radix occupies the low 6 bits
(HAMMER2_OFF_MASK_RADIX = 0x3F, hammer2_disk.h:461). A crafted HAMMER2
filesystem image places a blockref with radix β₯ 17 in a position the
mount-time recovery scan reaches.
Reachability (mount-time, attacker-controlled image)
hammer2_freemap_adjust(hmp, bref, HAMMER2_FREEMAP_DORECOVER) is called from:
- hammer2_vfsops.c:2234 β for every non-VOLUME parent chain during recovery.
- hammer2_vfsops.c:2325 β for leaf blockrefs whose mirror_tid > freemap_tid.
- hammer2_chain.c:1627 β during dedup.
The recovery scan (hammer2_recovery β hammer2_recovery_scan,
hammer2_vfsops.c:2170-2357) runs on every writable mount
(line 1335: if (!hmp->ronly) error = hammer2_recovery(hmp);), scanning
the blockref tree read from the on-disk image. An admin mounting an
attacker-supplied HAMMER2 image triggers the path.
Reproduction
A kernel-module harness (h2adj.c) calls the real, exported
hammer2_freemap_adjust() (symbol at 0xffffffff80983110) on the live
root HAMMER2 filesystem, passing crafted hammer2_blockref_t values whose
data_off carries radix = 16 (valid max), 30, and 44.
Baseline β unpatched #0 kernel (GENERIC, INVARIANTS ON):
H2ADJ: radix=16 data_off=...0c10 predicted_count=4 elapsed=0 ms (normal) panic: assertion "radix <= HAMMER2_RADIX_MAX" failed in hammer2_freemap_adjust at /usr/src/sys/vfs/hammer2/hammer2_freemap.c:980 hammer2_freemap_adjust() at hammer2_freemap_adjust+0x3d9 h2adj_load() at h2adj_load+0x114 Stopped at Debugger+0x7c
β radix=30 (and any radix > 16) panics the kernel at line 980.
The guest drops to DDB (db>), requiring a hard reset. This is a
mount-time DoS: the system crashes the instant the crafted image is
mounted (recovery scan hits the bad-radix blockref).
On a production kernel (INVARIANTS OFF): the KKASSERT at line 980 is
compiled to do { } while(0) (systm.h:118), so radix=44 falls through to
count = 1 << (44-14) = 1<<30 and the while(count) loop at line 1107
executes 1,073,741,824 iterations β the sustained CPU-burn / mount hang
described in the finding title. (The KKASSERT at line 1108 on bmmask11 is
also a no-op, so the loop runs to completion rather than panicking.)
Impact
- Default GENERIC kernel (INVARIANTS ON): mount-time kernel panic (DoS β system crash requiring reset). Affects any admin who mounts an attacker-controlled HAMMER2 image.
- Production/no-INVARIANTS kernel: mount-time sustained CPU-burn (~1 billion iterations per bad-radix blockref; the mount hangs for seconds-to-minutes depending on radix value and number of poisoned blockrefs).
Both are local DoS via a crafted filesystem image (root-mount threat model:
vfs.usermount defaults to 0, so mounting requires root, but the realistic
vector is an admin mounting a supplied image, or an auto-mount scenario).
No memory-corruption primitive: the only effect of the unchecked radix is the panic (GENERIC) or CPU-burn (noinv). No escalation chain.
The fix (fix.diff)
Adds an explicit range check immediately after extracting the radix, converting the panic/burn into a graceful skip (the invalid blockref is logged and ignored during recovery):
radix = (int)data_off & HAMMER2_OFF_MASK_RADIX;
if (radix == 0 || radix > HAMMER2_RADIX_MAX) {
kprintf("hammer2_freemap_adjust: %016jx: ignoring bad radix %d\n",
(intmax_t)data_off, radix);
return;
}
Fix validation (Phase 8 β built + booted single-fix kernel)
- Unpatched
#0baseline: radix=30 βpanic: assertion "radix <= HAMMER2_RADIX_MAX" failedathammer2_freemap.c:980. Guest dead in DDB. - Patched
#1kernel (kern.version6.5-DEVELOPMENT #1, sha2567cc48799β¦): - radix=16:
elapsed=0 ms (normal)β valid radix unaffected. - radix=30:
hammer2_freemap_adjust: β¦: ignoring bad radix 30β graceful return, 4 ms, no panic. - radix=44:
hammer2_freemap_adjust: β¦: ignoring bad radix 44β graceful return, 5 ms, no panic. - Guest alive and responsive after all three calls.
β Fix closes the bug: the panic is gone, the CPU-burn loop is never reached, and valid radix values are processed normally.
PoC files
| file | purpose |
|---|---|
h2adj.c |
kernel-module harness calling the real hammer2_freemap_adjust() with crafted radix 16/30/44 |
Makefile.h2adj |
bsd.kmod.mk build for the harness |
forge.c |
HAMMER2 image forger (patches sroot_blockset + forges volume-header CRCs) |
dump_volhdr.c |
volume-header field dumper (diagnostic) |
fix.diff |
git-apply-able fix (validates radix range before use) |
build.sh / run.sh |
reproducible build & run |
panic.txt |
baseline panic signature from boot.log |
fix_build.log |
single-fix kernel build output (rc=0) |
fix_run.log |
patched-kernel re-run (graceful returns, no panic) |
env.txt |
guest environment (uname, cc, INVARIANTS in config) |
Fix verification
fixedVALIDATED the fix: built a single-fix kernel (nativekernel rc=0, fix_build.log), installed it (kernel.stripped -> /boot/kernel/kernel, verified BuildID changed from b18d2eb8 to ae5b1415, kern.version bumped #0->#1), rebooted, and re-ran the SAME harness. On the unpatched #0 baseline, radix=30 panics at hammer2_freemap.c:980 (guest dead in DDB). On the patched #1 kernel, radix=30 and radix=44 both return gracefully with 'ignoring bad radix N' (4-5ms each, guest alive and responsive), while radix=16 (valid) still processes normally (0ms). The fix closes the bug completely.
baseline #0: radix=16 elapsed=0ms (normal); radix=30 -> panic 'assertion radix <= HAMMER2_RADIX_MAX failed' at :980 (guest dead). patched #1: radix=16 elapsed=0ms (normal); radix=30 -> 'ignoring bad radix 30' graceful 4ms; radix=44 -> 'ignoring bad radix 44' graceful 5ms; guest alive.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_freemap.c:977
- sys/vfs/hammer2/hammer2_freemap.c:978
- sys/vfs/hammer2/hammer2_freemap.c:980
- sys/vfs/hammer2/hammer2_freemap.c:1089
- sys/vfs/hammer2/hammer2_freemap.c:1107
- sys/vfs/hammer2/hammer2_vfsops.c:1335
- sys/vfs/hammer2/hammer2_vfsops.c:2234
- sys/vfs/hammer2/hammer2_vfsops.c:2325
- sys/vfs/hammer2/hammer2_disk.h:461
- sys/vfs/hammer2/hammer2_disk.h:630
- sys/vfs/hammer2/hammer2_disk.h:89
Detail
Exploit chain
none (pure DoS -- no memory-corruption primitive; the unchecked radix drives only a KKASSERT panic on GENERIC or a CPU-burn loop on noinv, neither of which yields a write/UAF/controlled-corruption primitive). No escalation chain applicable.
Evidence (decisive lines)
BASELINE unpatched #0 GENERIC: 'H2ADJ: radix=16 ... elapsed=0 ms (normal)' then 'panic: assertion "radix <= HAMMER2_RADIX_MAX" failed in hammer2_freemap_adjust at /usr/src/sys/vfs/hammer2/hammer2_freemap.c:980' / 'hammer2_freemap_adjust() at hammer2_freemap_adjust+0x3d9' / 'h2adj_load() at h2adj_load+0x114' / 'Stopped at Debugger+0x7c'. PATCHED #1 kernel: 'hammer2_freemap_adjust: ...: ignoring bad radix 30' (graceful, 4ms) and 'ignoring bad radix 44' (graceful, 5ms) -- guest alive, no panic.
PoC changes
Created the full evidence pack from scratch (no prior PoC existed). Wrote h2adj.c (kernel-module harness calling the real exported hammer2_freemap_adjust with crafted radix 16/30/44, timed via nanouptime), forge.c (HAMMER2 image forger that patches sroot_blockset + forges the 3 volume-header CRC-32C values with a from-scratch CRC-32C implementation verified against the kernel's stored CRCs), dump_volhdr.c (volume-header field dumper), build.sh/run.sh/Makefile.h2adj, and fix.diff (radix range validation). Initial image-mount attempt (corrupting sroot_blockset[1]) did not trigger the function because the recovery scan's set-associative key ordering did not iterate the crafted slot; switched to the harness calling the real kernel function, which definitively reproduced the panic.
Verified recommended fix
Add an explicit radix range check immediately after extracting the radix at hammer2_freemap.c:977: if(radix==0 || radix > HAMMER2_RADIX_MAX) { kprintf warning; return; }. This converts the mount-time panic (GENERIC) / CPU-burn (noinv) into a graceful skip of the invalid blockref during recovery. The full git-apply-able diff is in findings/poc/DF-0822/fix.diff. Supersedes the finding markdown's proposal (same intent: validate radix + early return, but this is the verified line-accurate patch).
Verdict
REPRODUCED. hammer2_freemap_adjust() (sys/vfs/hammer2/hammer2_freemap.c:945) extracts the allocation radix from an attacker-controlled on-disk blockref.data_off field (low 6 bits, range 0-63, hammer2_disk.h:630/461) at line 977 without validation. The only guards are KKASSERT(radix!=0) at :978 and KKASSERT(radix<=HAMMER2_RADIX_MAX=16) at :980, which on the default GENERIC kernel (options INVARIANTS, confirmed in X86_64_GENERIC:56) expand to panic() calls. A radix>=17 from a crafted HAMMER2 image therefore panics the kernel at mount time. Confirmed by a kernel-module harness (h2adj.c) that calls the REAL exported hammer2_freemap_adjust() (symbol 0xffffffff80983110) on the live root HAMMER2 filesystem with radix=30: the kernel printed 'panic: assertion "radix <= HAMMER2_RADIX_MAX" failed in hammer2_freemap_adjust at hammer2_freemap.c:980' and dropped to DDB (db>). radix=16 (valid max) completed in 0ms. On a production/no-INVARIANTS kernel the KKASSERT is compiled to do-while(0) (systm.h:118), so radix=44 falls through to count=1<<(44-14)=1<<30 at :1089 and the while(count) loop at :1107 burns ~1 billion iterations -- the sustained CPU-burn DoS of the finding title. Both variants are mount-time DoS reachable via hammer2_recovery_scan (hammer2_vfsops.c:2234,2325) which runs on every writable mount (:1335).
No comments yet.