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

hammer2_sync_insert() TOCTOU re-lookup races concurrent same-key creates on mounted rw clusters: KKASSERT(chain == NULL) panics INVARIANTS kernels (duplicate-key chain_create on production)

Field Value
ID DF-2660
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-367 TOCTOU (CWE-672 operation after validation)
File sys/vfs/hammer2/hammer2_synchro.c
Lines 458 (inode unlocked), 741-758 (relock + assert)
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

The sync scan runs with the inode unlocked (synchro.c:458) and the parent locked shared, so VOP create/rename xops on a mounted rw multi-master cluster execute concurrently against it. When the scan finds a key missing locally, hammer2_sync_insert() drops the locks, relocks the parent exclusively, and re-runs the lookup as a TOCTOU re-check β€” then asserts the result is still missing: KKASSERT(chain == NULL) (synchro.c:758). A user doing rm X; echo > X (same name β†’ same dirent key) that lands in the relock window inserts the key on ALL columns (including the sync thread's own idx), the re-lookup finds it, and the INVARIANTS kernel panics; production kernels instead drive hammer2_chain_create() into a duplicate-key insert against an RB tree whose comparator treats overlap as match (chain.c:113-117). Live on stock for β‰₯3-master clusters (quorum reachable among 2 participants) and on DF-2657-fixed kernels for 2-master clusters; on stock 2-master clusters the DF-2657 h2coll park hides it.

Threat model & preconditions

Local unprivileged user on a mounted rw multi-master hammer2 cluster racing file recreation against the 5-second sync passes' insert path.

Proof of concept

Structural proof at findings/poc/DF-2660/README.md; the live hammer (tight rm -f X; echo data > X loop) could not complete honestly β€” every multi-session cluster run on this guest was terminated early by the pre-existing DF-2620-family teardown UAF (see DF-2657's panic_serial.log).

Treat the raced chain as adopt-and-retry instead of a panic:

--- a/sys/vfs/hammer2/hammer2_synchro.c
+++ b/sys/vfs/hammer2/hammer2_synchro.c
@@ -755,7 +755,14 @@
    chain = hammer2_chain_lookup(parentp, &dummy,
                     focus->bref.key, focus->bref.key,
                     &error,
                     HAMMER2_LOOKUP_NODIRECT |
                     HAMMER2_LOOKUP_ALWAYS);
-   KKASSERT(chain == NULL);
+   if (chain != NULL) {
+       /* raced a concurrent modify on our column: adopt it */
+       hammer2_chain_unlock(chain);
+       hammer2_chain_drop(chain);
+       return (HAMMER2_ERROR_EAGAIN);
+   }

References

  • DF-2657 (whose fix exposes this on 2-master clusters), DF-2618 (the RB overlap-as-match comparator)

Timeline

  • 2026-08-29 Discovered during pass-2 audit of hammer2_synchro.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2660 Β· 14 files
FileTypeDescriptionSize
VERDICT.md β€” 6.0 KB ↓ raw
README.md β€” 2.1 KB ↓ raw
trigger_df2660.sh β€” 5.1 KB view raw
build.sh β€” 122 B view raw
run.sh β€” 143 B view raw
build.log β€” 20 B view raw
attempts_fghi.console.txt β€” 3.2 KB view raw
run_f.summary β€” 0 B ↓ download
run_i.summary β€” 0 B ↓ download
fix.diff β€” 952 B view raw
fix_run.log β€” 1.4 KB view raw
env.txt β€” 896 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 5.3 KB view raw

DF-2660 β€” hammer2_sync_insert() re-lookup TOCTOU: concurrent same-key

create on a mounted rw cluster trips KKASSERT(chain == NULL) on INVARIANTS

kernels (duplicate-key chain_create on production)

sys/vfs/hammer2/hammer2_synchro.c:741-758. Medium / hammer2 / CWE-367.

NOT REPRODUCED live on the stock INVARIANTS guest β€” shadowed 4/4 by a sibling kernel panic on the same uncoordinated path (every racing VOP dies at hammer2_inode_chain_and_parent.cold.9+0x15, fault va 0x118, via hammer2_chain_inode_find() from the xop unlink/lookup paths) before the synchro.c:758 assert can fire. The race window is code-certain; concurrent VOPs-vs-sync panics of the same family WERE reproduced deterministically at the sibling site. Full analysis in VERDICT.md and attempts_fghi.console.txt.

What it is

The sync scan runs with the inode unlocked (synchro.c:458) and the parent shared; hammer2_sync_insert() drops locks, relocks EX, re-looks-up the key as a TOCTOU re-check and asserts chain == NULL (synchro.c:758). A concurrent rm X; echo > X on the mounted rw cluster that inserts the same dirent key on the sync thread's own column inside the window panics INVARIANTS kernels; production kernels fall into duplicate-key hammer2_chain_create() against the overlap-as-match RB comparator.

Reproduce (attempt rig)

# guest (root):
sh trigger_df2660.sh <label> <vn-base>   # e.g. j 0

The trigger builds a 3-master clone cluster (A/B identical with f001..f200, C pristine), waits for the catch-up pass to start (quiescent entry β€” churn starves the ipcluster quorum into 60s h2coll parks), then unleashes 6 reversed-hash-order rm fX; echo > fX loops against the 400-insert backlog. On stock, expect the sibling NULL-deref panic (trace in attempts_fghi.console.txt) within seconds; the specific KKASSERT requires the sibling defect to be fixed first.

Fix

fix.diff β€” replace the KKASSERT with an EAGAIN return when the re-lookup finds a raced chain (the racing creator wrote all columns consistently in one transaction; the pass retries). No-regression validated on normal catch-up sync (see VERDICT.md).

VERDICT.md
↓ download raw

DF-2660 VERDICT β€” NOT REPRODUCED (specific KKASSERT shadowed by a sibling

kernel panic in every live race attempt; race window itself code-certain)

Bottom line

Six race attempts were driven against real, verified 3-master hammer2 clusters on the stock INVARIANTS kernel (four of them β€” f, g, h, i β€” with a live catch-up backlog of 400 hammer2_sync_insert() calls racing 6 full-speed rm fX; echo > fX hammer loops). The specific KKASSERT(chain == NULL) at sys/vfs/hammer2/hammer2_synchro.c:758 never fired, because every attempt was killed first β€” 4/4 with an identical signature β€” by a sibling pre-existing defect on the same uncoordinated concurrency path:

Fatal trap 12: page fault, fault virtual address = 0x118
hammer2_inode_chain_and_parent.cold.9() at +0x15     (hammer2_inode.c:446-453)
hammer2_chain_inode_find() at +0x97                  (hammer2_chain.c:5678)
-> from hammer2_xop_unlink/lookup/checkdirempty      (hammer2_xops.c:472/298/96)

hammer2_inode_chain_and_parent() dereferences chain->parent at hammer2_inode.c:453 without checking the chain == NULL case that its own lines 446-448 can produce. When a VOP (the hammer's rm) resolves a dirent whose in-core inode was just created by the sync thread's dodefer machinery (synchro.c:592-615) with a partially-populated cluster array, the xop worker NULL-derefs and panics the box.

Shadowing analysis (why the assert cannot win on stock): the root-level scan inserts inode-key chains first (inum keys, bit 63 clear β€” all 200 dodefer nips materialize in the first half of the backlog) and dirent-key chains second (the KKASSERT surface). From the start of the dirent phase, every hammer rm of any synced name is primed to hit the sibling NULL-deref (nips exist for all names; each rm's unlink resolves dirent->inode via hammer2_chain_inode_find), while the KKASSERT needs a same-key VOP create to land inside a single microsecond-scale lock handoff of the exact key the sync thread is inserting. The sibling window is orders of magnitude wider; empirically it won 4/4 attempts within seconds.

What IS empirically proven on stock (4/4, dumps written): concurrent VOP file operations against a live sync catch-up pass on a 3-master rw cluster panic the stock kernel β€” the broader concurrency defect class the finding belongs to; just at the sibling site, not the cited assert.

Additional gating mapped during the attempts (see attempts_fghi.console.txt): * Full-speed VOP churn (~80+ root-inode mtid changes/s) starves the sync thread's entry ipcluster xop (A/B feed mtid skew -> EINPROGRESS -> the 60s h2coll park, admin.c:990-998) β€” the pass never even starts. The working choreography: quiescent cluster until the pass starts, then unleash the hammer against the backlog (attempt f: H2NOD_COUNT=3, BACKLOG_SEEN poll=0, 400 inserts observed in syslog). * A hammer that trails the insertion frontier never collides on the exact key (attempt f/g/h: forward sweeps trail the faster sync thread; the frontier never crosses back). Attempt i used reversed-order sweeps to force a head-on frontier meeting β€” and died at the sibling NULL-deref at uptime 42s, as predicted by the shadowing analysis. * mount(8) returns 64 both for EBUSY-merges and for usage errors (hidden m2/m3 subdirs) β€” cluster formation must be verified via the h2nod-<label> census, not mount exit codes.

Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 2026, X86_64_GENERIC (INVARIANTS), QEMU/KVM, stock kernel.

Status classification

not_reproduced / reproduced=0 β€” the finding's specific panic (assertion chain == NULL in hammer2_sync_insert, synchro.c:758) was not observed live; the race window it guards is structurally code-certain (scan with the inode unlocked at synchro.c:458, parent shared; drop-locks/relock-EX/ re-lookup/KKASSERT at synchro.c:741-758), and concurrent-VOPs-vs-sync panics of the same family were reproduced deterministically at the sibling site (documented above, full traces in attempts_fghi.console.txt).

Fix

fix.diff replaces the KKASSERT with an EAGAIN return on a raced re-lookup (the racing creator wrote the key to ALL columns in one cluster transaction, so dropping the stale insert preserves consistency; the sync pass retries). Applied to the fix-validation kernel together with the DF-2658 fix; validated for no-regression on normal catch-up sync (see below). Race-specific behavioral validation is not possible on stock while the sibling hammer2_inode_chain_and_parent NULL-deref pre-empts every racing VOP β€” fixing THAT defect (one NULL check at hammer2_inode.c:446-453) is the prerequisite for any live re-attempt and is recommended as its own finding.

(No-regression run results appended below.)

Fix validation (kernel #1, Thu Oct 15 07:30:39 UTC 2026)

The EAGAIN fix (fix.diff) was compiled into the same validation kernel as the DF-2658 fix (fix_build.log, BUILD_INSTALL_DONE_RC0). No-regression evidence: the DF-2658 fixed-kernel run (../DF-2658/run_fixed.log) exercised the full catch-up path on a real 3-master cluster β€” 400+ sync_insert / sync_replace calls through the patched hammer2_synchro.c, deferred passes and the root-inode final sync all completed, no EAGAIN storms, no new panic sites from this change (the only panic was the pre-existing sibling chain_inode_find NULL-deref).

Race-specific behavioral validation (raced re-lookup returns EAGAIN and the pass retries) is NOT testable on this guest while the sibling NULL-deref pre-empts every racing VOP (4/4 attempts); fixing hammer2_inode.c:446-453 (one NULL check) is the prerequisite for a live re-attempt. fix_status=not_testable (race), no-regression passed.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff compiled clean into validation kernel #1 (both fixes, BUILD_INSTALL_DONE_RC0). Race-specific behavioral validation is not testable on this guest: the sibling chain_inode_find NULL-deref pre-empts every racing VOP before the synchro.c:758 site can be reached (4/4), and since the baseline itself never reproduced, patched-vs-baseline comparison has no signal. No-regression PASSED: the DF-2658 fixed-kernel run (../DF-2658/run_fixed.log) drove 400+ sync_insert/sync_replace calls through the patched hammer2_synchro.c on a real 3-master cluster to full convergence with no EAGAIN-related errors and no new panic sites.

findings/poc/DF-2660/fix_run.log; findings/poc/DF-2658/run_fixed.log (same kernel, full catch-up pass); findings/poc/DF-2658/fix_build.log
↓ fix.diffper-fix-DF-2660

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md (shadowing analysis + choreography lessons); attempts_fghi.console.txt (attempt telemetry + all four sibling-crash backtraces, reconstructed from the session after the guest logs were lost to the fix-build snapshot revert); env.txt; fix.diff (EAGAIN replacement); fix_run.log (no-regression on kernel #1).

PoC changes

No seed PoC existed (audit-time finding, status was untested). Built the race rig incrementally over attempts a-i: separate top-level merge mountpoints + h2nod census verification (mount rc 64 is ambiguous), vn attach guards, quiescent-entry-then-unleash choreography (VOP churn >=80 root-tid/s starves the sync entry ipcluster into 60s h2coll parks - the pass never starts), syslog-based backlog detection (kern.msgbuf saturates under debug floods), hash-order and reversed-order hammer sweeps (trailing sweeps never meet the insertion frontier).

Verified recommended fix

Replace KKASSERT(chain == NULL) in hammer2_sync_insert() with an EAGAIN return when the re-lookup finds a raced chain (the racing creator wrote all columns consistently in one transaction; the pass retries) - fix.diff, compiled into validation kernel #1.

Verdict

NOT REPRODUCED live: the specific KKASSERT(chain == NULL) at synchro.c:758 was never observed on the stock INVARIANTS guest. Six attempts against real, census-verified 3-master same-clid clusters; four genuine race attempts (f/g/h/i) each drove a live 400-insert hammer2_sync_insert() backlog racing 6 full-speed rm/echo hammer loops (f: H2NOD_COUNT=3, BACKLOG_SEEN poll=0, 400 inserts in syslog) - and every attempt was killed first, 4/4 with an identical signature, by a sibling pre-existing NULL-deref on the same uncoordinated path: hammer2_inode_chain_and_parent.cold.9+0x15 (hammer2_inode.c:446-453 dereferences chain->parent after its own chain==NULL case) via hammer2_chain_inode_find+0x97 (hammer2_chain.c:5678) reached from the xop unlink/lookup paths (hammer2_xops.c:472/298/96) whenever a VOP touches a name whose in-core inode was just created by the sync thread's dodefer machinery with a partially-populated cluster array. Shadowing analysis: the root scan inserts inode-key chains (all dodefer nips materialize) before the dirent-key chains (the KKASSERT surface), so from the start of the dirent phase every hammer rm of any synced name is primed for the sibling panic, while the assert needs a same-key VOP create inside one microsecond-scale lock handoff of the exact key being inserted - the sibling window is orders of magnitude wider and won 4/4. What IS empirically proven (4/4, dumps written): concurrent VOP file operations against a live sync catch-up pass on a 3-master rw cluster panic the stock kernel - the finding's broader concurrency class, at the sibling site. The cited window itself remains structurally code-certain (inode unlocked at synchro.c:458, drop-locks/relock-EX/re-lookup/KKASSERT at 741-758).