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.
Recommended fix
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
- sys/vfs/hammer2/hammer2_chain.c:5308-5311 (panic site)
- findings/poc/DF-2628/gen_names.c (deterministic window-fill generator)
- DF-2628 (verification run that surfaced this)
Timeline
- 2026-08-28 Discovered during DF-2628 PoC verification (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2632 Β· 29 filesDF-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
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):
fill2632batch=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")denseprobebatch=16 with sync+200 ms pause per batch (density_probe.log): last progress linecreated=96, panic beforecreated=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 fullhammer2_flush_core β ... β base_insertbacktrace β 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 key0xdeadbeef60428000, 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 dropschain->core.spinaround each move, chain.c:4191-4220). The spurious unbalance drivestd_critcountnegative, 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:
naturalprobereplicateshammer2_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/:
hammer2_base_insert()returnsint; the overlap case now skips the insert before any stats side effects, rate-limited diagnostic (krate 1/s) instead ofpanic(), and β critically β does NOT touch the spinlock (the old panic path's unconditionalspin_unexwas the critcount corruptor). Callers updated:hammer2_chain_rename_obrefdefers by settingHAMMER2_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.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 withreturn 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 trippedKKASSERT(chain->core.live_count == 0 && RB_EMPTY(...))inhammer2_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.- Rate-limited
krateprintfdiagnostics 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
fixedA/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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_chain.c:5308-5311
- sys/vfs/hammer2/hammer2_chain.c:5309
- sys/vfs/hammer2/hammer2_chain.c:4921-5001
- sys/vfs/hammer2/hammer2_chain.c:4065-4231
- sys/vfs/hammer2/hammer2_chain.c:4215-4217
- sys/vfs/hammer2/hammer2_chain.c:3487-3509
- sys/vfs/hammer2/hammer2_chain.c:3501
- sys/vfs/hammer2/hammer2_chain.c:3829
- sys/vfs/hammer2/hammer2_chain.c:4651-4673
- sys/vfs/hammer2/hammer2_chain.c:2314
- sys/vfs/hammer2/hammer2_flush.c:1039-1042
- sys/vfs/hammer2/hammer2_flush.c:1116-1134
- sys/vfs/hammer2/hammer2_subr.c:178-229
- sys/vfs/hammer2/hammer2_inode.c:1311-1327
- sys/sys/thread2.h:220-223
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.
No comments yet.