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

Stale b_bio1.bio_flags survive buffer-cache recycling: vfs_bio.c performs no BIO_DONE/BIO_WANT hygiene when a buffer header is released and reacquired, so one forgetful/abandoning completion path permanently poisons the header β€” next bread() panics at vn_strategy's KKASSERT, or (without that guard) biowait() returns before device DMA completes

Summary

getnewbuf resets only b_flags and reinitbufbio clears only bio_done/bio_offset - stale BIO_DONE survives bqrelse->bufqueue->getblk and reaches vn_strategy's unconditional KKASSERT (panic), or on non-KKASSERT kernels _biowait returns before DMA completes (torn reads + bpdone on a reused header). No in-tree trigger (all completion paths clear by hand) - kernel-internal defense-in-depth with a root-KLD-rig reproduction: control passes, poison case panics with the SAME buffer address in both traces proving the flag crossed recycling. bio_flags=0-at-recycle + assignment-arming fix validated on a rebuilt kernel (both cases bread=0).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5787 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 6.6 KB ↓ raw
biopoison.c β€” 3.6 KB view raw
Makefile β€” 72 B ↓ download
build.sh β€” 141 B view raw
run.sh β€” 172 B view raw
build.log β€” 5.7 KB view raw
run.log β€” 2.4 KB view raw
panic.txt β€” 1.2 KB view raw
fix_run.log β€” 2.5 KB view raw
fix_build.log β€” 42.5 KB view raw
env.txt β€” 150 B view raw
fix.diff β€” 690 B view raw
verdict.json β€” 5.0 KB view raw
manifest.json β€” 1.2 KB view raw

DF-5787 evidence pack β€” stale b_bio[12].bio_flags survive buffer-cache recycling

Finding (short)

sys/kern/vfs_bio.c never clears a buffer header's embedded b_bio1.bio_flags when the buffer is released and later reacquired:

The kernel itself documents that stale BIO_DONE is load-bearing: vn_strategy() has KKASSERT((bio->bio_flags & BIO_DONE) == 0) (sys/kern/vfs_bio.c:3385). Every in-tree completion path must clear the flags by hand (_biowait() clears BIO_DONE|BIO_SYNC on success at :3284; the breadcb/cluster_readcb callback contract puts clearing on the callback β€” see hammer's hammer_indirect_callback, hammer_io.c:1640). Any forgetful or abandoning completion path (callback bug, biowait_timeout abandonment) permanently poisons the buffer header, and the next bread()/bwrite() on that buffer:

  1. panics at vn_strategy (:3385) on an always-KKASSERT kernel, or
  2. on the biowait side, _biowait() observes the stale BIO_DONE and returns before the device I/O completes β€” the caller reads b_data while the device is still DMAing into it (torn data), then brelse()s the buffer with the I/O in flight, so the asynchronous biodone() later runs bpdone() on a requeued (possibly reused) buffer β†’ queue corruption / double-accounting.

Rig

KLD module biopoison.c (root-only, simulates the documented-but-undefended caller-contract violation):

  • Case 0 (control): getblk β†’ clear B_CACHE β†’ bqrelse β†’ bread β€” must return 0 with no panic (proves rig mechanics).
  • Case 1 (poison): getblk β†’ clear B_CACHE and leave BIO_DONE set (exactly what breadcb()'s cached fast path does at vfs_bio.c:883, minus the callback's cleanup) β†’ bqrelse β†’ bread.

Result (baseline, unpatched kernel)

biopoison: vp=0xfffff80116870500 v_type=1            (hammer2 file vp, /etc/fstab)
biopoison: === control run (no stale flag) ===
biopoison[0]: getblk -> bp=0xfffff8004f7442d0 b_flags=21000160 bio1.flags=00000000 B_CACHE=1
biopoison[0]: released (stale BIO_DONE=0), issuing bread()
biopoison[0]: bread returned 0 (no panic)
biopoison: === poisoned run (stale BIO_DONE across bqrelse -> getblk recycling) ===
biopoison[1]: getblk -> bp=0xfffff8004f7442d0 b_flags=21001164 bio1.flags=00000000 B_CACHE=1
biopoison[1]: released (stale BIO_DONE=1), issuing bread()
panic: assertion "(bio->bio_flags & BIO_DONE) == 0" failed in vn_strategy at /usr/src/sys/kern/vfs_bio.c:3385
Trace: vn_strategy <- breadnx <- bpio_case <- biopoison_handler <- module_register_init

Same buffer header (0xfffff8004f7442d0) in both cases β€” the flag crossed the bqrelse β†’ queue β†’ getblk boundary. Verdict: reproduced (panic; guest entered ddb, ssh session died).

Build

# on the guest (kernel obj tree must exist):
mkdir -p /root/biopoison && cd /root/biopoison    # biopoison.c + Makefile here
make                                             # SYSDIR=/usr/src/sys in Makefile
# -> biopoison.ko

Run

kldload /root/biopoison/biopoison.ko     # console/serial shows the output above

Fix validation

fix.diff: assignment-style flag arming in breadnx()/bwrite() (bp->b_bio1.bio_flags = BIO_SYNC; instead of |=), plus reinitbufbio() clearing bio_flags for recycled headers. After building the patched kernel in the guest (make quickkernel && make installkernel, reboot), the identical module load runs both cases with no panic β€” see VERDICT.md.

VERDICT.md
↓ download raw

DF-5787 β€” VERDICT

Status: reproduced (kernel panic via KLD rig; fix validated fixed)

What was audited and claimed

Pass-3 audit of sys/kern/vfs_bio.c (4659 LOC) across the five mandated hunt areas (buffer lifecycle/recycling races, biodone/brelse + B_DONE discipline, KVA qenter/qremove pairing, bufdaemon flush logic, unmount/fsync races). Beyond the excluded known finding DF-2675 (allocbuf 17-page KVA overflow), one new defect was found and verified:

Buffer-cache recycling performs zero hygiene on the embedded BIO completion flags. A buffer header released with b_bio1.bio_flags bits set (BIO_DONE/BIO_WANT/BIO_SYNC) carries them into its next use:

The kernel itself treats stale BIO_DONE as an invalid state: KKASSERT((bio->bio_flags & BIO_DONE) == 0) in vn_strategy() β€” sys/kern/vfs_bio.c:3385 (KKASSERT is unconditional in DragonFly). The only reason this does not fire in-tree is that every completion path carefully clears the flags by hand (_biowait() at :3284 on success; the breadcb()/cluster_readcb() contract puts clearing on the callback β€” see hammer_indirect_callback(), sys/vfs/hammer/hammer_io.c:1640 "bio->bio_flags &= ~(BIO_DONE | BIO_SYNC)"). The breadcb() cached-path itself sets BIO_DONE and releases (sys/kern/vfs_bio.c:883) β€” one forgetful callback, or a biowait_timeout() abandonment, permanently poisons the buffer header.

Reproduction (baseline, kernel #0 Thu Jul 2, unpatched)

KLD biopoison.c (root-only rig; emulates the documented-but-undefended contract violation β€” the same state breadcb()'s fast path leaves behind, minus the callback's cleanup):

biopoison[0]: getblk -> bp=0xfffff8004f7442d0 ... B_CACHE=1
biopoison[0]: released (stale BIO_DONE=0), issuing bread()
biopoison[0]: bread returned 0 (no panic)              <- control passes
biopoison[1]: getblk -> bp=0xfffff8004f7442d0 ...      <- SAME header, found path
biopoison[1]: released (stale BIO_DONE=1), issuing bread()
panic: assertion "(bio->bio_flags & BIO_DONE) == 0" failed in vn_strategy at /usr/src/sys/kern/vfs_bio.c:3385
Trace: vn_strategy <- breadnx <- bpio_case <- biopoison_handler <- module_register_init

Full serial output: run.log / panic.txt. Guest entered ddb; ssh died; vm.sh status β†’ down-equivalent (DDB). Same buffer address in both cases proves the flag crossed bqrelse() β†’ clean queue β†’ getblk() reacquisition.

On a kernel without the (always-on) KKASSERT the next failure point is worse: _biowait() (sys/kern/vfs_bio.c:3261-3263) observes the stale BIO_DONE and returns before the device I/O completes β€” the caller consumes b_data while the device is still DMAing into the pages (torn data), then brelse()s the buffer with I/O in flight, so the asynchronous biodone() β†’ bpdone() later runs on a requeued (possibly reused) buffer: queue corruption, double-accounting, or the _biowait KKASSERT at :3283 (KKASSERT(bp->b_cmd == BUF_CMD_DONE)).

Threat model

This is a kernel-internal robustness / defense-in-depth defect, not an unprivileged attack primitive: the poison requires a kernel-side completion path that releases a bio with BIO_DONE set (buggy out-of-tree or future VFS/driver callback, or biowait_timeout() abandonment as in sys/dev/disk/fd/fd.c:2279). No in-tree trigger exists today; every current caller honors the contract. Severity Low.

Fix validation (fix.diff, kernel #1 Mon Oct 12 16:00:55)

fix.diff (3 hunks, applies cleanly to sys/): 1. reinitbufbio(): clear bio->bio_flags (covers recycled headers via getnewbuf() :2104 and kern_physio's re-arm, sys/kern/kern_physio.c:68). 2. breadnx() :921: bp->b_bio1.bio_flags = BIO_SYNC; (assignment, not |=). 3. bwrite() :991: same assignment arming.

Procedure: vm.sh reset with-src β†’ patch /usr/src β†’ make -j4 quickkernel (vfs_bio.o rebuilt, 55 s) β†’ make installkernel β†’ reboot β†’ rebuild the identical module β†’ kldload:

biopoison[0]: bread returned 0 (no panic)              <- control
biopoison[1]: released (stale BIO_DONE=1), issuing bread()
biopoison[1]: bread returned 0 (no panic)              <- panic GONE
biopoison: bread SURVIVED - stale flag was cleared somewhere during recycling

Guest stayed up (kldstat shows biopoison.ko loaded, uptime continues). Baseline panic vs patched no-panic: fixed. Guest reset to with-src afterwards; no /usr/src modifications remain in the shipped tree (sys/ is untouched β€” fix.diff was only applied inside the disposable guest).

Negative results (classes hunted and killed in pass 3)

  • getnewbuf/findblk recycling race: b_refs is checked unsynchronized then re-checked under lock+spin (sys/kern/vfs_bio.c:2002,2031,2131); brelvp() under v_token closes the findblk 0β†’1 window. No hole found.
  • brelse's manual B_DELWRI unwind (:1311-1320): unreachable β€” buf_deallocate callbacks (hammer_io_deallocate) never set B_DELWRI.
  • KVA qenter/qremove pairing: every qenter/qremove is symmetric at b_kvabase with bkvareset()/CPUMASK_ASSZERO on each mapping change (:1444-1451, :1854-1856, :3077-3094, :3212-3216, :3694-3698, :3834-3838, :3991-3994). The only asymmetry is the known DF-2675 (17-page mapping).
  • brelse page-invalidation loop: resid can go negative only after the final iteration; presid never sees it (KASSERT safe) β€” killed.
  • bufdaemon flush queue protocol: all queue mutations under pcpu spin with buffer locked; marker discipline correct; B_DEFERRED livelock bounded.
  • bio_track_rel underflow: guarded by unconditional bit-30 panic (:548-549).
  • getnewbuf()'s size/maxsize parameters are dead (nothing enforces the misaligned-loffset headroom getblk computes at :2939) β€” cosmetic, since every header owns a full MAXBSIZE slot; only DF-2675 defeats that.
  • inmem() would spin forever if f_iosize == 0 (:2503-2522, tinc == 0) β€” no in-tree mount produces iosize 0; noted, not filed.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied inside guest /usr/src, quickkernel rebuilt (vfs_bio.o relinked, kernel #1 Mon Oct 12 16:00:55), installkernel + reboot, identical module re-run: control case passed and poison case returned bread=0 with NO panic and the guest alive - the baseline panic at vfs_bio.c:3385 is gone.

fix_run.log ('biopoison[1]: bread returned 0 (no panic)' + 'bread SURVIVED', guest uptime continues); fix_build.log (quickkernel/installkernel output, vfs_bio.c recompiled); fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 16:00:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

No unprivileged chain: the trigger is a kernel-internal contract violation (buggy/abandoning bio completion path), not user input. Chain that was demonstrated: KLD emulates breadcb()'s cached-path leftover state (BIO_DONE set at release, vfs_bio.c:883) -> buffer recycled through bqrelse/getblk with flag intact -> bread() -> vn_strategy KKASSERT panic at vfs_bio.c:3385 (baseline) -> with fix.diff, bread() completes normally and _biowait clears the flags.

Evidence (decisive lines)

panic.txt (panic text + backtrace vn_strategy<-breadnx<-bpio_case), run.log (full serial: control case passes, poison case panics, same bp address 0xfffff8004f7442d0 in both), fix_run.log (patched kernel: 'biopoison[1]: bread returned 0 (no panic)', guest alive), build.log (full module build), fix.diff (3 hunks), VERDICT.md (narrative + killed-candidates list)

PoC changes

Authored from scratch (no seed): KLD module biopoison.c resolves a hammer2 file vp via nlookup/cache_vref, then runs a control case (getblk, clear B_CACHE, bqrelse, bread) and a poison case (same + stale b_bio1.bio_flags |= BIO_DONE before bqrelse) on load. Makefile needs SYSDIR=/usr/src/sys for out-of-tree kmod.mk builds on the guest.

Verified recommended fix

Clear embedded bio flags at buffer-cache recycling and arm sync completion with assignment instead of OR: reinitbufbio() sets bio->bio_flags = 0; breadnx()/bwrite() set bp->b_bio1.bio_flags = BIO_SYNC (see fix.diff).

Verdict

Reproduced: a buffer header released with a stale b_bio1.bio_flags BIO_DONE carries that flag across bqrelse -> bufqueue -> getblk(found-path) reacquisition, and the next bread() on the buffer panics at the unconditional KKASSERT((bio->bio_flags & BIO_DONE) == 0) in vn_strategy (sys/kern/vfs_bio.c:3385); on a kernel without that guard, _biowait (vfs_bio.c:3261) instead returns before the device I/O completes, so the caller consumes b_data during DMA and brelse()s a buffer whose biodone/bpdone then runs on a requeued or reused buffer (queue corruption / double accounting). Root cause: getnewbuf() resets only b_flags (vfs_bio.c:2095), reinitbufbio() clears bio_done/bio_offset only (vfs_bio.c:747-750), getblk()'s found path never touches bio_flags, and breadnx()/bwrite() arm completion flags with |= (vfs_bio.c:919-920, 989-990). Reachability requires a kernel-side completion path that releases a bio with BIO_DONE set (forgetful breadcb/cluster_readcb callback - the contract hammer_indirect_callback honors by hand - or biowait_timeout abandonment, e.g. sys/dev/disk/fd/fd.c:2279); no in-tree trigger found, so this is a Low-severity kernel-robustness/defense-in-depth defect, verified with a root-loaded KLD rig; fix.diff (clear bio_flags in reinitbufbio + assignment arming in breadnx/bwrite) was built into the guest kernel and the identical rig then ran both cases with no panic.