Unvalidated on-disk B-Tree leaf data_len consumed as `bytes` by hammer_blockmap_free/dedup/finalize with INVARIANTS-only bounds β deterministic panic from unprivileged unlink on crafted nohistory HAMMER; production: int32 layer2 bytes_free wrap and force-free of a still-referenced big-block
Summary
hammer_blockmap_free() receives bytes = leaf->data_len copied verbatim from the on-disk B-Tree element (hammer_object.c:2512-2513 via hammer_delete_at_cursor :2542) and applies it to freemap accounting after only KKASSERT(bytes <= HAMMER_XBUFSIZE) (:786) - INVARIANTS-only. The node CRC guarding the leaf is forgeable (DF-3011 precedent); no data CRC checked on the destroy path. hammer_blockmap_dedup (:909-910) and hammer_blockmap_finalize (:1009-1010) share the pattern. VERIFIED stock INVARIANTS guest: sparse-file record patched to data_len=0x80000 with recomputed CRC32C, nohistory mount, 'rm' as uid 1001 -> panic at :786, backtrace hammer_blockmap_free<-hammer_delete_at_cursor<-hammer_ip_delete_range<-hammer_sync_inode (flusher slave), guest wedged in DDB. Trigger needs no ioctl/capability post-mount (mount needs root or vfs.usermount=1 - DF-3003/3011/3040 family model). Production kernels: 'layer2->bytes_free += bytes' (:833) is int32 arithmetic; with crafted initial bytes_free the force-free branch (:853-874) resets layer2 zone/append_off and returns the big-block to the free pool while other records still reference it - silent freemap corruption/stale data on the mounted fs. No kernel memory unsafety (bytes never sizes a copy). Fix validated in-guest (production bounds checks in free/dedup/finalize): crafted image + unpriv rm -> console warning, rc=0, clean unmount, guest up.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3076 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.4 KB | β raw | |
| VERDICT.md | β | 5.8 KB | β raw | |
| lenforge.c | β | 6.6 KB | view raw | |
| icrc32.c | β | 43.1 KB | view raw | |
| build.sh | β | 170 B | view raw | |
| setup.sh | β | 695 B | view raw | |
| run.sh | β | 681 B | view raw | |
| build.log | β | 9 B | view raw | |
| run.log | β | 1.4 KB | view raw | |
| panic.txt | β | 14.4 KB | view raw | |
| env.txt | β | 233 B | view raw | |
| fix.diff | β | 1.8 KB | view raw | |
| fix_build.log | β | 5.2 MB | β download | |
| fix_run.log | β | 609 B | view raw |
DF-3076 β unvalidated on-disk leaf->data_len flows into hammer_blockmap_free/dedup/finalize as bytes with INVARIANTS-only bounds β deterministic panic from unprivileged unlink; production: int32 bytes_free accounting wrap / force-free of a referenced big-block
Kernel: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), sys/vfs/hammer
File under audit: sys/vfs/hammer/hammer_blockmap.c (pass 2)
Reproduce (guest, root unless noted)
# 0. build the forger (needs /usr/src/sys/vfs/hammer headers)
sh build.sh # cc -O -o lenforge lenforge.c icrc32.c -I/usr/src/sys/vfs/hammer
# 1. one-time base image: sparse 1MB file -> single zone-10 DATA record
# key=0x110000, real data_len=65536, nohistory mount, clean umount
sh setup.sh # SETUP_OK
# 2. forge: patch that record's data_len 65536 -> 524288 (0x80000),
# recompute node CRC32C, mount nohistory
sh run.sh A # MOUNT_OK ... READY: rm -f /mnt/target.bin
# 3. TRIGGER AS AN UNPRIVILEGED USER (uid 1001)
rm -f /mnt/target.bin # rc=0; destruction is async (flusher)
sleep 5 # -> kernel panic
Expected output (stock INVARIANTS kernel #0)
Serial console (full capture in panic.txt / run.log):
panic: assertion "bytes <= HAMMER_XBUFSIZE" failed in hammer_blockmap_free at /usr/src/sys/vfs/hammer/hammer_blockmap.c:786
hammer_blockmap_free() at hammer_blockmap_free+0x39a
hammer_delete_at_cursor() at ...
hammer_ip_delete_range() at ...
hammer_sync_inode() at ...
hammer_flusher_slave_thread() at ...
Debugger("panic") -> guest wedged at db>
Control (run.sh keep, unpatched image): same unprivileged rm completes,
clean unmount, no panic (run.control section of fix_run.log).
Fix kernel (fix.diff, make nativekernel): same crafted image, same
unprivileged rm β single console warning
hammer_blockmap_free: blockmap_free: bad bytes 524288 for a000000022000000
rm completes, clean unmount, guest stays up (fix_run.log).
Why data_len=0x80000 (not 0x7FFFFFF0)
hammer_ip_delete_range has its own left-edge guard
(hammer_object.c:1997, off = key - data_len) that fires before the
blockmap when the crafted length underflows the record key. A sparse file
(single record, key=0x110000, scan starts at ran_beg=0) admits any
data_len <= key β 0x80000 threads that needle while still exceeding the
:786 bound of 65536. (A dense-file variant with data_len=0x7FFFFFF0
panics at the left-edge assert instead β also demonstrated, see
VERDICT.md; both are the same unvalidated-data_len class.)
Threat model
Mounting the crafted image requires root or vfs.usermount=1
(DF-0797/0798/3040 family threat model; guest ships vfs.usermount=0).
Post-mount, the panic trigger itself needs no privileges β file
deletion on the attacker's own filesystem, uid 1001 in the recorded run.
Debug (INVARIANTS) kernels: deterministic local DoS. Production kernels:
the assert is compiled out; layer2->bytes_free += bytes
(hammer_blockmap.c:833) wraps int32 arithmetic with attacker-chosen
initial bytes_free (freemap CRCs are forgeable), and landing the sum on
exactly HAMMER_BIGBLOCK_SIZE trips the force-free branch at :853
(zone=0, append_off=0, blocks_free++, freebigblocks++) for a big-block
still referenced by other records β silent freemap corruption / stale
data on the mounted fs.
DF-3076 VERDICT
REPRODUCED (stock INVARIANTS kernel #0, deterministic, unprivileged trigger): kernel panic in the audited function itself.
panic: assertion "bytes <= HAMMER_XBUFSIZE" failed in hammer_blockmap_free
at /usr/src/sys/vfs/hammer/hammer_blockmap.c:786
hammer_blockmap_free() at hammer_blockmap_free+0x39a
hammer_delete_at_cursor() at hammer_delete_at_cursor+0x6b9
hammer_ip_delete_range() at hammer_ip_delete_range+0x18f
hammer_sync_inode() at hammer_sync_inode+0x3c7
hammer_flusher_slave_thread()
Guest wedged in DDB; reproduction required a vm.sh reset with-src.
Root cause (line-accurate)
hammer_blockmap_free() takes bytes from its caller with no
production validation (sys/vfs/hammer/hammer_blockmap.c:762):
:785bytes = HAMMER_DATA_DOALIGN(bytes);β 16-byte alignment only; data_len up to0x7FFFFFF0passes through unchanged (and DOALIGN of0x7FFFFFF1..0x7FFFFFFFwraps negative).:786KKASSERT(bytes <= HAMMER_XBUFSIZE);β INVARIANTS-only; this is the only size bound on the whole path.- The value originates on media:
hammer_delete_at_cursor()copiesdata_len = elm->leaf.data_lenstraight from the on-disk B-Tree element (hammer_object.c:2512-2513) and hands it tohammer_blockmap_free()for zone 9/10/11 records (hammer_object.c:2538-2543). The leaf's node CRC is forgeable by the image author (CRC32C is not a security barrier β DF-3011 precedent). - Same pattern in
hammer_blockmap_dedup()(:909-910,KKASSERT(bytes <= HAMMER_BIGBLOCK_SIZE), reached via the SYSCAP-gated dedup ioctl,hammer_dedup.c:134) andhammer_blockmap_finalize()(:1009-1010, frontend-bounded in practice) β hardened in the same fix.diff.
Reachability (unprivileged post-mount)
rm -f /mnt/target.bin as uid 1001 on a nohistory mount β inode
teardown in the flusher slave (hammer_sync_inode β
hammer_ip_delete_range) β hammer_ip_delete_record passes
HAMMER_DELETE_DESTROY because hammer_nohistory(ip)
(hammer_object.c:2234-2239) β hammer_delete_at_cursor:2542. No
ioctl, no capability β just file deletion on the (crafted) filesystem.
Mounting the crafted image itself needs root or vfs.usermount=1
(guest default 0) β identical preconditions to the DF-3003/3011/3040
crafted-media family.
What the PoC had to thread (why 0x80000)
hammer_ip_delete_range ratchets ran_beg = off per deleted record
(hammer_object.c:2054) and panics on the "left edge case"
(:1997-2000) when key - data_len < ran_beg β a shield that sits
upstream of the audited sink and fires for aggressively-large lengths:
- Dense 1MB file + data_len=0x7FFFFFF0 β panic
hammer left edge case 0000000000000020 2147483632(demonstrated, pre-fix run 1). - Dense 1MB file, last record + data_len=0x80000 β still left-edge
(
ran_begratcheted to 0xF8000; demonstrated, run 2). - Sparse file (single zone-10 record key=0x110000, scan begins at
ran_beg=0) + data_len=0x80000 β€ key β passes both edge checks
(
:1997,:2016) β reacheshammer_blockmap_freeβ:786panic. This is the recorded reproduction.
Impact
- INVARIANTS kernels (recorded): deterministic local kernel panic / DoS from an unprivileged file deletion on the crafted fs. The guest is single-user here, but the same code runs on any debug-built production DragonFly box that mounts untrusted media.
- Production kernels (code-trace, not run β no production kernel
built): the assert is compiled out.
layer2->bytes_free += bytes(:833) is int32 arithmetic; with attacker-chosen initialbytes_free(freemap layer2 CRCs forgeable, e.g.bytes_free=0x80800010+bytes=0x7FFFFFF0β exactlyHAMMER_BIGBLOCK_SIZEmod 2^32) the force-free branch at:853-874fires:layer2->zone = 0; layer2->append_off = 0; ++blocks_free; ++vol0_stat_freebigblocksβ releasing a big-block still referenced by other records (the crafted data_len is fictional). Consequence: silent freemap corruption and stale/cross-record data on the mounted filesystem; the reservation machinery that normally prevents reuse (hammer_reserve_setdelay_offset,:857) is engaged but the block still returns to the allocator pool. No kernel memory unsafety was found on this path βbytesnever sizes a copy in this file; every layer1/layer2 access is in-bounds of a 16K buffer-cache buffer. Severity ceiling therefore Low/Medium (DoS on debug kernels, own-fs integrity corruption on production), consistent with the DF-3033 calibration.
Fix validation (fix.diff, kernel #1 Sun Sep 6 00:57:37 UTC 2026)
fix.diff adds production bounds checks to all three consumers
(free: warn+return; dedup/finalize: EINVAL):
make nativekernel KERNCONF=X86_64_GENERICβ NK_RC=0,make installkernelβ IK_RC=0 (fix_build.log).- Baseline (stock #0): panic at
:786, guest wedged (run.log). - Patched #1, same crafted image, same unprivileged rm:
hammer_blockmap_free: blockmap_free: bad bytes 524288 for a000000022000000on console, rm rc=0, clean unmount, guest stays up (fix_run.log). - Control on patched kernel (unpatched image): rm clean, no warning beyond the crafted record, clean unmount.
Artifacts
lenforge.c/icrc32.c/build.shβ image forger (B-Tree walk, max-key DATA record selection, data_len patch, node CRC32C recompute)setup.shβ base image (sparse target file, nohistory clean umount)run.sh A|B|keepβ forge+mount runnerrun.logβ baseline reproduction transcript + panicpanic.txtβ full serial console capture (panic at hammer_blockmap.c:786)fix_build.log,fix_run.logβ fix kernel build + patched-behavior runenv.txtβ guest environment (uname, cc, vfs.usermount=0)fix.diffβ the validated fix (git-apply-able; never applied to sys/)
Fix verification
fixedfix.diff (production bounds checks in free/dedup/finalize) built via make nativekernel KERNCONF=X86_64_GENERIC (NK_RC=0, IK_RC=0). Patched kernel, identical crafted image and unprivileged trigger: no panic, console warning 'hammer_blockmap_free: blockmap_free: bad bytes 524288 for a000000022000000', rm rc=0, clean unmount, guest stays up. Control image unpatched run: clean on both kernels.
["fix_build.log: '>>> Kernel install for X86_64_GENERIC completed on Sun Sep 6 01:13:22 UTC 2026'", 'fix_run.log: patched-run serial warning line + RM_RC=0 + CLEAN_UMOUNT_OK + guest up; control section clean']
Confirmed kernel references
- sys/vfs/hammer/hammer_blockmap.c:762
- sys/vfs/hammer/hammer_blockmap.c:785-787
- sys/vfs/hammer/hammer_blockmap.c:833
- sys/vfs/hammer/hammer_blockmap.c:853-874
- sys/vfs/hammer/hammer_blockmap.c:909-910
- sys/vfs/hammer/hammer_blockmap.c:1009-1010
- sys/vfs/hammer/hammer_object.c:2512-2513
- sys/vfs/hammer/hammer_object.c:2538-2543
- sys/vfs/hammer/hammer_object.c:1997-2000
- sys/vfs/hammer/hammer_object.c:2054
Detail
Exploit chain
crafted HAMMER1 image (root mount or vfs.usermount=1) -> sparse file's single zone-10 DATA record data_len 65536->524288, node CRC32C recomputed -> nohistory mount -> unprivileged rm -> flusher slave hammer_sync_inode -> hammer_ip_delete_range (left-edge checks dodged: single record, ran_beg=0, 0x80000 <= key 0x110000) -> hammer_ip_delete_record passes HAMMER_DELETE_DESTROY (nohistory) -> hammer_delete_at_cursor:2542 -> hammer_blockmap_free:786 KKASSERT -> panic/DDB. Ceiling: DoS on INVARIANTS kernels; own-fs accounting corruption + referenced-big-block force-free on production kernels; no uid0 route (no memory corruption primitive).
Evidence (decisive lines)
['run.log: forge output (PATCH data_len 65536 -> 524288) + serial panic \'assertion "bytes <= HAMMER_XBUFSIZE" failed in hammer_blockmap_free at hammer_blockmap.c:786\'', 'panic.txt: full serial console capture with the hammer_blockmap_free <- hammer_delete_at_cursor <- hammer_ip_delete_range <- hammer_sync_inode backtrace, guest stopped at db>', "fix_run.log: patched kernel #1 -> 'hammer_blockmap_free: blockmap_free: bad bytes 524288 for a000000022000000', RM_RC=0, CLEAN_UMOUNT_OK, guest up; control (unpatched image) also clean", 'VERDICT.md: left-edge shield analysis (why data_len must satisfy key-data_len>=0) and the two pre-probes']
PoC changes
Authored fresh forger (lenforge.c) instead of using a seed: B-Tree walk from vol0_btree_root, selects the max-key DATA record (must satisfy key-data_len>=0 to pass hammer_ip_delete_range's left-edge shield), patches data_len, recomputes the v7 node CRC32C (icrc32.c from the DF-3011 pack). Two probe runs established the left-edge constraint before the successful 0x80000/sparse-file variant.
Verified recommended fix
Replace the INVARIANTS-only KKASSERT size bounds in hammer_blockmap_free (:786), hammer_blockmap_dedup (:910) and hammer_blockmap_finalize (:1010) with production checks that reject bytes <= 0 or bytes above the zone limit before touching layer2 accounting (see fix.diff); longer term validate leaf->data_len at the B-Tree extract boundary (same entry point as DF-3033's fix).
Verdict
Deterministic kernel panic in hammer_blockmap_free at hammer_blockmap.c:786 (assertion "bytes <= HAMMER_XBUFSIZE") from an unprivileged rm (uid 1001) on a crafted nohistory HAMMER1 image: the on-disk leaf's data_len (patched to 0x80000 with a recomputed node CRC32C) flows through hammer_ip_delete_range -> hammer_delete_at_cursor:2542 into hammer_blockmap_free with only an INVARIANTS-only KKASSERT as bound. Guest wedged in DDB; reproduced once from a fresh forged mount after two left-edge-shield probes (same class, different assert - documented). Production kernels compile the assert out: layer2->bytes_free += bytes (:833) is int32 arithmetic and with forged initial bytes_free the ==HAMMER_BIGBLOCK_SIZE force-free branch (:853) releases a big-block still referenced by other records - silent freemap corruption on the mounted fs, not kernel memory unsafety (bytes never sizes a copy in this file). Fix validated: production bounds checks in free/dedup/finalize turn the panic into a console warning, rm completes, clean unmount, guest stays up.
No comments yet.