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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2643 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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(sysctlvfs.hammer2.df2643_harnessthat releases the freshly-acquired chain and forcesparent = NULLright afterhammer2_inode_chain()inhammer2_xop_strategy_writeβ i.e. the exact post-race state thehammer2_admin.c:556-563XXX 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
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= theparent->bref.typeloop-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 traplines; 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
- sys/vfs/hammer2/hammer2_strategy.c:623-626 (unguarded pass-through)
- sys/vfs/hammer2/hammer2_strategy.c:340-364 (the read xop's guard)
- sys/vfs/hammer2/hammer2_strategy.c:734 (assign_physical -> chain_lookup)
- sys/vfs/hammer2/hammer2_strategy.c:760 (later (*parentp)->hmp deref site)
- sys/vfs/hammer2/hammer2_chain.c:2426-2429 (actual first deref, parent->bref.type, offset 0x98 confirmed by trap)
- sys/vfs/hammer2/hammer2_inode.c:408-428 (hammer2_inode_chain NULL return)
- sys/vfs/hammer2/hammer2_admin.c:556-563 (XXX race admission)
- sys/vfs/hammer2/hammer2_strategy.c:675-681 (existing EIO completion the fix routes into)
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
fixedfix.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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_strategy.c:623-626
- sys/vfs/hammer2/hammer2_strategy.c:340-364
- sys/vfs/hammer2/hammer2_strategy.c:734
- sys/vfs/hammer2/hammer2_strategy.c:760
- sys/vfs/hammer2/hammer2_chain.c:2426-2429
- sys/vfs/hammer2/hammer2_inode.c:408-428
- sys/vfs/hammer2/hammer2_admin.c:556-563
- sys/vfs/hammer2/hammer2_strategy.c:675-681
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.
No comments yet.