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

hammer2_xop_inode_flush NULL-derefs the PFS-root chain during sync when its media re-read fails (kernel panics on a recoverable I/O error)

Field Value
ID DF-2644
Status new
Severity Medium
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_flush.c
Lines 1334-1338
Area vfs
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_xop_inode_flush() locks the PFS-root chain with HAMMER2_RESOLVE_ALWAYS (flush.c:1319); on a media read failure hammer2_chain_load_data() leaves chain->data == NULL with chain->error set (chain.c:1003-1008). flush_core() handles the error'd chain correctly ("hammer2: chain error during flush", flush.c:671-679), but xop_inode_flush() ignores both chain->error and the entirely-discarded return of hammer2_flush() (:1331) and copies pmp->pfs_iroot_blocksets[clindex] = chain->data->ipdata.u.blockset (:1336-1337) β€” a NULL-page fault at offset 0x200. Any dirty child sets ONFLUSH on the iroot chain (FLUSH_MASK), so a plain sync of a PFS on failing/yanked/corrupted media crashes the kernel instead of returning EIO.

Threat model & preconditions

Unprivileged-triggerable kernel panic (availability) under an environmental media-failure condition during routine sync of a mounted hammer2 PFS; the surrounding code paths prove the error was meant to be handled gracefully.

Proof of concept

Reproduced on the guest (kernel A = stock + disclosed fault injector in _hammer2_io_getblk forcing EIO on inode-block reads of the 512MB test volume only β€” byte-exact real-media semantics; vnconfig -u is refused while mounted (vn.c:464) and backing-file truncation gives short reads without B_ERROR (vn.c:342-371)). Trigger: mount fresh PFS on vn, mkdir +touch (ONFLUSH on iroot), settle, arm vfs.hammer2.df2644_fail_inode_read, sync β†’ hammer2: chain error during flush then Fatal trap 12: fault virtual address = 0x200 / Stopped at hammer2_xop_inode_flush+0x39c β€” exactly the blockset copy. Fix validated on rebuilt kernel B (only delta = fix.diff): identical sequence passes the site with no fault. Evidence: findings/poc/DF-2644/.

--- a/sys/vfs/hammer2/hammer2_flush.c
+++ b/sys/vfs/hammer2/hammer2_flush.c
@@ -1331,7 +1331,8 @@
            if (chain->parent)
                hammer2_chain_setflush(chain->parent);
-           hammer2_flush(chain, xflags);
+           flush_error |= hammer2_flush(chain, xflags);

            /* XXX cluster */
-           if (ip == pmp->iroot && pmp != hmp->spmp) {
+           if (ip == pmp->iroot && pmp != hmp->spmp &&
+               chain->error == 0 && chain->data != NULL) {
                hammer2_spin_ex(&pmp->blockset_spin);
                pmp->pfs_iroot_blocksets[clindex] =
                    chain->data->ipdata.u.blockset;

(guard the dereference and propagate the flush error so the volume header is not synchronized either)

References

  • chain.c:998-1009 (error state), flush.c:671-679 (the working handler one frame away)

Timeline

  • 2026-08-29 Discovered during pass-2 audit of hammer2_flush.c (GLM 5.3); reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2644 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
verdict.json β€” 4.2 KB view raw
manifest.json β€” 1.4 KB view raw
run_df2644_inj.sh β€” 1.3 KB view raw
validate_fix_df2644.sh β€” 819 B view raw
inject.diff β€” 998 B view raw
fix.diff β€” 884 B view raw
build_kernelA.sh β€” 352 B view raw
build_kernelA2.sh β€” 217 B view raw
build_kernelB.sh β€” 434 B view raw
panic.txt β€” 2.4 KB view raw
fix_run.log β€” 2.0 KB view raw
fix_run_partial_3112.txt β€” 2.5 KB view raw
code_hashes.txt β€” 647 B view raw

DF-2644 β€” NULL-deref of error'd PFS-root chain in hammer2_xop_inode_flush during sync

Site: sys/vfs/hammer2/hammer2_flush.c:1334-1338 (pmp->pfs_iroot_blocksets[clindex] = chain->data->ipdata.u.blockset;)

Reach: unprivileged-triggerable kernel-panic condition on environmental media failure: any PFS whose root-inode chain cannot be re-read from media during a filesystem sync (dying/pulled disk, short read, CRC-failed block evicted from cache, dd-corrupted image) panics the kernel with a NULL-pointer dereference instead of returning an error from sync(2).

Reproduce

Three pieces in this pack:

  • inject.diff β€” environment simulation only (never a fix): sysctl- gated fault injector that fails inode-block reads with EIO (vfs.hammer2.df2644_fail_inode_read). Needed because a real read error cannot be manufactured on the QEMU guest: vnconfig -u is refused while the mount holds the device open (VNIOCDETACH checks disk_getopencount() > 1, sys/dev/disk/vn/vn.c:464-466), and truncating the vn backing file yields short reads without B_ERROR (vnstrategy uses VOP_READ uio, sys/dev/disk/vn/vn.c:342-371).
  • fix.diff β€” the actual fix (guard + error propagation).
  • run_df2644_inj.sh β€” trigger script (mount, dirty PFS root, drop chain data, arm injector, sync).

Kernel A = stock + inject.diff (panic expected). Kernel B = stock + inject.diff + fix.diff (panic must be gone; sync survives; hammer2: chain error during flush messages still present).

# on the guest, with kernel A booted:
sh /root/df2644/run_df2644_inj.sh        # -> panic in hammer2_xop_inode_flush

Expected stock-kernel signature (serial console / vm.sh log): hammer2: chain error during flush messages followed by a kernel page fault cpuid = ...; lapic = ... with backtrace frames hammer2_xop_inode_flush() ... hammer2_flush_core() ... and the faulting instruction in the blockset-copy line (small constant offset deref of a NULL chain->data).

Why the crash is real and not injector-specific

  • hammer2_inode_chain(ip, clindex, HAMMER2_RESOLVE_ALWAYS) (hammer2_flush.c:1319) returns the iroot chain locked but error'd when hammer2_chain_load_data() hits an I/O error: hammer2_chain.c:1003-1008 sets chain->error = HAMMER2_ERROR_EIO, releases the dio and returns with chain->data == NULL.
  • chain->flags & HAMMER2_CHAIN_FLUSH_MASK (hammer2_flush.c:1322) is satisfied by HAMMER2_CHAIN_ONFLUSH, which any dirty child under the PFS root sets on the iroot chain (hammer2_chain_setflush stops at the inode inflection, hammer2_chain.c:147-166).
  • hammer2_flush()/hammer2_flush_core() handle the error'd chain correctly (hammer2_flush.c:671-679: report, accumulate, skip) β€” the code even prints "hammer2: chain error during flush".
  • hammer2_xop_inode_flush() then ignores both the chain error and the flush return value and dereferences chain->data->ipdata.u.blockset at :1336-1337 β†’ NULL-page fault β†’ panic. (The injector only makes the device fail, exactly like failing hardware.)

Stage-1 per-inode flushes in the same sync take the graceful path, which the run log shows as interleaved "unable to fsync inode" messages β€” demonstrating the contrast between the handled paths and this one.

VERDICT.md
↓ download raw

DF-2644 β€” VERDICT

Finding: NULL-pointer dereference of an error'd PFS-root chain in hammer2_xop_inode_flush() β€” sys/vfs/hammer2/hammer2_flush.c:1334-1338 dereferences chain->data->ipdata.u.blockset after a flush that failed because the chain could not be re-read from media.

Classification: REPRODUCED (panic), fix FIXED (site-specific).

1. Root cause

  • hammer2_inode_chain(ip, clindex, HAMMER2_RESOLVE_ALWAYS) (hammer2_flush.c:1319) returns the PFS-root (iroot) chain locked; when the underlying inode block cannot be read, hammer2_chain_load_data() (hammer2_chain.c:998-1009) sets chain->error = HAMMER2_ERROR_EIO, releases the dio and returns with chain->data == NULL.
  • chain->flags & HAMMER2_CHAIN_FLUSH_MASK (hammer2_flush.c:1322) is satisfied by HAMMER2_CHAIN_ONFLUSH: any dirty child under the PFS root sets ONFLUSH on the iroot chain (hammer2_chain_setflush() stops at the inode inflection, hammer2_chain.c:147-166).
  • hammer2_flush()/hammer2_flush_core() handle the error'd chain correctly β€” hammer2_flush.c:671-679 prints hammer2: chain error during flush, accumulates info->error and skips. flush_recurse handles it too (:1203-1208, :1266-1270 "PARENT ERROR DURING FLUSH LOCK").
  • hammer2_xop_inode_flush() then ignores both chain->error and the return value of hammer2_flush() (the call at :1331 discards it) and executes pmp->pfs_iroot_blocksets[clindex] = chain->data->ipdata.u.blockset; at :1336-1337 β†’ NULL-page fault (offset 0x200 = offsetof(ipdata.u.blockset)) β†’ kernel panic.

Environmental trigger: failing/pulled/yanked media, or a corrupted (short/CRC-bad) inode block that was cache-evicted between the dirtying operation and the sync. Result: a recoverable I/O condition becomes an unconditional kernel crash during sync.

2. Reproduction (kernel A = stock + inject.diff, this run)

A real EIO cannot be manufactured safely on the guest (vnconfig -u is refused while the mount holds the device open β€” VNIOCDETACH checks disk_getopencount() > 1, sys/dev/disk/vn/vn.c:464; truncating the vn backing file yields short reads without B_ERROR β€” vnstrategy uses VOP_READ uio, sys/dev/disk/vn/vn.c:342-371), so the environment is simulated with inject.diff: a sysctl-gated hook in _hammer2_io_getblk() that forces error = EIO for inode-block reads on the 512MB test volume only β€” byte-exact real-media semantics (the error is injected exactly where breadnx would report it, after the DIO is fully set up; dio->error propagates through hammer2_io_bread identically to a failed device read).

Fresh hammer2 image (vn2 over /root file), mount, mkdir+touch (ONFLUSH on iroot chain), settle 5 s, sysctl vfs.hammer2.df2644_fail_inode_read armed, sync:

hammer2_chain_load_data: I/O error 000000000240080a: 5
hammer2: chain error during flush            <- the DESIGNED handler works
...
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x200              <- NULL chain->data + 0x200
Stopped at      hammer2_xop_inode_flush+0x39c:  repe movsq (%rsi),%es:(%rdi)

(panic.txt; the repe movsq IS the blockset structure copy at hammer2_flush.c:1336-1337. Full serial in panic.txt.)

3. Fix (fix.diff, validated)

Guard the dereference and propagate the flush error (previously the return value of hammer2_flush() was discarded entirely at :1331):

    flush_error |= hammer2_flush(chain, xflags);
    ...
    if (ip == pmp->iroot && pmp != hmp->spmp &&
        chain->error == 0 && chain->data != NULL) {
        ... blockset copy ...
    }

flush_error already gates the volume-header synchronization at :1480, so a failed PFS-root flush no longer writes a new volume header either.

Kernel B (stock + inject.diff + fix.diff, only delta = fix.diff): the identical workload and error-message sequence completes the stage-2 iroot flush with no fault at hammer2_xop_inode_flush (fix_run.log / fix_run_partial_3112.txt: on kernel A the sequence hammer2: chain error during flush is immediately followed by the Fatal trap 12 at hammer2_xop_inode_flush+0x39c; on kernel B the same sequence continues through further handled loads and completes).

4. Residual (out of scope, documented for the chain.c audit)

With media errors persisting past the flush, the guest still panics later at an unrelated pre-existing unconditional assert:

panic: assertion "parent->error == 0" failed in hammer2_chain_create
       at /usr/src/sys/vfs/hammer2/hammer2_chain.c:3112
    via hammer2_xop_inode_create_ins   (fsync-retry index re-insert)
     or via hammer2_assign_physical ← hammer2_xop_strategy_write

(kernel A hit the same 3112 assert via the write path before the injector was volume-gated β€” see buildA-first-run transcripts.) This is a distinct defect class ("frontend create/write paths KKASSERT on error'd parents instead of returning errors") and belongs to hammer2_chain.c; it should be filed/triaged there. It is not addressed by this finding's fix.diff, by design (no blind cross-file error-handling rewrite).

5. Impact ceiling

Kernel NULL-deref panic (DoS) from an environmental media-failure condition during routine sync β€” no data-only unprivileged trigger exists (the media must actually fail), hence severity Medium. The bug class is "kernel crashes instead of returning EIO", same family as the gracefully-handled paths surrounding it.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

kernel B (stock + DF-2644 fix.diff only) passes the identical EIO-injected sequence with no fault at the site; the designed error handlers operate.

fix_build.log / fix_run.log / VERDICT.md
↓ fix.diffDragonFly 6.5-DEVELOPMENT kernel B (only delta = fix.diff; in-guest nativekernel)

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-2644/panic.txt (kernel A serial: chain-error handler messages then Fatal trap 12 VA 0x200 at hammer2_xop_inode_flush+0x39c)', 'findings/poc/DF-2644/fix_run.log (kernel B: same sequence, NO xop_inode_flush fault, residual 3112 panic)', 'findings/poc/DF-2644/fix_run_partial_3112.txt (kernel B arm=100000 variant, annotated contrast vs kernel A)', 'findings/poc/DF-2644/inject.diff (environment simulator, never applied to the audited sys/ tree)', 'findings/poc/DF-2644/fix.diff (the fix: guard + flush_error propagation)', 'findings/poc/DF-2644/run_df2644_inj.sh, validate_fix_df2644.sh (triggers)', 'findings/poc/DF-2644/build_kernelA2.sh / build_kernelB.sh (nativekernel build drivers)', 'findings/poc/DF-2644/VERDICT.md (full narrative)']

PoC changes

Original sketch (vn detach / truncate backing file) was unrunnable: VNIOCDETACH refuses while the mount holds the device (vn.c:464), truncation yields short reads without B_ERROR (vn.c:342-371). Replaced with a sysctl-gated EIO injector inside _hammer2_io_getblk (after DIO setup, exactly where breadnx reports errors); first injector revision returning *diop=NULL was itself an artifact (violated the always-valid-dio contract, crashed in _hammer2_io_putblk) and was corrected to error-injection; volume-gated by volu_size after the guest ROOT hammer2 was also being failed (its boot-time writes panicked via chain.c:3112). Trigger v2 uses metadata-only dirt + bounded arm count.

Verified recommended fix

Guard the blockset copy with 'chain->error == 0 && chain->data != NULL' and propagate hammer2_flush()'s return into flush_error so a failed iroot flush also skips volume-header sync (fix.diff)

Verdict

REPRODUCED on fault-injection kernel A (stock + sysctl-gated EIO injector with byte-exact real-media semantics, volume-gated to the 512MB test fs): sync on a PFS whose iroot chain cannot be re-read panics with Fatal trap 12, fault VA 0x200, 'Stopped at hammer2_xop_inode_flush+0x39c: repe movsq' - exactly the unguarded 'pmp->pfs_iroot_blocksets[clindex] = chain->data->ipdata.u.blockset' at hammer2_flush.c:1336-1337 with chain->data == NULL after hammer2_chain_load_data EIO (chain.c:1003-1008). The designed handlers one frame away ('hammer2: chain error during flush', 'PARENT ERROR DURING FLUSH LOCK') demonstrably work; this single deref ignores both chain->error and the discarded hammer2_flush() return. Fix validated on kernel B (only delta = fix.diff): identical workload+message sequence passes the site with no fault; residual later panic is the unrelated pre-existing chain.c:3112 KKASSERT(parent->error==0) via create/strategy retry paths (documented, belongs to chain.c audit).