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

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.

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

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2661 Β· 19 files
FileTypeDescriptionSize
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-panic vm.sh reset with-src β€” procedure identical to kernel B's minus fix_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_spans instead (see DF-2662 pack).
  • Kernel C + ARM=1: 5/5 ROUND_SURVIVED + ALL_ROUNDS_SURVIVED, RC=0 (fix_run.log, run twice).
VERDICT.md
↓ download raw

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): forces error = EIO exactly where a real media error lands β€” after bp is instantiated, before the GOOD decision. Gated to INODE brefs on a 512MB test volume; guest root fs (different size) is untouched.
  • Media: newfs_hammer2 on vn(4) over a file; 40 inodes created in one directory (one 64KB window); golden image materialized with dd if=/dev/vn2 while 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), ls to warm the path walk without statting file inodes, arm injector, then 8 concurrent cat loops 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 β€” in hammer2_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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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
↓ 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 = stock + inject + DF-2661 fix + DF-2662 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).