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).
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2660 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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_testablefix.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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_synchro.c:458
- sys/vfs/hammer2/hammer2_synchro.c:555
- sys/vfs/hammer2/hammer2_synchro.c:741
- sys/vfs/hammer2/hammer2_synchro.c:753
- sys/vfs/hammer2/hammer2_synchro.c:758
- sys/vfs/hammer2/hammer2_chain.c:113
- sys/vfs/hammer2/hammer2_inode.c:446
- sys/vfs/hammer2/hammer2_chain.c:5678
- sys/vfs/hammer2/hammer2_xops.c:472
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).
No comments yet.