DragonFlyBSD Kernel Audit
← triage · dashboard
DF-2646

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

--- 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2646 · 4 files
FileTypeDescriptionSize
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
README.md
↓ download 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.)

VERDICT.md
↓ download raw

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_testable
per-fix-DF-2646

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