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

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/.

--- 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2662 Β· 10 files
FileTypeDescriptionSize
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)

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.

VERDICT.md
↓ download raw

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Sun Aug 30 06:51:32 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (kernel C)

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).