KKASSERT(radix != 0) in h2_bulkfree_callback β deterministic unprivileged kernel panic from a radix-0 on-disk data_off
| Field | Value |
|---|---|
| ID | DF-2650 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:C |
| CWE | CWE-617 Reachable Assertion |
| File | sys/vfs/hammer2/hammer2_bulkfree.c |
| Lines | 847-849 (filter gap at :179) |
| 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
h2_bulkfree_callback computes bytes = 1 << radix from the blockref
data_off radix field and asserts radix != 0 (bulkfree.c:847-849). The
only earlier filter (:179) skips brefs whose data_off is entirely zero; a
bref with a non-zero base but radix == 0 passes it and, on INVARIANTS
kernels (the stock X86_64_GENERIC guest is one), trips the KKASSERT.
On non-INVARIANTS kernels the record is misaccounted as a 1-byte
fragment (one 16K block marked allocated, avail -= 16384) β no memory
corruption.
Threat model & preconditions
Any local user on a machine with a mounted hammer2 filesystem whose media contains a radix-0 data_off (crafted image via mount privilege / vfs.usermount, or bit corruption): deterministic panic on INVARIANTS kernels, freemap misaccounting on production kernels. Unprivileged reachability piggybacks DF-0815's ungated BULKFREE ioctls (the ioctl dispatcher ignores the caps_priv_check failure).
Proof of concept
VERIFIED end-to-end on the stock guest (findings/poc/DF-2650/):
forge_2650.py plants a radix-0 DATA bref (data_off 0x1800800) key-sorted
into the mounted PFS's root-inode blockset (CHECK_NONE ancestors, volhdr
CRC32C recompute); mount survives (mount never walks it); then
su -m nobody trigger /mnt/h2x/f1 (HAMMER2IOC_BULKFREE_SCAN,
_IOWR('h',92,64), bfi.size=8MB) β
panic: assertion "radix != 0" failed in h2_bulkfree_callback at
hammer2_bulkfree.c:848, guest down at db>. Fix (kprintf + graceful
skip) validated on a rebuilt kernel: same PoC prints
illegal radix-0 data_off 0000000001800800 e000000000000003/64 and
completes (r=0, no panic) β fix_run.log. No userβroot route
(panic-only class).
Recommended fix
--- a/sys/vfs/hammer2/hammer2_bulkfree.c
+++ b/sys/vfs/hammer2/hammer2_bulkfree.c
@@ -845,7 +845,20 @@
radix = (int)(bref->data_off & HAMMER2_OFF_MASK_RADIX);
- KKASSERT(radix != 0);
+ /*
+ * DF-2650: a radix of 0 means a 1-byte allocation, which cannot
+ * exist on media. On-disk corruption or a crafted image must not
+ * panic an INVARIANTS kernel; skip the record instead of marking
+ * a bogus fragment.
+ */
+ if (radix == 0) {
+ kprintf("hammer2_bulkfree_scan: illegal radix-0 data_off "
+ "%016jx %016jx/%d\n",
+ (intmax_t)bref->data_off,
+ (intmax_t)bref->key,
+ bref->keybits);
+ return 0;
+ }
bytes = (size_t)1 << radix;
References
- DF-0815 (the ungated ioctl that makes this unprivileged-reachable), DF-2616 family (data_off geometry trust)
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_bulkfree.c (GLM 5.3); verified reproduced (panic as nobody) + fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2650 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge_2650.py | β | 3.7 KB | view raw | |
| h2common.py | β | 3.8 KB | view raw | |
| trigger.c | β | 1.2 KB | view raw | |
| build.sh | β | 873 B | view raw | |
| run.sh | β | 736 B | view raw | |
| build.log | β | 597 B | view raw | |
| run.log | β | 1.9 KB | view raw | |
| panic.txt | β | 1.1 KB | view raw | |
| env.txt | β | 190 B | view raw | |
| fix.diff | β | 775 B | view raw | |
| fix_build.log | β | 2.5 KB | view raw | |
| fix_run.log | β | 1.1 KB | view raw | |
| verdict.json | β | 4.7 KB | view raw | |
| manifest.json | β | 826 B | view raw |
Fix verification
fixedBaseline stock INVARIANTS kernel #0: panic "assertion radix != 0 failed in h2_bulkfree_callback at hammer2_bulkfree.c:848", guest down. Fixed kernel (make nativekernel + installkernel, -Werror clean): same crafted image mounts, same unprivileged ioctl completes with r=0, console shows the new "illegal radix-0 data_off 0000000001800800 e000000000000003/64" warning and the full scan finishes (100.00% storage processed). Bad behavior gone.
['fix_build.log (build excerpts + completion)', 'fix_run.log (patched-kernel run: warning + clean completion, no panic)']
Confirmed kernel references
Detail
Exploit chain
crafted image (mount privilege) -> PFS root inode blockset slot forged with radix-0 DATA bref (CHECK_NONE ancestors, volhdr CRC32C recomputed) -> mount @testvol succeeds -> unprivileged user opens /mnt/h2x/f1 -> ioctl(HAMMER2IOC_BULKFREE_SCAN) ignores caps_priv_check -> topology scan walks PFS root blockset -> h2_bulkfree_callback -> KKASSERT(radix != 0) panic
Evidence (decisive lines)
['panic.txt: \'panic: assertion "radix != 0" failed in h2_bulkfree_callback at /usr/src/sys/vfs/hammer2/hammer2_bulkfree.c:848\' with backtrace h2_bulkfree_callback+0x2a9 <- hammer2_bulkfree_scan.constprop.2 <- hammer2_bulkfree_pass', 'run.log: trigger run as uid 65534 on stock kernel #0, guest wedged (ssh timeout), serial console shows the panic', 'forge_2650.py: plants DATA bref {type=3, key=0xe000000000000003, data_off=0x1800800 (radix==0)} in blockset slot 2 of the testvol root inode; CHECK_NONE on testvol bref + sroot bref; volhdr CRCs recomputed', "fix_run.log: fixed kernel (fix.diff) prints 'illegal radix-0 data_off' warning and completes the bulkfree (no panic)"]
PoC changes
Forger derived from DF-2624 tooling (h2common.py reused verbatim); key fix vs first attempt: the poison bref key must be sorted above the sibling dirent key (hammer2_base_find breaks on the first slot with key >= search key), radix-0 data_off with a valid in-volume base; trigger.c issues HAMMER2IOC_BULKFREE_SCAN (_IOWR('h',92,64B)) directly as an unprivileged user instead of hammer2(8).
Verified recommended fix
Replace KKASSERT(radix != 0) in h2_bulkfree_callback with a kprintf + return 0 (skip the record), as in fix.diff.
Verdict
Reproduced on the stock INVARIANTS kernel #0: a crafted hammer2 image carrying a DATA bref whose data_off radix field is 0 (base 0x1800800, radix bits 0), planted key-sorted into a free blockset slot of the mounted PFS's root inode, survives mount (mount never walks the mounted PFS's root blockset) and, once any local user issues the ungated HAMMER2IOC_BULKFREE_SCAN ioctl (caps_priv_check result ignored, DF-0815), the bulkfree topology scan feeds the bref to h2_bulkfree_callback which trips KKASSERT(radix != 0) at hammer2_bulkfree.c:848 -> kernel panic with full backtrace (h2_bulkfree_callback <- hammer2_bulkfree_scan <- hammer2_bulkfree_pass). Guest down at db> prompt. Non-INVARIANTS kernels compile the assert out and misaccount the record as a 1-byte fragment (marks a 16K block allocated) without memory corruption, so the impact ceiling is a deterministic local DoS on INVARIANTS kernels (plus freemap misaccounting elsewhere).
No comments yet.