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)
PoC verification
Evidence pack
findings/poc/DF-0813 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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)athammer2_chain.c:260withchain==NULLβ access atoffsetof(hammer2_chain_t, refs)below the zero page β fatal trap 12 (page fault).hammer2_chain_drop(NULL)βKKASSERT(chain->refs > 0)athammer2_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:
flush_coreunlockschainathammer2_flush.c:662(hammer2_chain_unlock).- During the unlock window (662β665 relock), a concurrent unlink/delete can run
_hammer2_chain_delete_helper(hammer2_chain.c:3520), which underchain->core.spin+parent->core.spin(lines 3550-3551) setschain->parent = NULLathammer2_chain.c:3559. - After relock,
flush_coredetectschain->parent != parentat line 681, setsretry=1, and returns to the loop. - Back at line 397:
info.parent(old non-NULL parent) !=chain->parent(now NULL) β body enters β line 404 setsinfo.parent=NULLβ line 405hammer2_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 --checkandpatch -p1both succeed. - Patched kernel compiles:
make -j6 nativekernel KERNCONF=X86_64_GENERICproducedkernel.stripped(15.7 MB) andkernel.debug(119 MB) with zero compile errors.hammer2_flush.orebuilt 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)
sys/vfs/hammer2/hammer2_flush.c:397β retry loop condition (info.parent != chain->parent)sys/vfs/hammer2/hammer2_flush.c:403β unguardedhammer2_chain_drop(info.parent)sys/vfs/hammer2/hammer2_flush.c:405β unguardedhammer2_chain_ref(info.parent)β NULL derefsys/vfs/hammer2/hammer2_flush.c:382-383β setup (correctly guarded)sys/vfs/hammer2/hammer2_flush.c:434-435β teardown (correctly guarded)sys/vfs/hammer2/hammer2_flush.c:662βhammer2_chain_unlock(chain)(race window open)sys/vfs/hammer2/hammer2_flush.c:665βhammer2_chain_lock(chain)(race window close)sys/vfs/hammer2/hammer2_flush.c:681-689β parent-change detection, sets retry=1sys/vfs/hammer2/hammer2_chain.c:3559βchain->parent = NULLin delete_helpersys/vfs/hammer2/hammer2_chain.c:260βatomic_fetchadd_int(&chain->refs,1)in chain_refsys/vfs/hammer2/hammer2_chain.c:350βKKASSERT(chain->refs > 0)in chain_drop
Fix verification
not_testableNOT 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.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_flush.c:397
- sys/vfs/hammer2/hammer2_flush.c:403
- sys/vfs/hammer2/hammer2_flush.c:405
- sys/vfs/hammer2/hammer2_flush.c:382
- sys/vfs/hammer2/hammer2_flush.c:434
- sys/vfs/hammer2/hammer2_flush.c:662
- sys/vfs/hammer2/hammer2_flush.c:681
- sys/vfs/hammer2/hammer2_chain.c:3559
- sys/vfs/hammer2/hammer2_chain.c:260
- sys/vfs/hammer2/hammer2_chain.c:350
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.
No comments yet.