hammer2_update_spans() dereferences chain->data of an EIO-failed inode chain (NULL deref panic, unmasked by DF-2661's fix)
| Field | Value |
|---|---|
| ID | DF-2662 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-476 NULL Pointer Dereference |
| File | sys/vfs/hammer2/hammer2_iocom.c |
| Lines | 285-325 (deref at :313) |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass β surfaced during hammer2_io.c verification) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The asynchronous post-mount SPAN walk (invoked from hammer2_autodmsg's
VOLDATA DUMP branch) calls hammer2_chain_lookup() and dereferences
chain->data->ipdata (iocom.c:313) without checking chain->error; a
chain whose load failed with EIO has data == NULL, giving a
NULL+offset read (meta.pfs_clid at +0x90). Needs no concurrency, unlike
DF-2661. Same loop also contains a bare continue for non-INODE chains
that never advances the iteration (infinite kernel loop if the
super-root ever contains a non-INODE entry).
NOTE: DF-2617's validated fix.diff already carried an iocom.c:313 guard hunk (skip errored chains); this finding independently reproduces and files the defect so it is tracked on its own merits.
Threat model & preconditions
Any device read error hitting a super-root PFS inode during the asynchronous post-mount SPAN walk panics the kernel β easier to hit than DF-2661 (mount + error, no concurrency). Local DoS / availability; pure NULL deref, no memory disclosure.
Proof of concept
Reproduced in isolation on kernel B (stock + DF-2661 fix + EIO
injector, so the DIO assert cannot fire): same trigger as DF-2661 β
Fatal trap 12: page fault while in kernel mode, fault virtual address
= 0x90, Stopped at hammer2_autodmsg+0x273: movq 0x90(%r15),%rax with
r15 = chain->data = NULL for the EIO-failed PFS inode (data_off
0x240040a). objdump pins the faulting loads to the
ripdata->meta.pfs_clid/pfs_fsid/pfs_type/filename copies. ARM=0 control
survived 5/5 rounds; kernel A (stock) died earlier in the DIO assert,
proving this bug was masked pre-fix. Evidence:
findings/poc/DF-2662/.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_iocom.c
+++ b/sys/vfs/hammer2/hammer2_iocom.c
@@ -311,8 +311,19 @@
while (chain) {
- if (chain->bref.type != HAMMER2_BREF_TYPE_INODE)
+ /*
+ * DF-2662: A chain whose data failed to load (e.g. EIO)
+ * has chain->data == NULL and must not be dereferenced.
+ * Also advance the iteration for skipped chains (the old
+ * bare `continue` looped forever on non-INODE entries).
+ */
+ if (chain->bref.type != HAMMER2_BREF_TYPE_INODE ||
+ chain->data == NULL) {
+ chain = hammer2_chain_next(&parent, chain, &key_next,
+ key_next, HAMMER2_KEY_MAX,
+ &error, 0);
continue;
+ }
ripdata = &chain->data->ipdata;
References
- DF-2661 (whose fix unmasked this), DF-2617 (fix hunk overlap)
Timeline
- 2026-08-29 Surfaced during the hammer2_io.c pass-2 verification; reproduced in isolation + fix authored same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2662 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.2 KB | β raw | |
| VERDICT.md | β | 3.4 KB | β raw | |
| panic.txt | β | 2.4 KB | view raw | |
| disasm_autodmsg.txt | β | 1.2 KB | view raw | |
| fix.diff | β | 802 B | view raw | |
| build_fix.sh | β | 535 B | view raw | |
| buildC.log | β | 5.7 MB | β download | |
| fix_run.log | β | 8.9 MB | β download | |
| manifest.json | β | 991 B | view raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-2662 β hammer2_update_spans() dereferences chain->data of an EIO-failed inode chain (NULL deref panic)
- File:
sys/vfs/hammer2/hammer2_iocom.c(found during the pass-2 audit ofsys/vfs/hammer2/hammer2_io.cβ unmasked while validating DF-2661's fix) - Severity: Medium (kernel NULL-deref panic; triggered by device read errors)
- Bucket: hammer2
Build
Kernel B (baseline for THIS bug) = stock + DF-2661 fix (fix_b.diff) +
DF-2661/inject.diff. With the DF-2661 assert fixed, the same trigger
exposes this bug in isolation.
Kernel C (fix validation) = kernel B source + fix.diff (this pack).
cd /usr/src patch -p1 < /root/df2661/inject.diff patch -p1 < /root/df2661/fix_b.diff patch -p1 < /root/df2662/fix.diff make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel ...
Run
sh /root/df2661/trigger_df2661.sh golden # once sh /root/df2661/trigger_df2661.sh 5 # ARM=1 default
Expected
Kernel B: Fatal trap 12: page fault ... Stopped at
hammer2_autodmsg+0x273: movq 0x90(%r15),%rax β r15 == 0 is
chain->data == NULL for a PFS inode whose read failed with EIO during the
post-mount async SPAN walk (hammer2_update_spans).
Kernel C: 5x ROUND_SURVIVED + ALL_ROUNDS_SURVIVED.
DF-2662 VERDICT β hammer2_update_spans() NULL deref on EIO-failed inode chain
Status: REPRODUCED (panic, kernel B) / FIXED (kernel C)
The bug
hammer2_update_spans() (sys/vfs/hammer2/hammer2_iocom.c:285) walks the
super-root's PFS entries asynchronously after every mount β it is invoked
from hammer2_autodmsg()'s "VOLDATA DUMP" branch (iocom.c:237) when the
auto-CONN handshake reply arrives.
The walk does:
chain = hammer2_chain_lookup(&parent, &key_next, ...);
while (chain) {
if (chain->bref.type != HAMMER2_BREF_TYPE_INODE)
continue;
ripdata = &chain->data->ipdata; /* <-- no error check */
hammer2_chain_lookup() returns INODE chains even when
hammer2_chain_load_data() failed: the chain comes back locked with
chain->error = HAMMER2_ERROR_EIO and chain->data == NULL
(hammer2_chain.c:996-1001 prints the "I/O error" line and leaves data
NULL). update_spans never checks either and dereferences
chain->data->ipdata -> NULL+offset reads (meta.pfs_clid at +0x90).
Secondary latent bug in the same loop: the bare continue for non-INODE
chains never advances the iteration (hammer2_chain_next is only called
at the loop tail) -> infinite kernel loop should the super-root ever
contain a non-INODE entry. The fix advances before continuing.
Reproduction (kernel B = stock + DF-2661 fix + EIO injector)
Same trigger as DF-2661 (512MB vn-backed hammer2 volume, injector armed, concurrent cold inode loads). With DF-2661's assert fixed, the box no longer dies in the DIO layer; instead the post-mount async SPAN walk hits the injected EIO on a PFS inode (data_off 0x240040a) and:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x90 instruction pointer = 0x8:0xffffffff80967933 Stopped at hammer2_autodmsg+0x273: movq 0x90(%r15),%rax
Disassembly (disasm_autodmsg.txt) pins the faulting load to the
ripdata->meta.pfs_clid/pfs_fsid/pfs_type/filename copy into the freshly
allocated LNK_SPAN message (offsets 0x90/0x98/0xa0/0x100 exactly match
struct hammer2_inode_data). See panic.txt for the full transcript.
This crash was observed WHILE the DF-2661 fix was active β i.e. it is an independent, pre-existing EIO-path bug (previously masked on stock kernels by DF-2661's earlier panic in the same workload).
Fix validation (kernel C = kernel B + fix.diff)
fix.diff skips chains whose data failed to load and advances the
iteration for skipped entries:
if (chain->bref.type != HAMMER2_BREF_TYPE_INODE ||
chain->data == NULL) {
chain = hammer2_chain_next(...);
continue;
}
Kernel C survived the identical 5-round EIO storm (ROUND_SURVIVED x5), see fix_run.log. (Same run also re-validates DF-2661's fix end-to-end.)
Threat model
Same environmental class as DF-2661: any device read error hitting a super-root PFS inode during the asynchronous post-mount SPAN walk panics the kernel. Timing-wise this one is even easier to hit than DF-2661 β no concurrency is required, the async walk alone suffices if the PFS inode read fails. Impact: local DoS. No memory disclosure (pure NULL deref), no privilege boundary. Severity: Medium.
Kernel references
- sys/vfs/hammer2/hammer2_iocom.c:285-325 (update_spans walk, missing chain->error/chain->data check; bare-continue infinite loop)
- sys/vfs/hammer2/hammer2_iocom.c:237 (async invocation from autodmsg)
- sys/vfs/hammer2/hammer2_chain.c:985-1006 (lookup returns chain with data == NULL and error == EIO)
Fix verification
fixedBaseline kernel B crashed at hammer2_autodmsg+0x273 (NULL deref of chain->data in the update_spans walk). Kernel C (fix applied) survived the identical 5-round EIO storm twice: ROUND_SURVIVED x5, ALL_ROUNDS_SURVIVED, RC=0. fix_baseline_reproduced=1 (trap before fix), fix_patched_reproduced=0 (no trap after fix).
fix_run.log (kernel C storm survival); panic.txt + disasm_autodmsg.txt (kernel B baseline crash); build.log / fix_build.log
Confirmed kernel references
Detail
Exploit chain
N/A for privilege escalation: pure NULL-pointer dereference (read of fixed offsets from NULL), no attacker-controlled memory content. Impact ceiling is local DoS on device read error during/after mount.
Evidence (decisive lines)
panic.txt (Fatal trap 12 transcript + root-cause annotation); disasm_autodmsg.txt (objdump pinning the faulting loads to the ripdata field copies); fix_run.log (kernel C: ROUND_SURVIVED x5 + ALL_ROUNDS_SURVIVED); build.log (kernel B baseline build), fix_build.log (kernel C build); VERDICT.md narrative
PoC changes
No seed existed (found while validating DF-2661's fix). The DF-2661 trigger is reused verbatim; the discrimination experiments (ARM=0 control surviving 5/5 on kernel B; storm crash on kernel B; storm survival on kernel C) isolate this bug from both DF-2661 and generic mount/umount racing.
Verified recommended fix
In hammer2_update_spans(): treat chains with chain->data == NULL (failed load) like non-INODE chains, and advance the iteration with hammer2_chain_next() before continuing.
Verdict
hammer2_update_spans() (hammer2_iocom.c:285) walks the super-root PFS entries asynchronously after every mount (invoked from hammer2_autodmsg's VOLDATA DUMP branch at iocom.c:237) and dereferences chain->data->ipdata without checking chain->error: an inode chain whose load failed with EIO comes back from hammer2_chain_lookup with data == NULL (chain.c:996-1001), producing a NULL+offset read (meta.pfs_clid at +0x90). Reproduced in isolation on kernel B (stock + DF-2661 fix + EIO injector): Fatal trap 12, fault VA 0x90, 'Stopped at hammer2_autodmsg+0x273: movq 0x90(%r15),%rax' with r15 = chain->data = NULL; objdump of the faulting region matches the ripdata->meta.pfs_clid/pfs_fsid/pfs_type/filename field copies (offsets 0x90/0x98/0xa0/0x100). This bug is pre-existing and was masked on stock kernels by DF-2661's earlier panic in the same EIO workload. Unlike DF-2661 it needs no concurrency - the async post-mount walk alone suffices if the PFS inode read fails. Fix (skip chains with data == NULL, and advance the iteration instead of the old bare continue which could spin forever on non-INODE entries) validated on kernel C: identical 5-round EIO storm survives completely (twice).
No comments yet.