Flush-driven indirect collapse contradicts its own skip guard: concurrent child COW-modify during hammer2_chain_indirect_maintenance leaves a live child, tripping the unconditional repchange emptiness KKASSERT (panic) or orphaning the subtree (silent data loss)
| Field | Value |
|---|---|
| ID | DF-2645 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-362 Concurrent Execution using Shared Resource |
| File | sys/vfs/hammer2/hammer2_flush.c |
| Lines | 1039-1042 (invariant sites chain.c:4160,4202-4209,2314) |
| Area | vfs |
| Confidence | likely |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
flush_core() drives every UPDATE-flagged INDIRECT into
indirect_maintenance (flush.c:1039-1042). The collapse loop skips
children whose in-memory bref no longer matches the media entry β exactly
the state a concurrent frontend modify creates after the flusher's own
scan flushed the child (COW assigns a fresh data_off via chain_modify).
The skip leaves the child live in the rbtree, but
hammer2_chain_repchange() (chain.c:4228 β 2314) unconditionally
KKASSERTs live_count==0 && RB_EMPTY β panic; on relaxed builds the
reptracks migrate while the child is still parented to the
already-deleted chain (chain.c:4160 deleted it from the grandparent),
making the child's media entry unreachable β silent data loss.
Root cause
Mutually inconsistent invariants: skip guard chain.c:4202-4209 vs the destructive delete at chain.c:4160 and the emptiness assert at chain.c:2314, all reached from flush.c:1040.
Threat model & preconditions
Unprivileged local user racing append-writes against syncer flushes on a sparsifying directory; impact ceiling is panic (DoS) plus potential silent loss of the racing file's metadata.
Proof of concept
Code-level reachability fully proven in VERDICT.md (site-by-site trace). Live stress: 6000-file dir, 70% deleted, 4 append writers (>130M appends), key-space-wide create/delete churn, 20 Hz sync loop on a 512MB vn-backed PFS β two ~35-minute runs did not win the microsecond-to-millisecond interleaving (markers absent from dmesg); run 2 was contaminated by an ENOSPC flush storm (DF-2633 family, not claimed). Evidence: findings/poc/DF-2645/.
Recommended fix
Make the collapse non-destructive until proven complete: defer
hammer2_chain_delete(parent, chain) (chain.c:4160) until after a full
clean sweep of the collapse loop; if any child was skipped
(bcmp/parent/DELETED guard at 4202-4209), abort with return 0 so the
flush defers and retries, keeping repchange's emptiness invariant true.
References
- DF-2632 (the overlap panic in the same maintenance function), DF-2633 (ENOSPC storm that contaminated run 2)
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_flush.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2645 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.3 KB | β raw | |
| VERDICT.md | β | 4.8 KB | β raw | |
| verdict.json | β | 2.3 KB | view raw | |
| manifest.json | β | 1007 B | view raw | |
| stress2645.c | β | 3.7 KB | view raw | |
| mknames.c | β | 593 B | view raw | |
| mkfiles.c | β | 1.0 KB | view raw | |
| delsome.c | β | 1.3 KB | view raw | |
| run_df2645.sh | β | 1.6 KB | view raw | |
| run.log | β | 1.0 KB | view raw |
DF-2645 β flush-driven indirect-block collapse racing a concurrent child modification
Class: lock/refcount race in the flusher's indirect-maintenance path,
reached from hammer2_flush_core() (sys/vfs/hammer2/hammer2_flush.c:1039-1042).
Reach: unprivileged local user (file create/delete + append + sync on a mounted hammer2 PFS).
Status: not_reproduced after 2 stress attempts (~35 min, >130M appends + continuous churn + 20 Hz sync loop); race is code-proven (confidence: likely). See VERDICT.md for the full analysis and run.log for the experiment transcripts.
Reproduce
cc -O2 -o stress2645 stress2645.c
cc -O2 -o mknames mknames.c
cc -O2 -o mkfiles mkfiles.c
cc -O2 -o delsome delsome.c
sh run_df2645.sh 12 1 # 512MB vn image, 6000-file dir, 70% delete,
# 4 append writers + create/delete churn +
# sync loop
Success criterion (not achieved): kernel panic with the
hammer2_chain_repchange KKASSERT (hammer2_chain.c:2314) or
hammer2: debug repchange console prints; alternatively any
LOST CHILD/overlap marker from the maintenance path.
Mechanism (why it should be possible)
- flush_core(B) [B = sparse INDIRECT with UPDATE set] scans+flushes B's children bottom-up, updating B's media blocktable per child (hammer2_flush.c:1116-1134).
- A frontend write modifies a file-inode chain F under B AFTER F's
flush completes but BEFORE flush_core(B) reaches
hammer2_chain_indirect_maintenance(parent, B)(:1040) βhammer2_chain_modify()COWs F to a fresh data_off (hammer2_chain.c:1437+), so F's in-memory bref diverges from B's media entry. - maintenance's collapse loop hits the skip guard
(
bcmp(&bsave, &sub->bref)mismatch, chain.c:4202-4209) and leaves F in B's rbtree. hammer2_chain_repchange(parent, B)(chain.c:4228 β :2314) unconditionally KKASSERTs B is empty β panic (DoS); on non-INVARIANTS semantics the reptracks migrate while F is still parented to the already-deleted B β F's media entry becomes unreachable (silent data loss).
Suggested fix
Defer the destructive hammer2_chain_delete(parent, chain)
(chain.c:4160) until after a clean full sweep of the collapse loop;
abort the collapse (return 0, flush retries later) when any child was
skipped, keeping repchange's invariant true.
DF-2645 β flush-driven indirect collapse vs concurrent child modify β repchange KKASSERT / orphaned subtree
Status: not_reproduced (2 stress attempts, ~35 min of racing; race is code-proven but was not won; attempt 2 degraded into an ENOSPC flush storm β DF-2633 family behavior β before the window could be hit).
Root-cause analysis (code-proven)
hammer2_flush_core() calls hammer2_chain_indirect_maintenance(parent,
chain) for every INDIRECT child with UPDATE set
(sys/vfs/hammer2/hammer2_flush.c:1039-1042). The collapse loop in
maintenance knows about concurrent modification races and skips children
whose in-memory bref no longer matches the parent's media entry:
if (bcmp(&bsave, &sub->bref) || /* chain.c:4202 */
sub->parent != chain ||
(sub->flags & HAMMER2_CHAIN_DELETED)) {
hammer2_chain_unlock(sub); /* skip, not moved */
...
continue;
}
β¦but the code after the loop assumes the loop emptied the chain:
hammer2_chain_repchange(parent, chain); /* chain.c:4228 */
hammer2_chain_repchange(...)
{
KKASSERT(chain->core.live_count == 0 && RB_EMPTY(&chain->core.rbtree));
/* chain.c:2314 β UNCONDITIONAL KKASSERT */
The two are mutually inconsistent under exactly the race the skip guard anticipates: a child that was flushed by this pass's downward scan (flush_recurse, hammer2_flush.c:1163-1257) and then modified again by the frontend before the bottom-up phase reaches maintenance has a fresh COW bref (new data_off from hammer2_chain_modify, chain.c:1437+) that no longer matches the parent's media blockref; the skip guard leaves it in the rbtree with live_count β₯ 1; repchange's KKASSERT fires β panic on INVARIANTS kernels (DragonFly KKASSERT is unconditional) and mis-migrated reptracks + silently orphaned subtree on others (the chain's own bref was already deleted from the grandparent at chain.c:4160, so the skipped child's media entry becomes unreachable β silent data loss).
Race window: from the end of flush_core(sub)'s bottom-up (its parent blocktable update, hammer2_flush.c:1116-1134) to maintenance's combined_find/bcmp (chain.c:4179-4210) β the remainder of B's RB_SCAN plus B's own CRC/flush work. An unprivileged local user running append-write loops against files whose inode chains live under a collapsible indirect block races the syncer flush.
Experiments (guest, stock kernel #0)
- run_df2645.sh 12 min mode 1: 6000-file dir, 70% pre-deleted,
4 append writers (~130M appends total), churn create/delete children,
syncloop at 20 Hz. Result: no panic, clean umount (full_run.log). Post-mortem: the pre-stress sparsification syncs had already collapsed the sparse indirects, so maintenance had no eligible work during the race phase. - Same with continuous key-space-wide churn (full_run2.log): writers
+ create/delete churn filled the 512MB volume mid-run; dmesg filled
with
hammer2_flush: ... error=00000020(HAMMER2_ERROR_ENOSPC from the chain_modify(parent) failure path, hammer2_flush.c:1057-1064) and the teardownsyncwedged inh2coll(ENOSPC retry storm β DF-2633 family, not this finding). NoLOST CHILD*, nodebug repchange, noexcessive loopsmarkers: the maintenance skip was never taken with a live child before ENOSPC took over.
Why it stays likely (not demoted)
Every link in the chain is individually verifiable in source: the skip guard exists and is reachable with a non-deleted, rbtree-resident child (bcmp mismatch via COW modify); repchange unconditionally asserts emptiness; the call order guarantees the assert runs after any skip. What was not achieved is winning the scheduler interleaving inside the window on this guest within the time budget (and the volume-fill side effect of the churn harness sabotaged attempt 2).
Suggested fix
--- a/sys/vfs/hammer2/hammer2_chain.c
+++ b/sys/vfs/hammer2/hammer2_chain.c
@@ repchange caller (indirect_maintenance, chain.c:4226-4228)
- hammer2_chain_unex ... spin release ...
- hammer2_chain_repchange(parent, chain);
+ if (chain->core.live_count == 0 && RB_EMPTY(&chain->core.rbtree)) {
+ hammer2_chain_repchange(parent, chain);
+ } else {
+ /* children were skipped due to concurrent modification;
+ * leave the chain linked (not deleted from the parent's
+ * rbtree/blocktable) and let the next flush retry the
+ * collapse. Requires undoing the chain delete at
+ * chain.c:4160 (defer it to this point). */
+ ...
+ }
The robust structure is to make the collapse loop's skip path count skips and, if any occurred, abort the collapse before hammer2_chain_delete(parent, chain) performs any destructive step (move the delete after a successful full sweep, or pre-flight-verify like the DF-2632 fix does for key-range overlaps).
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['findings/poc/DF-2645/VERDICT.md (full code-path proof with path:line)', 'findings/poc/DF-2645/stress2645.c (writers + churn harness)', 'findings/poc/DF-2645/run_df2645.sh (driver)', 'full_run.log / full_run2.log captured on guest /root/df2645/ (summarized in VERDICT.md)']
PoC changes
n/a (original trigger sketch replaced by stress2645.c family; harness iterated twice)
Verified recommended fix
Abort the collapse before the destructive chain delete when any child was skipped (defer hammer2_chain_delete(parent,chain) until after a clean full sweep), keeping repchange's invariant true
Verdict
Code-proven race: hammer2_flush_core (hammer2_flush.c:1039-1042) drives indirect_maintenance, whose collapse-loop skip guard (hammer2_chain.c:4202-4209, bcmp mismatch after a concurrent frontend COW modify of a child) contradicts the unconditional emptiness KKASSERT in hammer2_chain_repchange (chain.c:2314) called at chain.c:4228 -> panic (DoS) on stock kernels, orphaned subtree/silent data loss otherwise. Two stress runs (~130M appends + churn + 20Hz syncs) did not win the scheduler interleaving; run 2 was sabotaged by an unrelated ENOSPC flush storm (DF-2633 family) after the churn filled the 512MB volume. All markers (LOST CHILD, debug repchange, excessive loops) absent from dmesg.
No comments yet.