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

hammer2_xop_strategy_write dereferences the inode chain without the NULL check the read xop performs β€” NULL-deref panic if the inode's cluster chain is concurrently gone

Field Value
ID DF-2643
Status new
Severity Low
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_strategy.c
Lines 623-624 (compare :340-354)
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

The write xop does parent = hammer2_inode_chain(ip, clindex, HAMMER2_RESOLVE_ALWAYS); (strategy.c:623) and immediately passes &parent into hammer2_write_file_core/hammer2_assign_physical, whose first dereference is hammer2_dedup_lookup((*parentp)->hmp,...) (strategy.c:760) or hammer2_chain_lookup(parentp,...) β€” a NULL deref/panic. The read xop in the same file checks (if (parent) with an EIO fallback, :340-354). hammer2_inode_chain returns NULL when ip->cluster.array[clindex].chain is NULL (inode.c:408-427), which hammer2_inode_repoint's leftover-chain loop (inode.c:1430-1438) and device-removal paths (vfsops.c:631, 821) can produce; xop_start_except's own XXX admits the race (admin.c:556-563).

Threat model & preconditions

Local DoS (kernel NULL-deref panic) during unmount/device-removal/repoint races on multi-device or error-degraded PFSs; speculative on default single-device mounts. If triggered, the bio is never completed, lwinprog leaks, and the strategy pipe wedges.

Proof of concept

Not verified (Phase V skipped: Low/speculative, no deterministic trigger on the single-device INVARIANTS guest). Recommended trigger study: multi-device PFS with one device failed/removed while dirty buffers flush.

Mirror the read path: if (parent) { hammer2_write_file_core(...); unlock/drop; } else { error = HAMMER2_ERROR_EIO; } hammer2_xop_feed(&xop->head, NULL, clindex, error); so the write completes with EIO instead of dereferencing NULL.

Timeline

  • 2026-08-29 Discovered during pass-2 audit of hammer2_strategy.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2643 Β· 13 files
FileTypeDescriptionSize
harness.diff β€” 1.1 KB view raw
fix.diff β€” 1.0 KB view raw
run_panic_test.sh β€” 939 B view raw
runfix_test.sh β€” 1.4 KB view raw
panic.txt β€” 1.1 KB view raw
runfix.knob-on.log β€” 4.0 KB view raw
k3build.log β€” 5.6 MB ↓ download
k3install.log β€” 80.4 KB view raw
env.txt β€” 1.3 KB view raw
VERDICT.md β€” 6.4 KB ↓ raw
README.md β€” 4.3 KB ↓ raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 5.0 KB view raw

DF-2643 β€” hammer2 write xop dereferences the inode chain without the NULL check the read xop performs

sys/vfs/hammer2/hammer2_strategy.c:623-626 β€” hammer2_xop_strategy_write() passes &parent from hammer2_inode_chain() straight into hammer2_write_file_core() with no NULL check, while the read xop guards the identical call (:340-364). If the inode's cluster chain is gone when the write xop thread runs, the first dereference of the NULL parent is parent->bref.type inside hammer2_chain_lookup() (hammer2_chain.c:2426-2429, reached from strategy.c:734 via hammer2_assign_physical) β€” NULL-deref panic in a kernel xop thread, plus a stuck bio.

Verified on the QEMU guest

DragonFly 6.5-DEVELOPMENT, INVARIANTS kernels rebuilt in-guest from the guest's /usr/src (md5-identical to the audited tree):

  • K2 = stock code + harness.diff (sysctl vfs.hammer2.df2643_harness that releases the freshly-acquired chain and forces parent = NULL right after hammer2_inode_chain() in hammer2_xop_strategy_write β€” i.e. the exact post-race state the hammer2_admin.c:556-563 XXX comment admits is possible)
  • K3 = stock code + harness + fix.diff (the NULL guard)

Demonstrated

# observation evidence
1 Line-accurate asymmetry: read xop guards hammer2_inode_chain() == NULL (strategy.c:340-354), write xop does not (:623-626); hammer2_inode_chain() can return NULL (hammer2_inode.c:408-428: clindex >= nchains or empty slot) VERDICT.md
2 K2 (stock write path, forced NULL): write + sync β†’ Fatal trap 12, fault VA 0x98, Stopped at hammer2_chain_lookup+0x92: movzbl 0x98(%rbx),%eax β€” the NULL parent's bref.type read β€” all CPUs stopped, machine halted at db> panic.txt
3 K3 (same forced NULL + guard): no panic; every affected strategy write fails with EIO (xop_strategy_write: error 1 loff=... Γ—20948, the flusher retrying the still-dirty buffer β€” harness makes every retry fail, a real race fails once); guest alive and responsive; 0 Fatal traps runfix.knob-on.log
4 K3 knob OFF: writes succeed normally before and after (no behavioral delta from the guard) runfix_test.sh

Reachability (honest)

hammer2_inode_chain() returns NULL only when the inode's cluster slot for clindex is empty or nchains shrank. A strategy xop is only queued to a thread whose cluster slot was non-NULL at xop_start time (hammer2_admin.c:556-563, whose XXX comment explicitly admits the array is not stable and races can NULL-out chains). So the panic needs the slot to transition to NULL between queue and run: device removal / cluster repoint / PFS teardown racing an in-flight write β€” root-triggered admin operations (multi-device or replication topologies; this snapshot has no runtime volume-del, so no natural single-device trigger exists). The mechanical deref-and-panic is proven by the harness; the natural race window is speculative. Severity Low stands.

Fix (validated)

fix.diff adds the same guard the read xop has: if parent == NULL, skip hammer2_write_file_core(), error = HAMMER2_ERROR_EIO, feed it, and let the existing frontend completion fail the bio with EIO (:675-681). fix.diff applies cleanly on stock; K3 (harness+fix) A/B validated above.

Reproduce

Build harness kernel in guest /usr/src:

patch -p0 < harness.diff            # K2 (panic demo)
make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel
shutdown -r now
./run_panic_test.sh                 # panics into db> (capture serial)

then rebuild with the guard:

patch -p0 < fix.diff                 # K3
make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel
shutdown -r now
./runfix_test.sh                     # A/B/C: EIO, no panic, guest alive

Files

harness.diff          K2 sysctl knob (DF2643 HARNESS ONLY, not upstream)
fix.diff              the upstream NULL guard (git-apply-able)
run_panic_test.sh     K2 trigger (write + sync -> panic)
runfix_test.sh        K3 A/B/C validation script
panic.txt             Fatal trap 12 @ hammer2_chain_lookup+0x92 (K2)
runfix.knob-on.log    K3 knob-on console tail (error 1 spam, 0 traps)
k3build.log           K3 full build log (BUILD_RC=0)
k3install.log         K3 install log
env.txt VERDICT.md manifest.json verdict.json
VERDICT.md
↓ download raw

DF-2643 VERDICT β€” REPRODUCED (mechanically, via sanctioned harness); reachability remains the admin-op race; fix validated

The claim, made line-accurate

hammer2_xop_strategy_write() (sys/vfs/hammer2/hammer2_strategy.c:577-686) acquires the per-clindex parent chain at :623

parent = hammer2_inode_chain(ip, clindex, HAMMER2_RESOLVE_ALWAYS);

and immediately passes &parent into hammer2_write_file_core() (:624-626) with no NULL check. The read xop performs the identical acquisition at :340 and explicitly handles NULL at :351-354:

} else {
    error = HAMMER2_ERROR_EIO;
    chain = NULL;
}

hammer2_inode_chain() (hammer2_inode.c:408-428) returns NULL when clindex >= cluster->nchains or the cluster slot is empty.

Where the NULL first dereferences (refinement of the finding)

The finding cites strategy.c:760 ((*parentp)->hmp inside hammer2_assign_physical) as the first deref. In fact every write-path variant reaches hammer2_chain_lookup(parentp, ...) BEFORE that:

  • COMP_NONE: write_file_core :847 -> assign_physical :734 chain_lookup
  • AUTOZERO/non-zero: zero_check_and_write :1223 -> assign_physical :734
  • zero block: zero_write :1278 chain_lookup
  • LZ4/ZLIB: compress_and_write :1061 -> assign_physical :734

and hammer2_chain_lookup() derefs the parent immediately at hammer2_chain.c:2426-2429:

parent = *parentp;
*errorp = 0;
while (parent->bref.type == HAMMER2_BREF_TYPE_INDIRECT || ...

bref sits at offset 0x98 in hammer2_chain on this kernel β€” which is exactly where the machine faulted (below). strategy.c:760 would be the next deref (only reachable if the lookup tolerated NULL, which it does not).

Reproduction (K2 = stock code + harness knob)

Because this snapshot has no runtime device-removal path (only hammer2 volume-list exists in sbin/hammer2; no volume-add/del), the natural trigger (cluster slot NULLing between xop queue and run β€” the exact race hammer2_admin.c:556-563 XXX admits: "does not address possible races against inode updates which might NULL-out a chain") cannot be staged naturally on the single-device guest. Per the task directive, a harness kernel was built: harness.diff adds vfs.hammer2.df2643_harness, which β€” only when enabled β€” releases the chain hammer2_inode_chain() just acquired (unlock+drop, so nothing leaks) and sets parent = NULL before hammer2_write_file_core(). This is precisely the post-race state.

Kernel #2 (uname ... #2: Tue Oct 20 03:34:11 UTC 2026), STOCK write path:

# sysctl vfs.hammer2.df2643_harness=1
# dd if=/dev/zero of=/mnt/zdir/zfile bs=64k count=1   (buffered, OK)
# sync

-> serial console (panic.txt):

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x98
fault code = supervisor read data, page not present
Stopped at hammer2_chain_lookup+0x92: movzbl 0x98(%rbx),%eax
CPU3 stopping CPUs: 0x00000037
 stopped
db>
  • fault VA 0x98 = &parent->bref.type with parent == NULL (rbx = 0).
  • faulting frame hammer2_chain_lookup+0x92 = the parent->bref.type loop-head read at hammer2_chain.c:2429 β€” the exact instruction predicted from source.
  • all CPUs stopped; machine dead at DDB; the bio is never completed (stuck-write behavior on a live system).

Fix validation (K3 = stock + harness + fix.diff)

Kernel #3 (uname ... #1: Tue Oct 20 03:49:24 UTC 2026) adds the guard (fix.diff): NULL parent -> skip write_file_core, error = HAMMER2_ERROR_EIO, existing feed/collect/completion path fails the bio with EIO (strategy.c:675-681).

Same forced-NULL operation (knob=1, dd + sync):

  • No panic. 0 Fatal trap lines; guest stayed up and answered ssh.
  • Every strategy write attempt with the forced-NULL parent logged xop_strategy_write: error 1 loff=0000000000000000 (error 1 = HAMMER2_ERROR_EIO) β€” 20948 messages because the harness forces EVERY retry of the still-dirty buffer to fail (a real race fails once and the retry succeeds); the livelock is a harness artifact, not fix behavior.
  • knob=0 before/after: normal writes succeed β€” zero behavioral delta for healthy code paths.

Reachability honesty (unchanged from the finding)

  • Requires the inode cluster slot for the running xop thread to transition to NULL (or nchains to shrink) between xop queue time and run time: device removal / cluster repoint / PFS teardown racing in-flight writes.
  • xop_start only queues threads whose slots were non-NULL at queue time (hammer2_admin.c:556-563 + its XXX comment admitting the instability).
  • All such transitions are root-triggered admin operations on multi-device/replication topologies; none exist in this snapshot for a single-device mount. No unprivileged or remote trigger.
  • Therefore: mechanical NULL-deref = certain (captured), natural trigger = speculative race (root admin ops). Low severity confirmed. Impact ceiling: kernel NULL-deref panic (DoS) + one stuck bio; no memory-safety primitive (fixed-address read fault at 0x98).

Kernel references

Bottom line

REPRODUCED via the sanctioned harness on a rebuilt stock-code kernel: the write xop's missing NULL guard turns the admin.c-admitted chain-NULL race into a deterministic NULL-deref panic at hammer2_chain_lookup+0x92 (fault VA 0x98, all CPUs stopped). The one-line guard fix (mirroring the read xop) was A/B validated on the same harness: forced-NULL writes now fail with EIO through the existing error completion, no panic, healthy paths unchanged. Natural trigger remains the root-admin device/repoint race β€” Low severity stands.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (NULL guard mirroring the read xop) validated on rebuilt kernel K3 with the same harness knob armed: the forced-NULL write that panicked K2 at hammer2_chain_lookup+0x92 now completes through the existing error path with per-attempt EIO (xop_strategy_write: error 1), zero Fatal traps, guest alive and responsive; knob-off writes succeed unchanged

panic.txt (K2 Fatal trap 12 at 0x98) vs runfix.knob-on.log (K3: 20948 EIO messages, 0 Fatal traps, guest alive); k3build.log BUILD_RC=0
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Oct 20 03:49:24 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt (Fatal trap 12, fault VA 0x98, Stopped at hammer2_chain_lookup+0x92, CPU3 stopping CPUs, db>) captured from the K2 serial console; runfix.knob-on.log (K3 same forced-NULL operation: xop_strategy_write error 1 spam, 0 Fatal traps, guest alive); harness.diff (the knob, unlock+drop then parent=NULL); fix.diff (the guard); k3build.log (K3 BUILD_RC=0)

PoC changes

No seed rig existed. Built the two-kernel A/B harness: harness.diff adds a runtime sysctl vfs.hammer2.df2643_harness that, only when enabled, does hammer2_chain_unlock+drop and parent=NULL right after the inode_chain acquisition in xop_strategy_write (leak-free; the unlock/drop block it skips matches what inode_chain acquired); the natural multi-device teardown trigger does not exist in this snapshot (sbin/hammer2 has only volume-list, no volume-del), so the interposed-state harness prescribed by the task directive was used. fix.diff is the minimal upstream guard. Both kernels built in-guest with make nativekernel (BUILD_RC=0).

Verified recommended fix

Add the NULL guard the read xop already has: if hammer2_inode_chain() returns NULL in hammer2_xop_strategy_write, skip hammer2_write_file_core and feed HAMMER2_ERROR_EIO so the existing completion fails the bio with EIO (see fix.diff)

Verdict

REPRODUCED mechanically on a rebuilt stock-code kernel using the sanctioned race-stall harness: hammer2_xop_strategy_write (strategy.c:623-626) passes the chain from hammer2_inode_chain() into hammer2_write_file_core() with no NULL guard, unlike the read xop (:340-364). A harness sysctl knob (harness.diff, K2 kernel) releases the just-acquired chain and forces parent=NULL - the exact state the hammer2_admin.c:556-563 XXX comment admits a device-removal/repoint race can produce between xop queue and run. With the knob armed, a buffered write plus sync fired the async strategy write and the kernel took Fatal trap 12 at hammer2_chain_lookup+0x92 (movzbl 0x98(%rbx),%eax), fault VA 0x98 = parent->bref.type with parent NULL (hammer2_chain.c:2429 reached from strategy.c:734), all CPUs stopped, machine dead at db>. Line-accurate refinement: the first deref is the chain_lookup loop-head read at chain.c:2426-2429, not the finding-cited strategy.c:760 (*parentp)->hmp, which is the next deref site. Reachability remains honest-speculative: requires the cluster slot to NULL between xop_start and run (device removal/repoint/teardown racing in-flight writes - root admin ops on multi-device topologies; this snapshot exposes no volume-del so no natural single-device trigger). Fix validated A/B on K3 (harness + guard): same forced-NULL writes fail with EIO via the existing xop completion (xop_strategy_write: error 1 messages, zero Fatal traps, guest alive and responsive), knob-off writes unchanged. Low severity stands: kernel NULL-deref panic (DoS) plus one stuck bio; no memory-safety primitive (fixed-address read fault); no unprivileged or remote trigger.