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

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

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

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2650 Β· 14 files
FileTypeDescriptionSize
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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Sun Aug 30 00:18:56 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (both bulkfree.c fixes applied)

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