hammer2 DIO error path leaves stale dio->bp with DIO_GOOD cleared; concurrent accessor trips KKASSERT(dio->bp == NULL) and panics
| Field | Value |
|---|---|
| ID | DF-2661 |
| 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-754 (secondary CWE-362 race) |
| File | sys/vfs/hammer2/hammer2_io.c |
| Lines | 350-373 (error completion), 254 (assert), 458-512 (lastdrop) |
| 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
When a device read fails, _hammer2_io_getblk() clears DIO_INPROG
without setting DIO_GOOD (io.c:361-373) but leaves dio->bp pointing at
the errored buffer; disposal only happens at the refs 1β0 lastdrop
(:458-512). Any second thread that took a DIO ref while the failing
reader held INPROG (e.g. a concurrent load of a different inode in the
same 64KB window β ~128 512-byte inodes share one DIO) wakes, acquires
INPROG, and hits KKASSERT(dio->bp == NULL) at io.c:254 β kernel
panic. On non-INVARIANTS kernels the DOP_NEW path overwrites dio->bp
via getblk (:267) without brelse, orphaning the buffer lock.
Threat model & preconditions
Unprivileged local users cannot fabricate the EIO, but device read errors are routine (dying disks, host/cable resets on external storage, virtual-disk backend hiccups). Any multithreaded metadata workload touching the affected 64KB window (parallel ls/stat/open on one directory) then panics INVARIANTS kernels (X86_64_GENERIC default). Local DoS / availability.
Proof of concept
Reproduced on the guest (findings/poc/DF-2661/): 512MB vn-backed
hammer2 volume, 40 inodes in one directory (one DIO window);
sysctl-gated EIO injector (DF-2644 technique, gated to the test
volume); 8 concurrent cat loops over 8 distinct cold inodes after
fresh mount. Kernel A (stock+injector) panicked within β€3 storm
rounds: panic: assertion "dio->bp == NULL" failed in _hammer2_io_getblk
at hammer2_io.c:264 (stock :254 + injector offset), serial console
shows CPU1 printing the chain_load_data I/O error while CPU2 prints the
panic β the two-actor race in the act. Fix (dispose the errored buffer
before releasing INPROG) validated on rebuilt kernel. No userβroot
route: no attacker-controlled memory content; ceiling is the
assert-panic or an orphaned buffer lock.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_io.c
+++ b/sys/vfs/hammer2/hammer2_io.c
@@ -356,6 +356,19 @@
dio->error = error;
/*
+ * DF-2661: If the I/O failed, dispose of the buffer now. Leaving
+ * dio->bp set with DIO_GOOD clear violates the DIO state machine:
+ * any subsequent accessor that acquires DIO_INPROG expects
+ * dio->bp == NULL (and asserts it), and on non-INVARIANTS kernels
+ * the DOP_NEW path would overwrite the pointer without releasing
+ * the buffer, orphaning a buffer-lock.
+ */
+ if (error && dio->bp) {
+ brelse(dio->bp);
+ dio->bp = NULL;
+ }
+
+ /*
* Clear INPROG and WAITING, set GOOD wake up anyone waiting.
*/
References
- DF-2662 (unmasked by this fix), DF-2644 (injector technique), DF-2617 family (chain->data NULL on error)
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_io.c (GLM 5.3); reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2661 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.9 KB | β raw | |
| VERDICT.md | β | 5.2 KB | β raw | |
| env.txt | β | 772 B | view raw | |
| inject.diff | β | 998 B | view raw | |
| fix.diff | β | 664 B | view raw | |
| fix_b.diff | β | 1002 B | view raw | |
| trigger_df2661.sh | β | 2.9 KB | view raw | |
| build.sh | β | 300 B | view raw | |
| build_fix.sh | β | 861 B | view raw | |
| run.sh | β | 554 B | view raw | |
| build.log | β | 5.7 MB | β download | |
| buildB.log | β | 5.7 MB | β download | |
| fix_build.log | β | 5.7 MB | β download | |
| fix_run.log | β | 8.9 MB | β download | |
| run.log | β | 1.6 KB | view raw | |
| run_baseline.txt | β | 1.6 KB | view raw | |
| panic.txt | β | 1.8 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 5.4 KB | view raw |
DF-2661 β hammer2 DIO error path leaves stale dio->bp with DIO_GOOD cleared β state-machine violation, panic on concurrent accessor
- File:
sys/vfs/hammer2/hammer2_io.c(pass-2 audit of this file) - Root cause:
_hammer2_io_getblk()error path (io.c:350-373 vs io.c:254) - Severity: Medium (kernel panic / local DoS on device read error)
- Bucket: hammer2
- Related: DF-2662 (second EIO-path panic unmasked by this fix β different
file,
hammer2_iocom.c)
Kernels used (all built in-guest with make -j6 nativekernel)
- A (baseline) = stock +
inject.diff(build.sh; log lost to the mandatory post-panicvm.sh reset with-srcβ procedure identical to kernel B's minusfix_b.diff) - B (fix 1) = stock +
inject.diff+fix_b.diff(build.log / buildB.log) β DF-2661's assert is GONE; the same storm then exposes the unrelated DF-2662 NULL deref (hammer2_update_spans) - C (fix 1+2) = kernel B source + DF-2662's
fix.diff(fix_build.log / DF-2662/buildC.log)
Media prep (once per fresh guest)
sh /root/df2661/trigger_df2661.sh golden
512MB vn(4)-backed hammer2 volume, 40 inodes in one directory (one 64KB
DIO window), golden image materialized via dd if=/dev/vn2 while
attached. NEVER use vnconfig -T on prepared images (it is O_TRUNC).
Run (per kernel)
sh /root/df2661/trigger_df2661.sh 5 # ARM=1 (default): EIO storm ARM=0 sh /root/df2661/trigger_df2661.sh 5 # control: no EIO
Expected / observed
- Kernel A: panic
assertion "dio->bp == NULL" failed in _hammer2_io_getblk at hammer2_io.c:264(= stock :254 + 10 injector lines) β guest wedged in ddb (panic.txt, run_baseline.txt). - Kernel B + ARM=0 control: 5/5 ROUND_SURVIVED (crash is EIO-specific).
- Kernel B + ARM=1: no dio assert anymore; crashes in DF-2662's
hammer2_update_spansinstead (see DF-2662 pack). - Kernel C + ARM=1: 5/5 ROUND_SURVIVED + ALL_ROUNDS_SURVIVED, RC=0 (fix_run.log, run twice).
DF-2661 VERDICT β hammer2 DIO error path leaves stale dio->bp, panics concurrent accessor
Status: REPRODUCED (panic, kernel A) / FIXED (kernel C)
The fix
fix.diff (git-apply-able against pristine sys/), after
dio->error = error; (io.c:356):
if (error && dio->bp) {
brelse(dio->bp);
dio->bp = NULL;
}
This restores the invariant "GOOD clear && INPROG clear => bp == NULL"
that io.c:254 asserts and that _hammer2_io_putblk()'s errored disposal
path (else if (bp) brelse(bp)) now simply never sees.
The bug
_hammer2_io_getblk() (sys/vfs/hammer2/hammer2_io.c) implements the DIO
state machine with flag bits in dio->refs: DIO_INPROG (one thread owns
buffer instantiation), DIO_GOOD (dio->bp is stable and usable).
On a FAILED read the completion loop (io.c:361-373) clears INPROG,
sets GOOD only if error == 0, and leaves dio->bp pointing at the
errored buffer. The buffer is only disposed on the refs 1->0 "lastdrop"
transition in _hammer2_io_putblk() (io.c:458-512).
That breaks the state machine's own invariant, asserted at io.c:254:
KKASSERT(dio->bp == NULL); /* we just acquired INPROG, GOOD clear */
Any second thread that took a DIO ref while the first thread was inside the
INPROG section (e.g. a concurrent load of a different inode in the same
64KB window β hammer2 packs ~128 512-byte inodes per DIO) wakes from its
tsleep, acquires INPROG, and trips the assert -> panic.
Stack (serial console, kernel A):
_hammer2_io_bread() hammer2_chain_load_data() hammer2_chain_lock() hammer2_inode_chain()
The garbled console line in panic.txt is two CPUs printing simultaneously:
CPU1 emitting hammer2_chain_load_data: I/O error (chain.c:1001) while CPU2
emits the panic β the two-actor race caught in the act.
Reproduction (kernel A = stock + sysctl-gated EIO injector)
- Injector (
inject.diff, technique from DF-2644): forceserror = EIOexactly where a real media error lands β afterbpis instantiated, before the GOOD decision. Gated to INODE brefs on a 512MB test volume; guest root fs (different size) is untouched. - Media:
newfs_hammer2on vn(4) over a file; 40 inodes created in one directory (one 64KB window); golden image materialized withdd if=/dev/vn2while still attached (serves dirty buffer content). - Per round: fresh clone of golden (NEVER
vnconfig -Tβ it is O_TRUNC and zeroes the backing file), attach WITHOUT -T, mount (cold chains + cold device buffers -> real strategy I/O inside INPROG),lsto warm the path walk without statting file inodes, arm injector, then 8 concurrentcatloops on 8 distinct cold inodes in the same window. - Result: panic
assertion "dio->bp == NULL" failed in _hammer2_io_getblk at /usr/src/sys/vfs/hammer2/hammer2_io.c:264(= stock line 254; +10 injector lines), guest wedged in ddb. See panic.txt / panic_full.txt.
Non-INVARIANTS behavior (not separately built, code-traced)
With the assert compiled out, the second accessor re-runs breadnx(),
which reuses the passed *bpp (vfs_bio.c:905-907) β the stale bp is
re-strategied and the read self-heals; but a DOP_NEW accessor takes the
dio->bp = getblk(...) assignment (io.c:267), dropping the held bp pointer
without brelse, orphaning the buffer lock (subsequent getblk on that
(dev,loffset) blocks). Same root cause, same fix.
Fix validation
Three kernels were built in-guest (see README.md):
- A (stock + injector): baseline panic at the dio->bp assert (panic.txt) β DF-2661 reproduced.
- B (stock + injector +
fix.diff): the assert is GONE. With the storm armed the box now dies later and elsewhere β inhammer2_update_spans(hammer2_iocom.c), a second, independent, pre-existing EIO-path NULL deref that DF-2661's earlier panic had been masking. Filed separately as DF-2662 (its own pack has the crash, disassembly and fix). A control run with the injector disarmed (ARM=0) survived 5/5 rounds on kernel B, proving the fix did not introduce a generic mount/umount failure. - C (kernel B + DF-2662's fix): the identical 5-round EIO storm survives completely β ROUND_SURVIVED x5, ALL_ROUNDS_SURVIVED, RC=0 (fix_run.log; run twice for good measure). EIOs surface as ordinary open() failures.
Threat model
Unprivileged users cannot cause a device read error, but device read errors are routine environmental events (dying disks, cable/host resets on external storage, dm-crypt/VPN-backed block devices hiccuping, virtual disk backends under memory pressure). When one occurs, any multi-threaded metadata workload touching the affected 64KB window (parallel ls/stat/open on one directory is enough) panics the system on any kernel built with INVARIANTS (X86_64_GENERIC default). Impact: local DoS / availability. No memory corruption, no privilege boundary crossed: severity Medium.
Kernel references
- sys/vfs/hammer2/hammer2_io.c:254 (assert), :350-356 (error kept, bp left), :361-373 (GOOD gated on error==0), :458-512 (bp only disposed at lastdrop)
- sys/vfs/hammer2/hammer2_chain.c:985-1006 (concurrent loaders of chains sharing one DIO; error path bqrelse while refs>1 leaves bp set)
- sys/kern/vfs_bio.c:905-907 (breadnx reuses non-NULL *bpp)
Fix verification
fixedBaseline kernel A panicked at KKASSERT(dio->bp == NULL) (hammer2_io.c:254/264). With fix applied (kernel B) the assert never fires again; the EIO storm then reaches an unrelated, pre-existing NULL deref in hammer2_update_spans (DF-2662). With both fixes (kernel C) the identical 5-round storm survives completely, twice: ROUND_SURVIVED x5, ALL_ROUNDS_SURVIVED, RC=0, EIOs surface as ordinary open() failures. fix_baseline_reproduced=1 (panic before fix), fix_patched_reproduced=0 (no panic after fix).
fix_run.log (kernel C full storm survival, 9MB untrimmed); panic.txt (kernel A baseline); build.log/fix_build.log (kernel B/C builds); DF-2662 pack for the second bug's crash+fix
Confirmed kernel references
Detail
Exploit chain
N/A for privilege escalation: primitive is a state-machine violation leading to an assertion panic (INVARIANTS) or an orphaned buffer lock in the DOP_NEW path (non-INVARIANTS, traced, not built). No attacker-controlled memory content is involved; impact ceiling is local DoS when a device read error (dying disk, cable reset, degraded backend) coincides with concurrent metadata loads in the same 64KB window.
Evidence (decisive lines)
panic.txt (serial console: I/O-error kprintf interleaved with the 'dio->bp == NULL' panic, full trace, db> prompt); run_baseline.txt / run.log (run transcript); fix_run.log (kernel C: ROUND_SURVIVED x5 + ALL_ROUNDS_SURVIVED, RC=0, full untrimmed 9MB trace); build.log / fix_build.log (kernel B / kernel C builds); inject.diff (EIO injector gated to the 512MB test volume); VERDICT.md for the full narrative
PoC changes
Seed sketch had to be rebuilt almost entirely: (1) vnconfig -T is O_TRUNC and silently zeroed every prepared image - images must be cloned with cp and attached WITHOUT -T; (2) hammer2 umount invalidates the raw-device buffer cache and the backing file only receives bdwrite'd data late, so umount/remount cycles on one attachment lose the FS - a golden image is materialized with dd if=/dev/vn2 while still attached and re-cloned per round; (3) cold chains require a fresh mount per round (chains persist while mounted), so each round = fresh clone + attach + mount; (4) plain ls warms the path walk without statting file inodes (keeping them cold); (5) trigger loops use recorded PIDs (sh job-control %N does not work non-interactively).
Verified recommended fix
In _hammer2_io_getblk(), after dio->error = error: if (error && dio->bp) { brelse(dio->bp); dio->bp = NULL; } - restoring the invariant that a DIO with INPROG clear and GOOD clear has bp == NULL.
Verdict
hammer2_io.c leaves dio->bp set when a device read fails (DIO_GOOD is gated on error==0 at io.c:366 but the errored buffer is only disposed at the refs 1->0 lastdrop in _hammer2_io_putblk). Any thread that took a DIO ref while the failing read held DIO_INPROG wakes, acquires INPROG, and trips KKASSERT(dio->bp == NULL) at io.c:254 -> kernel panic. Reproduced on the INVARIANTS guest with a sysctl-gated EIO injector (technique from DF-2644) plus 8 concurrent cat loops over cold inodes sharing one 64KB DIO window: panic 'dio->bp == NULL' in _hammer2_io_getblk (hammer2_io.c:264 = stock 254 + injector offset), stack hammer2_io_bread<-chain_load_data<-chain_lock<-inode_chain, guest wedged in ddb. Serial console even shows CPU1 printing the chain_load_data I/O error while CPU2 prints the panic - the two-actor race in the act. Fix (dispose bp on error before clearing INPROG) applied and validated: with the fix the assert never fires; after also fixing the unrelated DF-2662 NULL deref the identical 5-round EIO storm survives completely (ROUND_SURVIVED x5, run twice).
No comments yet.