hammer2_flush_core LOST CHILD3 panics on the NULL->non-NULL parent transition (unconditional KKASSERT / hammer2_chain_unlock(NULL))
| Field | Value |
|---|---|
| ID | DF-2646 |
| 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-476 NULL Pointer Dereference |
| File | sys/vfs/hammer2/hammer2_flush.c |
| Lines | 681-690 (window created at 661-665) |
| Area | vfs |
| Confidence | speculative |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
flush_core() documents that parent may be NULL (destroy races,
flush.c:504-506) and temporarily releases the chain lock (:661-665). If a
detached chain being flushed (parent == NULL at entry) is adopted by a
concurrent frontend rename during that window (rename→chain_create
reconnect needs only the chain lock the flusher released), the LOST
CHILD3 branch fires with parent == NULL: KKASSERT(parent != NULL)
panics and hammer2_chain_unlock(NULL) would fault (chain.c:1132-1138).
Mirror-image of the known parent→NULL findings (DF-0813/DF-2568) at a
different site and transition direction.
Threat model & preconditions
Unprivileged local user racing renames against syncer flushes of detached (deleted-but-open) chains; kernel panic.
Proof of concept
Not schedulably winnable: requires a rename xop to adopt exactly the chain being flushed within a three-lock-operation window; no biasing harness exists for the interleaving. Phase V skipped per contract with full reasoning in the evidence pack (findings/poc/DF-2646/).
Recommended fix
--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -686,8 +686,8 @@
}
- KKASSERT(parent != NULL);
- hammer2_chain_unlock(parent);
+ if (parent)
+ hammer2_chain_unlock(parent);
retry = 1;
goto done;
(hammer2_flush()'s retry loop re-seeks info.parent from chain->parent, so retry=1 is the correct disposition in both directions.)
References
- DF-0813, DF-2568 (the known parent→NULL mirror family)
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-2646 · 4 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | — | 2.9 KB | ↓ raw | |
| VERDICT.md | — | 3.0 KB | ↓ raw | |
| verdict.json | — | 1.7 KB | view raw | |
| manifest.json | — | 660 B | view raw |
Finding: In hammer2_flush_core() (sys/vfs/hammer2/hammer2_flush.c:681-690),
the "LOST CHILD3" path handles the case where chain->parent changed while the
flusher temporarily released the chain lock (lines 661-665):
if (chain->parent != parent) {
if (hammer2_debug & 0x0040) { ... }
KKASSERT(parent != NULL); /* :686 */
hammer2_chain_unlock(parent); /* :687 */
retry = 1;
goto done;
}
Every other transition direction is handled or known-filed: the parent
non-NULL → NULL direction (chain deleted out of its parent during the
unlock window) is DF-0813/DF-2568 territory in the hammer2_flush() retry
loop. This finding covers the opposite direction at this site: parent ==
NULL at entry (flush of a detached chain — deleted-but-open inode chains,
or destroy races; the code explicitly documents parent can be NULL, usually
due to destroy races, hammer2_flush.c:504-506) and the chain gaining a
parent during the unlock window at :662-665 (a concurrent frontend rename
adopting the detached chain via hammer2_chain_rename() →
hammer2_chain_create() reconnect, which only needs the chain's exclusive
lock — exactly what the flusher released).
Then:
* INVARIANTS/unconditional DragonFly KKASSERT(parent != NULL) fires → panic.
* Non-INVARIANTS semantics: hammer2_chain_unlock(NULL) immediately
dereferences chain->lockcnt (hammer2_chain.c:1132-1138) → NULL-page
fault → panic either way.
Why Phase V is skipped (verdict: skipped)
The trigger requires winning a scheduler race whose window is the duration of three lock operations (hammer2_flush.c:661-665) between the flusher thread and a frontend rename xop adopting the same, currently-detached chain. There is no deterministic unprivileged schedule for this on the single-CPU-ish guest: the probability mass per attempt is microseconds against a rename that must target exactly the chain being flushed at that instant. A stress harness cannot bias the interleaving (the adoption itself requires the rename xop to win the chain lock in the window, and the vast majority of flushes of detached chains complete without any concurrent adopter existing). Code-level reachability is proven above; runtime reproduction is left as a low-value lottery. Consistent with the severity family of the known mirror findings (DF-0813/DF-2568, Medium).
Suggested fix
--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -683,6 +683,8 @@
if (hammer2_debug & 0x0040) {
kprintf("LOST CHILD3 %p->%p (actual parent %p)\n",
parent, chain, chain->parent);
}
- KKASSERT(parent != NULL);
- hammer2_chain_unlock(parent);
+ if (parent)
+ hammer2_chain_unlock(parent);
retry = 1;
goto done;
(With parent == NULL the retry loop in hammer2_flush() re-seeks
info.parent from chain->parent and re-references it, so retry=1 is the
correct disposition in both directions.)
DF-2646 — hammer2_flush_core LOST CHILD3: NULL→non-NULL parent transition panics via KKASSERT / unlock(NULL)
Finding: In hammer2_flush_core() (sys/vfs/hammer2/hammer2_flush.c:681-690),
the "LOST CHILD3" path handles the case where chain->parent changed while the
flusher temporarily released the chain lock (lines 661-665):
if (chain->parent != parent) {
if (hammer2_debug & 0x0040) { ... }
KKASSERT(parent != NULL); /* :686 */
hammer2_chain_unlock(parent); /* :687 */
retry = 1;
goto done;
}
Every other transition direction is handled or known-filed: the parent
non-NULL → NULL direction (chain deleted out of its parent during the
unlock window) is DF-0813/DF-2568 territory in the hammer2_flush() retry
loop. This finding covers the opposite direction at this site: parent ==
NULL at entry (flush of a detached chain — deleted-but-open inode chains,
or destroy races; the code explicitly documents parent can be NULL, usually
due to destroy races, hammer2_flush.c:504-506) and the chain gaining a
parent during the unlock window at :662-665 (a concurrent frontend rename
adopting the detached chain via hammer2_chain_rename() →
hammer2_chain_create() reconnect, which only needs the chain's exclusive
lock — exactly what the flusher released).
Then:
* INVARIANTS/unconditional DragonFly KKASSERT(parent != NULL) fires → panic.
* Non-INVARIANTS semantics: hammer2_chain_unlock(NULL) immediately
dereferences chain->lockcnt (hammer2_chain.c:1132-1138) → NULL-page
fault → panic either way.
Why Phase V is skipped (verdict: skipped)
The trigger requires winning a scheduler race whose window is the duration of three lock operations (hammer2_flush.c:661-665) between the flusher thread and a frontend rename xop adopting the same, currently-detached chain. There is no deterministic unprivileged schedule for this on the single-CPU-ish guest: the probability mass per attempt is microseconds against a rename that must target exactly the chain being flushed at that instant. A stress harness cannot bias the interleaving (the adoption itself requires the rename xop to win the chain lock in the window, and the vast majority of flushes of detached chains complete without any concurrent adopter existing). Code-level reachability is proven above; runtime reproduction is left as a low-value lottery. Consistent with the severity family of the known mirror findings (DF-0813/DF-2568, Medium).
Suggested fix
--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -683,6 +683,8 @@
if (hammer2_debug & 0x0040) {
kprintf("LOST CHILD3 %p->%p (actual parent %p)\n",
parent, chain, chain->parent);
}
- KKASSERT(parent != NULL);
- hammer2_chain_unlock(parent);
+ if (parent)
+ hammer2_chain_unlock(parent);
retry = 1;
goto done;
(With parent == NULL the retry loop in hammer2_flush() re-seeks
info.parent from chain->parent and re-references it, so retry=1 is the
correct disposition in both directions.)
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['findings/poc/DF-2646/VERDICT.md (full code-path analysis with path:line citations)']
PoC changes
n/a (no runnable PoC; race not schedulably winnable)
Verified recommended fix
Guard the unlock: 'if (parent) hammer2_chain_unlock(parent);' in the LOST CHILD3 path (hammer2_flush.c:686-687)
Verdict
Race-dependent NULL-deref/KKASSERT panic in hammer2_flush_core LOST CHILD3 (flush.c:681-690) when a detached chain (parent==NULL at flush entry) gains a parent during the flusher's unlock window (flush.c:661-665) via a concurrent frontend rename adoption. Code path proven; runtime reproduction requires winning a microsecond-scale lock interleaving with no deterministic unprivileged schedule, so Phase V is skipped per contract for speculative race findings. Mirror-image of the known parent->NULL findings (DF-0813/DF-2568) at a different site and transition direction.
No comments yet.