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

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/.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2645 Β· 10 files
FileTypeDescriptionSize
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)

  1. 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).
  2. 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.
  3. maintenance's collapse loop hits the skip guard (bcmp(&bsave, &sub->bref) mismatch, chain.c:4202-4209) and leaves F in B's rbtree.
  4. 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.

VERDICT.md
↓ download raw

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)

  1. run_df2645.sh 12 min mode 1: 6000-file dir, 70% pre-deleted, 4 append writers (~130M appends total), churn create/delete children, sync loop 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.
  2. 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 teardown sync wedged in h2coll (ENOSPC retry storm β€” DF-2633 family, not this finding). No LOST CHILD*, no debug repchange, no excessive loops markers: 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_testable
per-fix-DF-2645

Confirmed 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.