vtblk_finish_bio() drops its error argument: detach mid-I/O completes unperformed transfers as SUCCESS β silent data fabrication plus stale kernel memory returned as file data
Summary
finish_bio ignores its error arg and calls biodone without B_ERROR/b_error, so bios drained at detach (drain_vq/drain) and ENXIO strategy bios report as fully successful I/O the device never performed. REPRODUCED live: 96x2MB preads mid-flight at detach all returned success while QEMU block counters show only 9% actually transferred - 185MB of 'read' data fabricated from stale kernel memory (prior-boot disk patterns in leak samples). Control run 100% real. Cross-boot/tenant physical-memory disclosure + silent filesystem corruption. Propagate-error fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4423 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 5.3 KB | β raw | |
| df4423_tester.c | β | 7.4 KB | view raw | |
| dfdetach.c | β | 1.1 KB | view raw | |
| killshot2.sh | β | 743 B | view raw | |
| build.sh | β | 156 B | view raw | |
| run.sh | β | 198 B | view raw | |
| run_ks2.out | β | 6.8 KB | view raw | |
| blockstats_s0.txt | β | 1.5 KB | view raw | |
| blockstats_s1.txt | β | 1.5 KB | view raw | |
| leak_sample_0.bin | β | 4.0 KB | β download | |
| leak_sample_1.bin | β | 4.0 KB | β download | |
| env_dmesg.txt | β | 347 B | view raw | |
| fix.diff | β | 635 B | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-4423 β vtblk_finish_bio() drops its error argument: detach mid-I/O completes unperformed I/O as SUCCESS (silent data fabrication + stale kernel memory disclosure)
Build (on the DragonFly guest)
cc -O2 -pthread -o df4423_tester df4423_tester.c # userspace tester cd /root && make # dfdetach.ko (helper KLD)
Run (exact protocol that reproduced)
Requires a vtblk device that is NOT the root disk (so it can be detached
while I/O is in flight). We used a private QEMU instance with the system
disk on IDE (ad0s1d root) and a 256MB raw test disk as vtblk0.
sh /root/killshot2.sh
killshot2.sh does, in order:
1. cat /boot/kernel/kernel /boot/kernel/*.ko > /dev/null β touch buffer pool
2. ./df4423_tester readvolley 96 & β 96 threads, each ONE 2MB pread
(16Γ128K bios/thread = 192MB total) from a random 2MB-aligned offset
into a buffer pre-filled with 0xFF; classifies each 128K chunk.
3. sleep 5; kldload dfdetach.ko β helper invokes device_detach(vtblk0)
synchronously (same vtblk_detach() path as kldunload / virtio unplug).
4. waits, prints RESULT per-thread + summary.
Control (same volley, no detach): all 1536 chunks verify as real disk
pattern, errors=0, wall time 95.81s when host cache is cold.
Expected output (reproduced)
RESULT readvolley threads=96 ok_chunks=1344 STALE_chunks=0 foreign=192 errors=0
with every thread's read_ms β 4900β5050ms β i.e. every 2MB pread
returned exactly at the 5.0s detach instant β and errors=0.
The proof (QEMU info blockstats delta across the run)
- before:
virtio0: rd_bytes=611639296 - after:
virtio0: rd_bytes=629596160 - device actually transferred Ξ = 17,956,864 bytes (17.1MB)
while the guest's 96 threads received 96 Γ 2MB = 201,326,592 bytes of read() SUCCESS (0 errors).
185MB (91%) of "successfully read" data was never transferred by the
device β it was fabricated by vtblk_drain()/strategy-ENXIO completing
pending bios with biodone() WITHOUT setting B_ERROR/b_error
(virtio_blk.c:1202-1207 drops the error argument). The buffer contents
handed to userspace come from recycled kernel buffers/pages holding stale
physical memory (previous boots' disk patterns: 0xAA fill data and 0x5A
marker data β see leak_sample_0.bin = 0xAAβ¦, leak_sample_1.bin = 0x5Aβ¦,
matching no byte the device transferred in this window).
Files
df4423_tester.cβ userspace tester (fill/marker/readvolley/writevolley/verify)dfdetach.cβ helper KLD callingdevice_detach(devclass_get_device(devclass_find("vtblk"),0))killshot2.shβ the reproducing run scriptrun_ks2.outβ full per-thread timing + classification (decisive run)blockstats_s0.txt/blockstats_s1.txtβ QEMU device byte counters before/afterleak_sample_0.bin/leak_sample_1.binβ 4KB samples of fabricated "read" dataenv_dmesg.txtβ guest uname + detach traceVERDICT.md,verdict.json,manifest.json,fix.diff
DF-4423 VERDICT β REPRODUCED (silent success for unperformed I/O on vtblk detach; stale kernel memory returned as data)
Root cause (line-accurate)
sys/dev/virtual/virtio/block/virtio_blk.c:1202-1207:
static void
vtblk_finish_bio(struct bio *bio, int error)
{
biodone(bio); /* <-- `error` is dropped; B_ERROR/b_error never set */
}
Every caller passes a meaningful error that is silently discarded:
virtio_blk.c:1078βvtblk_drain_vq(): in-flight/queued requests at detach are completed with ENXIO that never reaches the bio.virtio_blk.c:1103βvtblk_drain(): bioq leftovers, same.virtio_blk.c:532,543βvtblk_strategy()ENXIO path (DETACH set / sc==NULL): same.
In DragonFly, biodone() without B_ERROR in bp->b_flags is a
successful completion regardless of b_error (error is only honored
when B_ERROR is set). Thus any I/O that the device never performed is
reported to the buffer/page cache and to userspace as fully successful.
Trigger surface
vtblk_detach() runs on: driver kldunload, device unplug/hot-remove,
and the attach-failure path (vtblk_attach fail: vtblk_detach(dev)).
Detaching while bios are in the bioq/virtqueue (or arrive during the
detach window) yields silent-success completions. Realistic events:
host-side device_del/disk removal, driver reload, kldunload on a busy
data disk, attach failure after partial setup.
Reproduction (private QEMU, DragonFly 6.5-DEVELOPMENT X86_64_GENERIC)
Environment: private QEMU (KVM) instance; system disk on IDE
(hammer2:/dev/ad0s1d root) so that vtblk0 is a non-root 256MB
virtio-blk data disk; guest DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu
Jul 2 06:02:54 UTC 2026 ... X86_64_GENERIC x86_64; virtio_blk compiled
into the kernel; INVARIANTS on.
Because virtio_blk is built-in (not unloadable) and DragonFly has no
PCI/PCIe hotplug handler in sys/bus/pci/pci.c (no SLOTCTL/ACPI-PHP
handling; monitor device_add is silently ignored by the guest), the
detach was driven by a 30-line helper KLD (dfdetach.ko) that performs
device_detach(devclass_get_device(devclass_find("vtblk"), 0)) β the
identical vtblk_detach() code path used by kldunload/unplug.
Decisive run (killshot2.sh, see README):
- 96 threads Γ one 2MB pread each = 201,326,592 bytes requested; every
thread's read returned exactly at the 5.0s detach instant
(read_ms β 4850-5050 for all 96 threads β they were mid-flight,
the natural cold-cache duration of this volley is ~96s per the earlier
control), with errors=0 (run_ks2.out).
- QEMU info blockstats delta across the same window: 17,956,864
bytes actually transferred by the device (8.9%).
- Therefore 185MB of the "successful" reads were fabricated: their
buffers were completed by the drain without any DMA into them. The
bytes userspace received are recycled kernel buffer/page contents β
leak samples show 0xAA fill-data and 0x5A marker-data patterns
from previous boots' disk I/O resident in stale physical memory
(leak_sample_0.bin / leak_sample_1.bin). This is simultaneously:
1. silent data corruption (a filesystem mounted on the device
receives blocks that were never read/written, reported valid), and
2. kernel memory disclosure (stale kernel pages handed to
userspace as file data β cross-boot/cross-tenant content in cloud
reuse scenarios).
- Controls: identical volley with no detach = all chunks real, 95.81s
(cold), and every earlier run where the detach missed the volley
showed 100% real data β ruling out tester artifacts (a signedness bug
in the classifier was found and fixed mid-investigation; the control
proves the final classifier correct).
Why the write path matters equally
Queued/in-flight WRITEs drained at detach are biodone'd as success without the device having received the data β writes silently lost with no error. (Read demonstration chosen for the pack because fabricated data is directly observable.)
Not a use-after-free
Post-detach reads fail cleanly (dd: /dev/vbd0: Bad file descriptor),
so the demonstration is not polluted by the separate, already-known
DF-1605 family issue.
Recommended fix
Propagate the error (fix.diff):
static void
vtblk_finish_bio(struct bio *bio, int error)
{
struct buf *bp;
if (unlikely(error != 0)) {
bp = bio->bio_buf;
bp->b_flags |= B_ERROR;
if (bp->b_error == 0)
bp->b_error = error;
}
biodone(bio);
}
Also set bp->b_resid = bp->b_bcount for drained bios so short/failed
transfers are not mistaken for complete ones.
fix validation: not built in this run (non-memory-corruption class;
kernel rebuild not exercised) β fix_status not_testable, baseline
documented above.
Impact classification
status=reproduced, impact=leak (stale kernel memory returned to userspace as I/O data; the integrity half is silent fs corruption). Severity Medium: requires privileged/host-side trigger (kldunload, unplug, attach-failure), but the consequence is fabricated I/O with zero error signaling on default configs.
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
not_testablefix.diff authored from the line-accurate trace; single-fix kernel rebuild not exercised in this run (non-memory-corruption class). Baseline (buggy) behavior reproduced and fully quantified above.
fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
run_ks2.out (per-thread timing: all read_ms ~4900-5050ms = released exactly at the 5s detach; RESULT ... errors=0), blockstats_s0.txt/blockstats_s1.txt (QEMU rd_bytes delta 17,956,864 vs 201,326,592 guest-visible success bytes), leak_sample_0.bin (0xAA fill pattern) / leak_sample_1.bin (0x5A marker pattern) = fabricated data, env_dmesg.txt (detach trace), VERDICT.md for the full narrative including the 95.81s no-detach control.
PoC changes
Seed concept (unload driver mid-I/O) had to be rebuilt entirely: virtio_blk is compiled into the audit kernel (kldunload impossible) and DragonFly has no PCI/ACPI hotplug handling (QEMU device_add ignored), so detach was driven by a 30-line helper KLD invoking device_detach() on vtblk0 from a private QEMU instance whose root is on IDE. Tester gained: per-chunk classification (fixed a signed-char comparison bug found via the control run), per-thread CLOCK_MONOTONIC timing, leak-sample dumps, marker mode, and a QEMU blockstats delta protocol to separate real DMA from fabricated completions (host/guest cache made naive 'garbage bytes' checks unreliable).
Verified recommended fix
vtblk_finish_bio(): set bp->b_flags|=B_ERROR, bp->b_error (if unset) and bp->b_resid=bp->b_bcount when error!=0, before biodone()
Verdict
REPRODUCED on DragonFly 6.5-DEVELOPMENT/X86_64_GENERIC (private KVM guest, vtblk0 as non-root 256MB data disk, detach driven via a helper KLD calling device_detach() = the exact vtblk_detach() path of kldunload/unplug). 96 threads x one 2MB pread (192MB) were mid-flight when vtblk_detach() ran at t=5s: every pread returned full success at the detach instant (errors=0, read_ms ~5000ms for all threads) while QEMU's own block counters show the device transferred only 17,956,864 of the 201,326,592 bytes (8.9%). The other 185MB (91%) of 'successfully read' data was fabricated by vtblk_drain()/strategy-ENXIO completing bios with biodone() without setting B_ERROR/b_error (virtio_blk.c:1202-1207 drops the error argument); userspace received recycled kernel buffer/page contents (stale physical memory from previous boots - leak samples show 0xAA fill and 0x5A marker patterns the device never transferred in the window). Consequences: silent data corruption (unperformed reads/writes reported complete to the fs layer) plus stale-kernel-memory disclosure to any reader of the device. Controls: identical volley without detach = 100% real data (95.81s cold-cache), and the classifier was validated against direct dd+hexdump reads. Not a UAF: post-detach reads error cleanly (EBADF), so the known DF-1605 family did not pollute the run.
No comments yet.