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

NULL-deref panic in hammer2_flush retry loop when chain loses parent during flush

Summary

hammer2_flush retry loop calls hammer2_chain_ref(info.parent) at :405 without checking for NULL unlike initial setup at :382-383 which guards. When flush_core returns retry=1 because chain->parent changed to NULL during unlock/relock window (:662-665) which occurs when concurrent file unlink deletes chain info.parent set to chain->parent(NULL) at :404 then unconditionally dereferenced at :405. hammer2_chain_ref does atomic_fetchadd_int(&chain->refs 1) with no NULL guard faulting at small offset. Chains never directly reparented old->new only old->NULL. Cleanup at :434-435 DOES guard with if(info.parent) proving omission. Any local user with write access to mounted HAMMER2 via concurrent unlink+sync.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2568 Β· 17 files
FileTypeDescriptionSize
race_flush.c trigger-source v1: workers create+write+fsync+unlink, syncers 5.1 KB view raw
race_flush_v2.c trigger-source v2: shared dir + rename churn, 20 workers 3.5 KB view raw
race_flush_v3.c trigger-source v3: population churn + dir churn + sync 4.9 KB view raw
race_flush_v5.c trigger-source v5: focused fsync-vs-unlink + rename 4.6 KB view raw
race_flush_v6.c trigger-source v6: concurrent dirtier/unlinker shared pool (PRIMARY) 5.5 KB view raw
race_flush_v7.c trigger-source v7: 512KB files for indirect block chains 4.2 KB view raw
setup.sh trigger-source root: create vnode-backed hammer2 fs at /h2mnt 829 B view raw
build.sh build-script build the PoC 175 B view raw
run.sh run-script run the PoC as unprivileged user 866 B view raw
run.log run-log baseline run summary (unpatched, no panic) 196 B view raw
fix_run.log run-log patched kernel stress run (no panic) 104 B view raw
fix_build.log build-log single-fix kernel build output (rc=0) 5.6 MB ↓ download
fix.diff suggested-fix add NULL check before hammer2_chain_ref at flush.c:405 422 B view raw
dmesg.txt dmesg hammer2 mount messages 245 B view raw
env.txt environment uname, cc version, sysctls, kernel sha256 559 B view raw
VERDICT.md verdict full narrative analysis 6.5 KB ↓ raw
README.md readme human-readable build/run instructions 2.1 KB ↓ raw
README.md readme human-readable build/run instructions
↓ download raw

DF-2568 β€” hammer2_flush retry-loop NULL-deref panic

Finding

hammer2_flush retry loop at sys/vfs/hammer2/hammer2_flush.c:405 calls hammer2_chain_ref(info.parent) without checking for NULL, unlike the initial setup at :382-383 and cleanup at :434-435 which guard. When a concurrent unlink deletes the chain's parent during the flush's unlock/relock window (flush_core:662-665), chain->parent becomes NULL, and the retry's hammer2_chain_ref(NULL) dereferences a NULL pointer β†’ kernel panic.

PoC files

  • race_flush.c β€” v1: workers create+write+fsync+unlink, syncers (basic)
  • race_flush_v2.c β€” v2: shared dir + rename churn
  • race_flush_v3.c β€” v3: population churn + dir churn
  • race_flush_v5.c β€” v5: focused fsync-vs-unlink + rename
  • race_flush_v6.c β€” v6: concurrent dirtier/unlinker shared pool (PRIMARY)
  • race_flush_v7.c β€” v7: 512KB files for indirect chains
  • setup.sh β€” root: create vnode-backed hammer2 fs at /h2mnt
  • build.sh β€” build the PoC
  • run.sh β€” run the PoC as unprivileged user

Build

./build.sh    # or: cc -O2 -pipe -o race_flush race_flush.c

Setup (as root)

./setup.sh    # creates 2G vnode-backed hammer2 fs, mounts at /h2mnt, chowns /h2mnt/race to maxx

Run (as unprivileged user)

./run.sh      # races concurrent create+write+fsync+unlink+sync on /h2mnt/race

Or run a specific variant:

cc -O2 -pipe -o race_flush_v7 race_flush_v7.c
./race_flush_v7 -d /h2mnt/race -D 12 -U 12 -s 4 -p 16 -f 512 -t 300

Expected result

On an unpatched kernel IF the race is won: kernel panic (NULL deref in hammer2_chain_ref) β€” fatal trap 12: page fault while in kernel mode, fault address near 0.

In practice: The race is extremely narrow. After 45+ minutes of stress testing with 7 PoC variants, the race was never triggered (zero "LOST CHILD" messages with vfs.hammer2.debug=0x40). The code bug is real but the race window is too narrow for userspace concurrency to hit reliably.

Fix

See fix.diff β€” adds if (info.parent != NULL) guard before hammer2_chain_ref(info.parent) at flush.c:405, matching the pattern at :382-383 and :434-435.

VERDICT.md verdict full narrative analysis
↓ download raw

DF-2568 β€” hammer2_flush retry-loop NULL-deref (verification verdict)

Verdict: NOT REPRODUCED (race too narrow) β€” CODE BUG CONFIRMED, fix.diff validated

Summary

The code bug is real and confirmed: hammer2_flush retry loop at sys/vfs/hammer2/hammer2_flush.c:405 calls hammer2_chain_ref(info.parent) WITHOUT a NULL check, unlike the initial setup at :382-383 (if ((info.parent = chain->parent) != NULL)) and the cleanup at :434-435 (if (info.parent) hammer2_chain_drop(info.parent)). If info.parent is NULL at :405, hammer2_chain_ref(NULL) dereferences &NULL->refs β†’ kernel page-fault panic.

However, the race required to trigger the NULL condition was not reproducible in 45+ minutes of aggressive stress testing across 7 PoC variants. Zero "LOST CHILD" messages (the kernel's own debug indicator that chain->parent changed during a flush unlock/relock window) were observed, meaning the race window at flush_core:681 was never reached.

Mechanism (theoretical)

The race requires chain->parent to change from non-NULL to NULL during the flush_core unlock/relock window at hammer2_flush.c:662-665:

661: hammer2_chain_ref_hold(chain);
662: hammer2_chain_unlock(chain);              // ← chain unlocked
663: if (parent)
664:     hammer2_chain_lock(parent, ...);       // ← parent locked
665: hammer2_chain_lock(chain, ...);           // ← chain relocked

Between :662 (chain unlock) and :664 (parent lock), both chain and parent are unlocked. A concurrent unlink xop (hammer2_xop_unlink in hammer2_xops.c:334) that has already locked the parent directory and found the chain in its rbtree can:

  1. Lock the chain (at hammer2_chain_lookup β†’ hammer2_chain_lock)
  2. Call hammer2_chain_delete(parent, chain, ...) β†’ _hammer2_chain_delete_helper at hammer2_chain.c:3556-3559 sets chain->parent = NULL
  3. The flush relocks chain at :665, sees chain->parent != parent at :681 β†’ retry=1
  4. Back in hammer2_flush retry loop at :397: info.parent != chain->parent β†’ enters the block, :403 drops old parent, :404 sets info.parent = NULL, :405 calls hammer2_chain_ref(NULL) β†’ NULL deref panic

The flush xop (hammer2_xop_inode_flush at hammer2_flush.c:1292) and unlink xop (hammer2_xop_unlink) are dispatched to different xop thread groups (different inode hashes: file inode vs parent directory inode), so they CAN run concurrently on different CPUs.

Why the race was NOT triggered

Despite exhaustive testing, the race was never hit. Key factors:

  1. Extremely narrow timing window: The gap between :662 (chain unlock) and :664 (parent lock) is just a few function calls. The concurrent unlink xop must have already locked the parent AND found the chain in the rbtree AND be blocking on the chain lock at the precise moment the flush releases it at :662.

  2. XOP dispatch overhead: The unlink xop must be dispatched, picked up by an xop worker thread, lock the parent, call hammer2_chain_lookup (which scans the rbtree), and attempt to lock the child β€” all before the flush completes the downward recursion and reaches :662. For simple file inodes with few children, the downward recursion is very fast.

  3. No LOST CHILD observed: With vfs.hammer2.debug=0x40 enabled (which prints all 4 "LOST CHILD" variants when chain->parent changes during flush), zero messages appeared across all runs. This means the race at flush_core:681 was never reached, not even partially.

Testing performed

PoC Description Duration Result
v1 (race_flush.c) Workers: create+write+fsync+unlink, syncers 120s no panic
v2 (race_flush_v2.c) 20 workers shared dir + rename churn 180s no panic
v3 (race_flush_v3.c) Population churn + dir churn + sync 300s no panic
v4 (race_flush_v4.c) (pipe-barrier variant, not used) β€” β€”
v5 (race_flush_v5.c) Focused fsync-vs-unlink + rename 300s no panic
v6 (race_flush_v6.c) Concurrent dirtier/unlinker shared pool 300s no panic
v7 (race_flush_v7.c) 512KB files (indirect chains), root fs 240s no panic

Total: ~45 minutes of stress across vnode-backed and root hammer2 filesystems, with spread_workers=0 and =1, debug=0x40, 12-30 concurrent processes.

Configurations tested: - File sizes: 48B to 512KB (to vary flush tree depth) - Pool sizes: 8 to 64 files (to vary collision probability) - Worker counts: 12-30 processes - vfs.hammer2.spread_workers: 0 and 1 - vfs.hammer2.debug: 0x40 (LOST CHILD messages enabled) - Filesystems: vnode-backed hammer2 (/h2mnt) and root hammer2 (/)

Fix

The fix adds a NULL check before hammer2_chain_ref(info.parent) at :405, matching the pattern at :382-383 and :434-435:

--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -402,7 +402,8 @@
            }
            hammer2_chain_drop(info.parent);
            info.parent = chain->parent;
-           hammer2_chain_ref(info.parent);
+           if (info.parent != NULL)
+               hammer2_chain_ref(info.parent);
        }

This is a trivially-correct defensive fix: the existing code at :382-383 already handles info.parent == NULL by conditionally referencing, and the cleanup at :434-435 conditionally drops. The retry path at :405 was the only place missing this guard. With the fix, if chain->parent becomes NULL during the flush (as the race theoretically allows), the retry loop simply skips the ref and continues with info.parent = NULL, which flush_core handles correctly (parent is allowed to be NULL per the comment at :504).

Fix validation

The fix.diff was applied to the in-guest source, compiled into a single-fix kernel (make -j6 nativekernel, rc=0), installed as /boot/kernel/kernel, and booted as 6.5-DEVELOPMENT #1. The patched kernel boots correctly and handles the stress workload without issues.

Since the race did not trigger on the unpatched kernel (no panic to compare against), the fix cannot be validated via before/after panic comparison. Instead, the fix is validated by: 1. Compiles cleanly (rc=0, no warnings) 2. Boots correctly (#1 kernel, all services start) 3. Handles stress workload (240s stress run, no panic, guest stays up) 4. Code correctness is obvious (matches existing guarded patterns)

fix_status: not_testable β€” the race can't be triggered to produce a panic on the unpatched kernel, so no before/after panic comparison is possible. The fix.diff applies, compiles, and boots; the code change is trivially correct.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable: race not triggerable on unpatched #0 baseline (45+ min stress, zero LOST CHILD, no panic), so no before/after panic comparison possible. fix.diff validated to: (1) apply cleanly (patch -p1: Hunk #1 succeeded at 402), (2) compile cleanly (make -j6 nativekernel rc=0), (3) boot correctly as #1 kernel, (4) handle same 240s stress workload without panic. Code change trivially correct β€” adds NULL guard matching existing guarded patterns at :382-383 and :434-435.

baseline #0: 45min stress, EXIT=124 (timeout), no panic, LOST CHILD=0. patched #1: 240s stress EXIT=124, no panic, LOST CHILD=0, sha256=d6a5eeb0... β€” both kernels handle workload identically since race never triggers. Fix compiles+boots correctly; code change defensive and matches existing patterns.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 23:13:15 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none

Evidence (decisive lines)

45+ min stress, 7 PoC variants, zero LOST CHILD messages, no panic: v1(120s) EXIT=0 / v2(180s) EXIT=0 / v3(300s) EXIT=0 / v5(300s) EXIT=0 / v6(300s) EXIT=0 / v7-vnode(300s) EXIT=0 / v7-root(240s) EXIT=0. All runs guest UP, dmesg LOST CHILD count=0. Code bug confirmed: flush.c:405 has no NULL check vs :382-383 (guarded) and :434-435 (guarded). hammer2_chain_ref at chain.c:258 does atomic_fetchadd_int(&chain->refs,1) which faults at &(NULL)->refs if chain is NULL.

PoC changes

Authored all PoC sources from scratch (dir empty). 7 variants: race_flush.c (v1), race_flush_v2.c (shared dir+rename), race_flush_v3.c (population+dir churn), race_flush_v5.c (focused fsync-vs-unlink), race_flush_v6.c (concurrent dirtier/unlinker shared pool β€” key insight that fsync and unlink must be separate processes), race_flush_v7.c (512KB files for indirect chains). Plus setup.sh (root: newfs_hammer2+mount vnode-backed fs), build.sh, run.sh.

Verified recommended fix

Add NULL check before hammer2_chain_ref(info.parent) at flush.c:405, matching pattern at :382-383: 'if (info.parent != NULL) hammer2_chain_ref(info.parent);'. One-line defensive guard making retry loop consistent with initial setup and cleanup code. Matches finding proposal. Full diff in findings/poc/DF-2568/fix.diff (git apply --check OK).

Verdict

NOT REPRODUCED β€” code bug confirmed but race too narrow to trigger. The code defect at hammer2_flush.c:405 is REAL: hammer2_chain_ref(info.parent) lacks a NULL guard that the initial setup at :382-383 and cleanup at :434-435 both have. If chain->parent becomes NULL during flush_core's unlock/relock window (:662-665), the retry loop dereferences NULL -> panic. The race mechanism was traced line-by-line: flush xop (file inode hash) and unlink xop (parent dir inode hash) dispatch to DIFFERENT xop threads and CAN run concurrently; during :662-665 window both chain and parent chain locks released, allowing concurrent hammer2_chain_delete to set chain->parent = NULL (chain.c:3559). HOWEVER, after 45+ min aggressive stress testing across 7 PoC variants (v1-v7: create+write+fsync+unlink+sync racing on vnode-backed and root hammer2, 12-30 concurrent processes, spread_workers=0 and =1, 48B-512KB files, debug=0x40), ZERO 'LOST CHILD' messages appeared β€” meaning flush_core:681 (chain->parent != parent after unlock/relock) was NEVER reached, not even once. Race window genuinely too narrow for userspace concurrency.