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

NULL deref in hammer2_flush() retry loop when chain->parent becomes NULL during concurrent flush β€” missing NULL guards present in setup/teardown

Summary

hammer2_flush.c:397-406 retry loop if(info.parent!=chain->parent). :403 hammer2_chain_drop(info.parent) NO NULL check. :405 hammer2_chain_ref(info.parent) NO NULL check β€” derefs NULL+offsetof(refs). Setup :382-383 correctly guards if((info.parent=chain->parent)!=NULL) ref. Teardown :434-435 correctly guards if(info.parent) drop. Loop body OMITS both NULL guards. chain->parent can become NULL during flush_core: :661 ref_hold prevents data drop but NOT topology changes. :662 chain_unlock releases mutex. Concurrent VOP unlink acquires chain->core.spin + parent->core.spin sets chain->parent=NULL (hammer2_chain.c:3550-3551). :665 chain_relock. :681 detects parent!=NULL sets retry=1. Back in loop :397 info.parent(non-NULL)!=chain->parent(NULL) true. :403 drop old parent safe. :404 info.parent=NULL. :405 hammer2_chain_ref(NULL) atomic_fetchadd_int(&NULL->refs,1) address ~0xB0 unmapped zero page = panic. Trigger: concurrent unlink + sync on HAMMER2 mount. Fix: if(info.parent) drop + if(info.parent) ref consistent with setup/teardown.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0813 Β· 14 files
FileTypeDescriptionSize
churn_h2.c trigger-source hammer2 create/write/fsync/unlink/sync churn harness 4.6 KB view raw
dirstress_h2.c trigger-source directory-topology-stress harness (indirect-block split/merge churn) 2.8 KB view raw
setup_h2.sh setup-script root: create vn-backed hammer2 image, newfs, mount 875 B view raw
teardown_h2.sh setup-script root: unmount + detach vnode 138 B view raw
build.sh build-script compile both harnesses 211 B view raw
run.sh run-script drive churn on a hammer2 mount 911 B view raw
fix.diff suggested-fix add NULL guards in retry loop body (consistent with setup/teardown) 546 B view raw
fix_build.log build-log patched kernel build: hammer2_flush.o + linking kernel.debug, no errors 45 B view raw
fix_run.log run-log patched kernel #7: PoC runs clean, no panic 852 B view raw
env.txt environment guest uname, cc, sysctls, mount state 873 B view raw
VERDICT.md verdict full narrative: code defect confirmed, race too tight to trigger 7.3 KB ↓ raw
README.md readme human reproduce doc 1.7 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human reproduce doc
↓ download raw

DF-0813 β€” hammer2_flush() NULL-deref in retry loop

Summary

sys/vfs/hammer2/hammer2_flush.c retry loop (lines 397-406) dereferences info.parent at lines 403 (hammer2_chain_drop) and 405 (hammer2_chain_ref) without a NULL guard. The setup (382-383) and teardown (434-435) of the same function correctly guard against NULL. If chain->parent becomes NULL during flush_core (via concurrent delete at hammer2_chain.c:3559), the retry loop crashes with a NULL deref panic.

Status: NOT REPRODUCED. The code defect is confirmed real (missing NULL guard, inconsistent with setup/teardown), but the trigger is an extremely tight race between hammer2 flush and concurrent chain deletion. ~15 min of aggressive churn on two hammer2 mounts (with debug trace bit 0x0040 enabled showing the retry-loop body never fired) produced zero panics.

Impact ceiling: NULL-deref panic / DoS (no escalation β€” fixed-offset deref).

How to reproduce (best-effort β€” race is probabilistic)

# 1. root: create + mount a hammer2 image
sudo sh setup_h2.sh
# (if label is DATA not DEFAULT: mount_hammer2 /dev/vn0@DATA /mnt/h2 && chmod 1777 /mnt/h2)

# 2. user: build + run the churn harness
./build.sh
H2DIR=/mnt/h2 SECS=300 ./run.sh

# 3. check for panic
dmesg | grep -i panic
# or check the serial console boot.log

The race may require extended churn (many minutes) to win, if winnable at all from pure userspace. Enable sysctl vfs.hammer2.debug=0x40 to observe "LOST CHILD4" trace messages (which fire when the retry-loop body executes at all).

Fix

See fix.diff β€” adds if (info.parent) guards at lines 403/405, consistent with the setup and teardown. Validated: applies cleanly, compiles, boots (#7), runs the PoC without regression.

VERDICT.md verdict full narrative: code defect confirmed, race too tight to trigger
↓ download raw

DF-0813 β€” NULL deref in hammer2_flush() retry loop when chain->parent becomes NULL

Verdict

NOT REPRODUCED (race-condition DoS; code defect CONFIRMED real but trigger is an extremely tight race that did not fire in ~15 min of aggressive hammer2 churn).

The code defect (CONFIRMED β€” path:line)

The finding is correct at the source level. sys/vfs/hammer2/hammer2_flush.c contains a retry loop in hammer2_flush() whose body dereferences info.parent without a NULL guard, while BOTH the setup and teardown of the same function correctly guard against NULL:

Setup (guarded): hammer2_flush.c:382-383

    if ((info.parent = chain->parent) != NULL)
        hammer2_chain_ref(info.parent);

Retry loop body (BUG β€” unguarded): hammer2_flush.c:403-405

        if (info.parent != chain->parent) {
            ...
            hammer2_chain_drop(info.parent);   /* line 403 β€” no NULL check */
            info.parent = chain->parent;        /* line 404 β€” now NULL if chain->parent==NULL */
            hammer2_chain_ref(info.parent);     /* line 405 β€” derefs NULL -> panic */
        }

Teardown (guarded): hammer2_flush.c:434-435

    if (info.parent)
        hammer2_chain_drop(info.parent);

The inconsistency is unambiguous: setup and teardown guard info.parent against NULL; the loop body does not. If the loop body runs with info.parent == NULL (or chain->parent == NULL after line 404), the calls to hammer2_chain_drop(NULL) / hammer2_chain_ref(NULL) dereference a NULL pointer:

  • hammer2_chain_ref(NULL) β†’ atomic_fetchadd_int(&chain->refs, 1) at hammer2_chain.c:260 with chain==NULL β†’ access at offsetof(hammer2_chain_t, refs) below the zero page β†’ fatal trap 12 (page fault).
  • hammer2_chain_drop(NULL) β†’ KKASSERT(chain->refs > 0) at hammer2_chain.c:350 β†’ same NULL deref.

Mechanism (theoretically reachable)

For the loop body at line 397 to execute, info.parent != chain->parent must hold on a retry. This requires chain->parent to change during flush_core:

  1. flush_core unlocks chain at hammer2_flush.c:662 (hammer2_chain_unlock).
  2. During the unlock window (662β†’665 relock), a concurrent unlink/delete can run _hammer2_chain_delete_helper (hammer2_chain.c:3520), which under chain->core.spin + parent->core.spin (lines 3550-3551) sets chain->parent = NULL at hammer2_chain.c:3559.
  3. After relock, flush_core detects chain->parent != parent at line 681, sets retry=1, and returns to the loop.
  4. Back at line 397: info.parent (old non-NULL parent) != chain->parent (now NULL) β†’ body enters β†’ line 404 sets info.parent=NULL β†’ line 405 hammer2_chain_ref(NULL) β†’ panic.

The path is reachable in principle. hammer2_chain_delete is called from the VOP unlink/remove xop path (hammer2_xops.c:420,449,641,...), and the flush is driven by user-initiated sync()/fsync().

Why it did NOT reproduce (the race is extremely tight)

Two stress harnesses (churn_h2.c = create/write/fsync/unlink/sync workers; dirstress_h2.c = heavy single-directory topology churn to force indirect-block split/merge + concurrent sync) were run for a cumulative ~15 minutes across two hammer2 mounts (/mnt/h2 vn-backed image and /tmp/h2churn on the root hammer2 fs), with vfs.hammer2.debug=0x0040 enabled (which prints "LOST CHILD4" at hammer2_flush.c:398-402 whenever the retry-loop body fires AT ALL).

Result: zero "LOST CHILD4" events β€” the retry-loop body at line 397 never executed during any run. This means a chain's parent never changed while a flush was processing it. The race window (flush_core's unlock at :662 β†’ relock at :665) is only a few lock operations wide, and the hammer2 flush/delete serialization prevented the overlap from occurring in practice. Zero panics on both the unpatched #0 and patched #7 kernels.

Impact ceiling

A NULL deref at a fixed offset (atomic_fetchadd_int at &NULL->refs, or KKASSERT on NULL->refs) is a pure DoS (kernel panic) β€” no memory corruption, no attacker-controlled write target, no escalation path. The panic would crash the system but cannot be leveraged for code execution or privilege escalation.

Realistic trigger: an unprivileged user with write access to a mounted hammer2 filesystem doing heavy concurrent modify/unlink + sync operations. The race is probabilistic and very tight; it may require extended churn to win (if winnable at all from pure userspace).

Fix (fix.diff)

The fix adds NULL guards in the retry loop body, making it consistent with the setup (line 382-383) and teardown (line 434-435):

--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -400,9 +400,11 @@
                    info.parent, chain, chain->parent);
            }
-           hammer2_chain_drop(info.parent);
+           if (info.parent)
+               hammer2_chain_drop(info.parent);
            info.parent = chain->parent;
-           hammer2_chain_ref(info.parent);
+           if (info.parent)
+               hammer2_chain_ref(info.parent);
        }

This is a minimal, targeted defense-in-depth fix: if chain->parent becomes NULL during a retry, the loop simply drops the old parent (if any) and sets info.parent=NULL without crashing; subsequent flush_core and teardown handle a NULL parent correctly (they all guard with if (parent) / if (info.parent)).

Fix validation (Phase 8)

  • fix.diff applies cleanly: git apply --check and patch -p1 both succeed.
  • Patched kernel compiles: make -j6 nativekernel KERNCONF=X86_64_GENERIC produced kernel.stripped (15.7 MB) and kernel.debug (119 MB) with zero compile errors. hammer2_flush.o rebuilt successfully.
  • Patched kernel boots: kern.version = 6.5-DEVELOPMENT #7 (today's ts).
  • PoC re-run on patched kernel: both harnesses ran for ~3 min with no panic, no regression, guest healthy.
  • fix_status: not_testable β€” since the race did not fire on EITHER the unpatched baseline or the patched kernel, a behavioral before/after comparison is not possible. The fix is validated at the compile + boot + code-inspection level: the NULL-deref path at lines 403/405 is definitively closed.

PoC changes

  • Authored churn_h2.c (create/write/fsync/unlink/sync churn harness).
  • Authored dirstress_h2.c (directory-topology-stress variant for indirect-block churn).
  • Authored setup_h2.sh / teardown_h2.sh (root: create vn-backed hammer2 image, newfs, mount).
  • Authored fix.diff (NULL guards in the retry loop body).

Kernel references (confirmed during verification)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

NOT TESTABLE behaviorally: the NULL-deref race did not fire on EITHER the unpatched baseline #0 or the patched #7 kernel, so no before/after behavioral comparison is possible. Validated at compile+boot+code level: fix.diff applies cleanly (git apply --check OK, patch -p1 OK), the single-fix kernel compiles with zero errors (hammer2_flush.o rebuilt, kernel.debug linked, kernel.stripped=15.7MB), boots as kern.version #7, and runs the full PoC (~3 min churn) with no panic and no regression. Code inspection confirms the NULL-deref paths at lines 403/405 are definitively closed by the added guards.

baseline #0: churn 15+ min, 0 LOST CHILD4, 0 panic, guest ALIVE (race never entered). patched #7: fix_build.log shows '--- hammer2_flush.o ---' + 'linking kernel.debug' with 0 errors; fix_run.log shows full churn completed (dirstress: syncs+io done; churn_h2: 'finished 60 s, 13 workers'), 0 panic, guest ALIVE uptime 4 min. sha256(kernel)=ef55559a8415a997ebfd4be9c858343a46edff980c5cca3cf4d96b75f5521b97.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #7: Fri Jul 10 21:01:59 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

none -- this is a NULL-deref at a fixed offset (atomic_fetchadd_int at &NULL->refs or KKASSERT on NULL->refs), which is a pure DoS (kernel panic) with no memory corruption, no attacker-controlled write target, and no escalation path. The impact ceiling is a system crash under a very tight probabilistic race; no uid=0 chain is derivable.

Evidence (decisive lines)

Baseline (unpatched #0, debug 0x40 on): 4 churn runs totaling ~15 min across /mnt/h2 + /tmp/h2churn -- 0 'LOST CHILD4' events, 0 panics, guest ALIVE each time. LOST CHILD4 trace (hammer2_flush.c:398, gated by debug&0x0040) fires whenever the retry-loop body at :397 executes AT ALL; its complete absence proves the race window was never entered. Patched #7 kernel: same PoC runs clean, 0 panics, guest healthy.

PoC changes

Authored churn_h2.c (create/write/fsync/unlink/sync workers), dirstress_h2.c (directory-topology-stress for indirect-block split/merge churn), setup_h2.sh/teardown_h2.sh (root: vn-backed hammer2 image creation+mount), build.sh/run.sh repro scripts, and fix.diff (NULL guards in retry loop body). Original PoC dir was empty (no prior sources); all sources authored fresh.

Verified recommended fix

Add 'if (info.parent)' guards before hammer2_chain_drop(info.parent) at line 403 and hammer2_chain_ref(info.parent) at line 405 in sys/vfs/hammer2/hammer2_flush.c, making the retry-loop body consistent with the guarded setup (382-383) and teardown (434-435). This is a minimal, targeted defense-in-depth fix: if chain->parent becomes NULL during a retry, the loop drops the old parent (if any) and sets info.parent=NULL without crashing; flush_core and teardown already handle NULL parent correctly. Matches the finding proposal (the finding also recommended NULL-guarding the two deref sites). Full git-apply-able diff in findings/poc/DF-0813/fix.diff.

Verdict

NOT REPRODUCED but code defect CONFIRMED REAL. The cited missing NULL guard in hammer2_flush.c is genuine: the retry-loop body at lines 403 (hammer2_chain_drop) and 405 (hammer2_chain_ref) dereferences info.parent without a NULL check, while BOTH the setup (lines 382-383: if((info.parent=chain->parent)!=NULL) ref) and teardown (lines 434-435: if(info.parent) drop) of the SAME function correctly guard against NULL. The inconsistency is unambiguous. If chain->parent becomes NULL during flush_core's unlock window (hammer2_flush.c:662 unlock -> 665 relock, via concurrent delete at hammer2_chain.c:3559 which sets chain->parent=NULL under core.spin), flush_core returns retry=1 (line 688), the loop re-enters at line 397 (info.parent non-NULL != chain->parent NULL), line 404 sets info.parent=NULL, and line 405 calls hammer2_chain_ref(NULL) -> atomic_fetchadd_int(&NULL->refs,1) -> fatal trap 12. However, ~15 min of aggressive multi-harness churn on two hammer2 mounts with vfs.hammer2.debug=0x40 enabled produced ZERO 'LOST CHILD4' trace events (meaning the retry-loop body at line 397 never even executed -- a chain's parent never changed while flush was processing it) and zero panics on both the unpatched #0 and patched #7 kernels. The race between hammer2 flush and concurrent chain deletion is extremely tight and did not fire in the test window.