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

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)

No comments yet.

PoC verification

Evidence pack

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

VERDICT.md
↓ download raw

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

  • :785 bytes = HAMMER_DATA_DOALIGN(bytes); β€” 16-byte alignment only; data_len up to 0x7FFFFFF0 passes through unchanged (and DOALIGN of 0x7FFFFFF1..0x7FFFFFFF wraps negative).
  • :786 KKASSERT(bytes <= HAMMER_XBUFSIZE); β€” INVARIANTS-only; this is the only size bound on the whole path.
  • The value originates on media: hammer_delete_at_cursor() copies data_len = elm->leaf.data_len straight from the on-disk B-Tree element (hammer_object.c:2512-2513) and hands it to hammer_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) and hammer_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_beg ratcheted 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) β†’ reaches hammer_blockmap_free β†’ :786 panic. 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 initial bytes_free (freemap layer2 CRCs forgeable, e.g. bytes_free=0x80800010 + bytes=0x7FFFFFF0 β†’ exactly HAMMER_BIGBLOCK_SIZE mod 2^32) the force-free branch at :853-874 fires: 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 β€” bytes never 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 a000000022000000 on 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 runner
  • run.log β€” baseline reproduction transcript + panic
  • panic.txt β€” full serial console capture (panic at hammer_blockmap.c:786)
  • fix_build.log, fix_run.log β€” fix kernel build + patched-behavior run
  • env.txt β€” guest environment (uname, cc, vfs.usermount=0)
  • fix.diff β€” the validated fix (git-apply-able; never applied to sys/)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 6 00:57:37 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.