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

hammer2 flusher panics on dense dirent packing: 'insert base overlapping elements' (unprivileged local DoS)

Field Value
ID DF-2632
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-617 Reachable Assertion / CWE-682 Incorrect Calculation
File sys/vfs/hammer2/hammer2_chain.c
Lines 5308-5311
Area vfs
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass β€” surfaced during DF-2628 PoC verification)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

Densely packing directory entries into a single 64KB dirhash window (HAMMER2_DIRHASH_LOMASK = 0x7FFF, i.e. 32768 slots) drives the hammer2 flusher into hammer2_base_insert's overlap check and panics the kernel: panic: insert base %p overlapping elements (hammer2_chain.c:5308-5311), reached via hammer2_flush_core β†’ hammer2_chain_indirect_maintenance β†’ hammer2_chain_rename_obref. Reproduced on both INVARIANTS (crit-count assertion fires first) and no-INVARIANTS (structural panic) stock kernels β€” transcripts in findings/poc/DF-2632/.

Root cause

hammer2_base_insert asserts/validates that a newly inserted element does not overlap existing rb-tree elements in the indirect block being built during flush-time block redistribution. Dense dirent packing in one dirhash window produces an overlapping key range during hammer2_chain_indirect_maintenance, i.e. the flusher's key compaction logic can generate an intermediate overlap state that the base-tree insert treats as fatal. The overlap arises from key-range arithmetic (key/keybits) during indirect block splitting at window-full boundaries.

Threat model & preconditions

  • Attacker position: unprivileged local user creating ~32768+ files in one directory whose names hash into the same 64K dirhash window. Natural names collide into windows stochastically; a CRC32C-multicollision name generator (see findings/poc/DF-2628/gen_names.c, 34000 same-CRC32C names) makes it deterministic. Single-user systems are equally exposed via large build trees / cache dirs.
  • Impact: deterministic kernel panic (local DoS), no forensics left on the dead fs; repeated on every flush until the directory is thinned from another boot.
  • Reachability: plain open(O_CREAT) in a loop β€” no mounted-image prerequisite, default hammer2 root filesystem.

Proof of concept

./gen_names deadbeef 34000 names.txt   # same-CRC32C names β†’ one window
./u2628_poc fill <dir> names.txt 32768 # deterministic fill β†’ panic

Full transcripts: findings/poc/DF-2632/serial_panic_invariants.log, serial_panic_noinv.log (from the DF-2628 verification run).

Impact

Unprivileged local kernel panic via ordinary file creation; panic repeats across reboots while the dense directory persists.

Investigate the key-range arithmetic in hammer2_chain_indirect_maintenance/hammer2_chain_rename_obref during window-boundary splits; the insert should never see an overlap it created itself. Short term: convert the hard panic into a corruption-tolerant skip (rate-limited kprintf + error propagation) so a dense directory degrades I/O instead of killing the kernel β€” the data on media is consistent; only the in-memory redistribution state overlaps.

References

Timeline

  • 2026-08-28 Discovered during DF-2628 PoC verification (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2632 Β· 29 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 7.4 KB ↓ raw
fill2632.c β€” 2.1 KB view raw
denseprobe.c β€” 1.5 KB view raw
naturalprobe.c β€” 4.4 KB view raw
gen_names.c β€” 6.2 KB view raw
run_df2632.sh β€” 781 B view raw
run_probe.sh β€” 526 B view raw
run_natural.sh β€” 843 B view raw
validate_fix.sh β€” 2.1 KB view raw
build.sh β€” 185 B view raw
run.sh β€” 577 B view raw
stock_run.log β€” 1.4 KB view raw
density_probe.log β€” 224 B view raw
natural_probe.log β€” 1.6 KB view raw
serial_panic_stock_standalone.log β€” 1.1 KB view raw
serial_panic_density96.log β€” 602 B view raw
serial_panic_invariants.log β€” 1.6 KB view raw
serial_panic_noinv.log β€” 1.9 KB view raw
fix_validation.log β€” 2.0 KB view raw
fix_validation_part2.log β€” 910 B view raw
fix_run.log β€” 961 B view raw
iteration1_assert.log β€” 1.0 KB view raw
build.log β€” 607 B view raw
fix_build.log β€” 322 B view raw
env.txt β€” 1.3 KB view raw
fix.diff β€” 10.8 KB view raw
verdict.json β€” 5.9 KB view raw
manifest.json β€” 1.5 KB view raw

DF-2632 β€” hammer2 flusher panics on dense dirent packing

insert base %p overlapping elements in hammer2_base_insert() (sys/vfs/hammer2/hammer2_chain.c:5308-5311), reached from the flusher's indirect-block maintenance: hammer2_flush_core β†’ hammer2_chain_indirect_maintenance β†’ hammer2_chain_rename_obref β†’ hammer2_base_insert. Unprivileged local DoS: any user with write access to one hammer2 directory can panic the box with ~100 specially-crafted filenames.

Trigger

hammer2_dirhash() (hammer2_subr.c:178-229) maps a delimiter-free name to key = 0x8.. | (crc32c << 32) | ((crc32c^(crc32c<<16)) & 0xFFFF0000) | 0x8000. All names sharing one CRC32C hash to the SAME key; hammer2_dirent_create() packs them into consecutive slots X8000, X8001, … of one 64K collision window (HAMMER2_DIRHASH_LOMASK = 0x7FFF).

gen_names.c (from findings/poc/DF-2628) emits arbitrary numbers of 8-char names sharing a chosen CRC32C (meet-in-the-middle, ~5.5 s for 40000 names on the guest).

fill2632.c creates the files (one empty file per name) in one directory with periodic sync(), printing progress so the panic-time count is recoverable from captured stdout.

Reproduce

# on the stock guest (root):
cc -O2 -o gen_names gen_names.c
cc -O2 -o fill2632 fill2632.c
truncate -s 512M /tmp/h2632.img
vnconfig -c vn1 /tmp/h2632.img
newfs_hammer2 -L DATA /dev/vn1
mkdir -p /mnt/h2632 && mount_hammer2 /dev/vn1@DATA /mnt/h2632
./gen_names deadbeef 40000 /tmp/names.txt
mkdir /mnt/h2632/dense
./fill2632 /mnt/h2632/dense /tmp/names.txt 128
# -> panic within the first batch (see serial_panic_stock_standalone.log)
# with fine-grained batches (denseprobe, batch=16, sync+200ms):
#    panics between 96 and 112 dense dirents (density_probe.log)

Expected (stock): panic: td_critcount is/would-go negative! with trace crit_panic ← spin_unlock ← hammer2_base_insert ← hammer2_chain_rename_obref ← hammer2_chain_indirect_maintenance (INVARIANTS kernel; the panic path's hammer2_spin_unex at chain.c:5309 runs while the caller holds no spin, driving td_critcount negative). A no-INVARIANTS build panics with the structural message instead (serial_panic_noinv.log, from DF-2628).

Fix validation (kernel #2, X86_64_GENERIC + fix.diff)

validate_fix.sh: 19818 dense same-CRC names created with ZERO errors, no panic (stock panics at 96-112), clean unmount, all 19818 files present after remount. Natural-name directories (22000 names, max 1 per dirhash window) unaffected. Stock-image write/sync/umount/remount md5 roundtrip unaffected. Fill throughput degrades as the window gets deep (deferred collapses retry, rate-limited 1/s console notice) β€” no panic, no data loss.

Files

fill2632.c / denseprobe.c / naturalprobe.c   triggers/probes
run_df2632.sh / run_probe.sh / run_natural.sh / validate_fix.sh
serial_panic_stock_standalone.log   fresh standalone stock panic (this run)
serial_panic_density96.log          panic at 96<N<=112 (batch=16)
density_probe.log / natural_probe.log / stock_run.log
fix_validation.log / fix_validation_part2.log
serial_panic_invariants.log / serial_panic_noinv.log   (DF-2628 transcripts)
fix.diff (shared DF-2632+DF-2633), VERDICT.md, verdict.json, manifest.json
VERDICT.md
↓ download raw

DF-2632 β€” VERDICT

Finding: hammer2 flusher panics with insert base %p overlapping elements (hammer2_base_insert, sys/vfs/hammer2/hammer2_chain.c:5308-5311) when directory-entry keys are densely packed into one 64K dirhash collision window β€” unprivileged local DoS.

Classification: REPRODUCED (panic), fix FIXED.

1. Reproduction (stock kernel #0, standalone, this run)

Fresh dedicated hammer2 image (512MB vn-over-tmpfs), one directory filled with same-CRC32C names from gen_names.c (crc32c target 0xdeadbeef):

  • fill2632 batch=128: connection dies inside the first batch; serial_panic_stock_standalone.log (serial console): panic with -1 spinlocks held panic: td_critcount is/would-go negative! 0xfffff80118428f80 -1 cpuid = 0 crit_panic() at crit_panic+0x2f spin_unlock() at spin_unlock+0x49 hammer2_base_insert() at hammer2_base_insert+0x652 hammer2_chain_rename_obref() at hammer2_chain_rename_obref+0x66 hammer2_chain_indirect_maintenance() at ...+0x411 Debugger("panic")
  • denseprobe batch=16 with sync+200 ms pause per batch (density_probe.log): last progress line created=96, panic before created=112 β†’ the deterministic trigger threshold is 96 < N ≀ 112 dense same-window dirents (serial_panic_density96.log).
  • (DF-2628's earlier transcripts, kept in this pack, show the same panic on a no-INVARIANTS rebuild with the structural message panic: insert base 0xfffff8005753e000 overlapping elements at 0 elm ... and the full hammer2_flush_core β†’ ... β†’ base_insert backtrace β€” proving it is not INVARIANTS-specific.)

2. Root cause (proven by the fix kernel's diagnostic output)

The fix kernel's deferred-insert diagnostic printed, on the first overlap:

hammer2: base_insert overlap deferred: parent 0xfffff8011943a100 type 2
    elm deadbeef60428000/7 type 2 vs base[0] deadbeef60428000/5 type 2

(type 2 = HAMMER2_BREF_TYPE_INDIRECT). Decoded:

  • The element being moved up by hammer2_chain_rename_obref (hammer2_chain.c:3501, called from indirect maintenance :4215) is an INDIRECT block with key 0xdeadbeef60428000, keybits 7 (range [0x...60428000, 0x...60428080)).
  • The parent's base[0] already holds an INDIRECT with the identical base key and keybits 5 (range [0x...60428000, 0x...60428020)) β€” a nested key range.

Mechanism: hammer2_chain_create_indirect normalizes an indirect's key to its radix (key &= ~((1<<keybits)-1), chain.c:3829) and the radix is chosen by the split heuristic (hammer2_chain_indkey_dir, chain.c:4651) each time an indirect must be created. With a densely-packed window the filler and the flusher interleave creates and collapses; two indirects at different radix levels normalize to the SAME base key, so their ranges nest instead of disjointly partitioning the space. When the flusher later collapses one (hammer2_chain_indirect_maintenance, chain.c:4065) and moves its children into the parent (rename_obref β†’ base_insert), hammer2_base_find (:4921) returns index 0 for the moved element and the overlap check at :5308-5311 fires: xkey (elm end) >= base[0].key because the ranges share their base key.

Two additional defects compound at the panic site:

  • The panic path at :5309 executes hammer2_spin_unex(&parent->core.spin) even though the indirect-maintenance call path does NOT hold that spin (the maintenance loop drops chain->core.spin around each move, chain.c:4191-4220). The spurious unbalance drives td_critcount negative, which is what the INVARIANTS kernel actually reports first (td_critcount is/would-go negative, sys/sys/thread2.h:220-223).
  • panic() then runs with corrupted crit/spin state ("panic with -1 spinlocks held").

3. Trigger density / natural-name analysis (honest negative)

  • Crafted names: the window needs only ~100 entries (96 < N ≀ 112 with per-16 syncs; DF-2628 saw 2-4K with coarser sync cadence β€” cadence changes how soon the flusher's maintenance runs against a dense window, not the mechanism).
  • Natural names: naturalprobe replicates hammer2_dirhash() in userland. For 25000 sequential + 25000 random 32-char names: names=50000 windows=50000 max-per-window=1 windows-with->1=0 β€” the window index carries ~47 bits of CRC entropy, so natural names NEVER make a window dense (a 100-entry natural collision would need ~2^47 names; even a single 2-entry collision has p β‰ˆ 2^-47 per pair). Live: 50000 natural names in one hammer2 directory with sync every 512: no panic (natural_probe.log). The finding therefore requires deliberate CRC32C multicollision construction β€” trivial with the MITM generator (5.5 s for 40000 names) but unreachable by accident. Severity Medium (local DoS) stands.

4. Fix (fix.diff, validated)

Three coordinated changes in sys/vfs/hammer2/:

  1. hammer2_base_insert() returns int; the overlap case now skips the insert before any stats side effects, rate-limited diagnostic (krate 1/s) instead of panic(), and β€” critically β€” does NOT touch the spinlock (the old panic path's unconditional spin_unex was the critcount corruptor). Callers updated: hammer2_chain_rename_obref defers by setting HAMMER2_CHAIN_BLKMAPUPD|HAMMER2_CHAIN_UPDATE (flush retries the blockref insertion later); hammer2_flush_core (hammer2_flush.c:1130) records the error, keeps UPDATE set and forces a flush retry.
  2. hammer2_chain_indirect_maintenance() gains a pre-flight check BEFORE any destructive step: snapshot all live child key ranges (media blockrefs + RBTREE chains) of the indirect being collapsed and test them against every parent slot except the chain's own; if any range would overlap, abort the collapse with return 0 (nothing has been modified, the flush defers, rate-limited notice). This is the load-bearing fix: a mid-collapse deferral (iteration 1 of the fix) left skipped children in the collapsed chain and tripped KKASSERT(chain->core.live_count == 0 && RB_EMPTY(...)) in hammer2_chain_repchange (chain.c:2314) β€” see fix_validation.log iteration note and serial console of kernel #1 in the run log; the pre-flight abort removes the deterministic trigger entirely and keeps the maintenance loop's invariants intact.
  3. Rate-limited krateprintf diagnostics for both defer paths.

5. Fix validation (kernel #2 = X86_64_GENERIC + fix.diff)

A/B against stock (same KERNCONF, INVARIANTS on):

test stock #0 fixed #2
dense same-CRC fill panic at 96-112 entries 19818 entries, 0 errors, no panic (stopped at milestone; fill slows as the window deepens β€” deferred collapses retry β€” but completes error-free)
dense files after umount+remount (n/a β€” panic) 19818/19818 present
natural names 22k in one dir ok ok (max 1/window), umount clean
healthy image write/sync/umount/remount md5 ok ok (match)

fix_validation.log + fix_validation_part2.log.

6. Notes / residual

  • The deferred-collapse state leaves the (ghost-range) indirect in place; the flusher retries and re-defers at most 1/s per rate limiter. No data loss observed (all 19818 files survived umount/remount).
  • The underlying nested-range creation (two radix levels normalizing to the same key during create/maintenance interleaving) is a deeper hammer2 topology issue; this fix makes it non-fatal and deferred, which is the correct minimal hardening for the panic. Upstream may want to revisit hammer2_chain_indkey_dir's split normalization.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

A/B on identical KERNCONF (X86_64_GENERIC, INVARIANTS): stock #0 panics at 96-112 dense dirents; fixed #2 filled 19818 dense dirents with zero errors and zero panics, all 19818 files present after umount+remount; natural-name dirs (22000 entries, max 1/window) and healthy-image md5 roundtrips unaffected; console shows the rate-limited defer notice (1/s) instead of the panic. The previously-observed bad behavior is GONE.

fix_validation.log, fix_validation_part2.log, fix_run.log, iteration1_assert.log, fix_build.log
↓ fix.diffDragonFly 6.5-DEVELOPMENT #2: Sat Aug 29 08:00:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unprivileged user with write access to one hammer2 directory: (1) generate ~200 8-char names sharing one CRC32C via meet-in-the-middle (gen_names.c, ~5s); (2) create the files with periodic sync(); (3) flusher's indirect-maintenance collapse hits the nested-range overlap at ~100 entries and panics the kernel - denial of service (no privilege escalation path; the bug is a topology-consistency panic, not memory corruption).

Evidence (decisive lines)

['serial_panic_stock_standalone.log - fresh stock panic: crit_panic <- spin_unlock <- hammer2_base_insert <- hammer2_chain_rename_obref <- hammer2_chain_indirect_maintenance', 'density_probe.log + serial_panic_density96.log - deterministic threshold 96<N<=112 dense dirents', 'natural_probe.log - 50000 natural names: max-per-window=1, no panic (trigger needs crafted collisions)', 'fix_validation.log + fix_validation_part2.log + fix_run.log - fixed kernel: 19818 dense entries, 0 errors, all files survive remount; natural + roundtrip unaffected', 'iteration1_assert.log - intermediate defer-in-loop variant tripped repchange KKASSERT; root-cause for the pre-flight design', 'VERDICT.md section 2 - the overlap diagnostic: elm deadbeef60428000/7 vs base[0] deadbeef60428000/5 (nested ranges)']

PoC changes

Materialized standalone triggers from the DF-2628 transcripts: fill2632.c (batch fill + progress so the panic-time count survives the dying ssh session), denseprobe.c (fine-grained density bracketing), naturalprobe.c (userland hammer2_dirhash replica + natural-name negative control). Adapted gen_names.c unchanged from DF-2628.

Verified recommended fix

hammer2_base_insert: skip+defer on overlap (error return, no stats side effects, no spinlock manipulation, rate-limited diagnostic) and abort indirect-maintenance collapses in a pre-flight key-range check before any destructive step, instead of panicking.

Verdict

Reproduced standalone on the stock INVARIANTS kernel: filling one hammer2 directory with ~100 CRC32C-multicollision names (same 64K dirhash window) panics the flusher via hammer2_flush_core -> hammer2_chain_indirect_maintenance -> hammer2_chain_rename_obref -> hammer2_base_insert ('insert base %p overlapping elements', chain.c:5308-5311; on INVARIANTS kernels the panic path's unconditional spin_unex at :5309 additionally corrupts td_critcount and reports 'td_critcount is/would-go negative'). Deterministic threshold measured at 96<N<=112 same-window dirents with per-16 syncs. The fix kernel's diagnostic proved the root cause: nested indirect-block key ranges (identical normalized base key deadbeef60428000, elm keybits 7 vs existing base[0] keybits 5) from create/flush interleaving under dense packing. Natural names cannot trigger it (window index carries ~47 bits of CRC entropy; 50000 natural names = max 1 per window, no panic), so the trigger requires deliberate multicollision construction - unprivileged local DoS, Medium. fix.diff (base_insert returns error + skip before stats side effects, no spinlock touch in the defer path; indirect-maintenance pre-flight abort of the whole collapse before any destructive step; flush_core retry-on-defer; rate-limited kprintf) validated on an identical-config rebuilt kernel: 19818 dense entries created with zero errors, no panic, all files survive umount+remount, natural-name dirs and healthy-image roundtrips unaffected.