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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4423 Β· 16 files
FileTypeDescriptionSize
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 calling device_detach(devclass_get_device(devclass_find("vtblk"),0))
  • killshot2.sh β€” the reproducing run script
  • run_ks2.out β€” full per-thread timing + classification (decisive run)
  • blockstats_s0.txt / blockstats_s1.txt β€” QEMU device byte counters before/after
  • leak_sample_0.bin / leak_sample_1.bin β€” 4KB samples of fabricated "read" data
  • env_dmesg.txt β€” guest uname + detach trace
  • VERDICT.md, verdict.json, manifest.json, fix.diff
VERDICT.md
↓ download raw

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.

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_testable
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-4423

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.