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/.
Recommended fix
--- 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)
PoC verification
Evidence pack
findings/poc/DF-2644 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 -uis refused while the mount holds the device open (VNIOCDETACH checksdisk_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 whenhammer2_chain_load_data()hits an I/O error: hammer2_chain.c:1003-1008 setschain->error = HAMMER2_ERROR_EIO, releases the dio and returns withchain->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 dereferenceschain->data->ipdata.u.blocksetat :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.
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) setschain->error = HAMMER2_ERROR_EIO, releases the dio and returns withchain->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 printshammer2: chain error during flush, accumulatesinfo->errorand skips. flush_recurse handles it too (:1203-1208, :1266-1270 "PARENT ERROR DURING FLUSH LOCK").hammer2_xop_inode_flush()then ignores bothchain->errorand the return value ofhammer2_flush()(the call at :1331 discards it) and executespmp->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
fixedkernel 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
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).
No comments yet.