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

physio consumes stale/uninitialized b_resid from the shared pbuf_mem pool and copyouts stale bounce-buffer contents before the B_ERROR check β€” failed raw reads disclose other contexts' kernel-resident I/O data

Field Value
ID DF-2979
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:L
CWE CWE-908 / CWE-459
File sys/kern/kern_physio.c
Lines 98-128 (gap: initpbuf vm_pager.c:383-395)
Area kern/vm + disk
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket kernleak
Reported pending
Known CVE none
CVE match novel

Summary

physio() derives the transfer length as iolen = bp->b_bcount βˆ’ bp->b_resid (:112) but neither physio, initpbuf() (resets b_data/ b_flags/b_cmd/b_error/b_bcount/b_bufsize β€” not b_resid), nor reinitbufbio() ever establishes b_resid for the transfer, and bpdone()/biodone_sync() never write it. pbuf_mem pbufs are recycled kernel-wide (physio, CAM pass-through bounce, disklabel/mbr/gpt scanners, subr_diskiocom) LIFO per bucket. Any device strategy completion that does not itself set b_resid β€” in-tree: virtio_blk error completions (the default block driver on QEMU/KVM), xdisk timeout/lost-link completions (remotely triggerable by the xdisk server), and the dscheck truncation path β€” makes physio consume the pbuf's previous tenant's value. Because the copyout at :113-121 executes before the B_ERROR check at :128, a failing read still transfers up to MAXPHYS (128KB) of the recycled bounce buffer to userspace. Local unprivileged user with read permission on any physio-backed device: one failing I/O of the virtio_blk/xdisk class discloses up to 128KB of whatever the recycled pbuf last carried β€” other users' raw disk reads, CAM pass-through payloads, disklabel-scan sectors. Demonstrated on the stock INVARIANTS guest as uid 65534: deterministic 510/512-byte stale disclosure on a failed read, and 4096 bytes of root-only raw hammer2 root-disk data (HAM2 volume-header magic confirmed) disclosed across contexts. The stale>bcount variant underflows iolen to ~2^64 (DF-0249's sink) but x86-64 std_copyout's wrap check bounds it to a spurious EFAULT. Leak-only: no write primitive, no uid=0 chain for this class.

Proof of contest

VERIFIED (findings/poc/DF-2979/): dfp.ko char device wired to the STOCK physread/physwrite with a strategy replaying in-tree completion classes. T1 (deterministic): read 64KB then failing read 512 β†’ EIO with buffer holding 510/512 bytes of the previous transfer's kernel bounce data. T3 (cross-context): six root feeders raw-reading the root-only disk while nobody spins failing 128KB reads β†’ 4096 bytes of root-only disk data matched, HAM2 volume-header magic in the captured sample. T2: EOF-poison b_resid β†’ iolen=(size_t)(512βˆ’65536) β†’ ~2^64 copyout rejected by support.s wrap check β†’ EFAULT on a fully-resident buffer (anomaly proof). Fix validated in-guest (b_resid=0 per iteration + bzero of the USERSPACE read bounce before dispatch): T1 zeros, T2 clean EIO Γ—400, T3 zero matches Γ—5000 under full churn, raw I/O otherwise functional.

Validated fix.diff in findings/poc/DF-2979/ (plus belt-and-braces: clear b_resid in initpbuf()).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of kern_physio.c (GLM 5.3); cross-context kernel-heap disclosure reproduced + fix validated. DF-0249/0250 re-verified still present, not re-reported.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2979 Β· 16 files
FileTypeDescriptionSize
dfp.c β€” 4.1 KB view raw
poc.c β€” 8.6 KB view raw
feeder.c β€” 1.2 KB view raw
Makefile β€” 67 B ↓ download
build.sh β€” 301 B view raw
run.sh β€” 940 B view raw
build.log β€” 205 B view raw
run.log β€” 1020 B view raw
run_vuln_t2.log β€” 699 B view raw
run_t3.log β€” 977 B view raw
run_fixed_t1t2.log β€” 1.3 KB view raw
env.txt β€” 414 B view raw
leak_sample_t3.bin β€” 128.0 KB ↓ download
leak_sample_t2.bin β€” 256.0 KB ↓ download
fix.diff β€” 1.1 KB view raw
VERDICT.md β€” 5.1 KB ↓ raw
VERDICT.md
↓ download raw

DF-2979 VERDICT β€” reproduced (kernleak, cross-context infoleak of shared kernel pbuf contents)

Guest: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS X86_64_GENERIC), QEMU/KVM 6 CPU, root on virtio-blk (vbd0s1d, hammer2). vm.pbuf_mem_count=394.

Root cause (path:line)

physio() consumes bp->b_resid after every transfer (sys/kern/kern_physio.c:112, iolen = bp->b_bcount - bp->b_resid) but the value is never established for this transfer:

The pbuf_mem pool is shared kernel-wide (physio, cam_periph.c:743 CAM pass-through bounce buffers, disklabel/mbr/gpt scanners, subr_diskiocom), and relpbuf() returns pbufs LIFO per bucket (vm_pager.c:~660). So the b_resid read at kern_physio.c:112 is a previous tenant's value whenever the device's strategy completion does not set it. Several in-tree completions don't:

  • virtio_blk.c:918-927 β€” error completions set B_ERROR/b_error and never touch b_resid (this is the default block driver on QEMU/KVM),
  • xdisk.c:940-950, 1030-1041, 1250-1257 β€” timeout / lost-link completions, same omission (remotely triggerable by the xdisk server),
  • dscheck truncation path (subr_diskslice.c:275-276) shrinks b_bcount without adjusting b_resid (benign only because current drivers set it later).

Additionally the copyout at kern_physio.c:113-121 executes before the B_ERROR check at kern_physio.c:128, so a failed read still transfers stale bounce-buffer bytes to userspace.

Trigger model

Any user with read permission on a physio-backed device (raw disks root:operator 640 on stock systems β€” group operator members, relaxed perms on tapes/cdroms in common setups; /dev/xdX readers against a misbehaving remote xdisk server). One failing I/O of the class above is enough; no lying driver value is required (that is DF-0249's separate, driver-side framing).

Reproduction (all as uid 65534 nobody, stock kernel #0)

T1 β€” stale-content disclosure (deterministic, run.log): NORMAL read(65536) fills the pbuf and leaves b_resid=0; ERR(NORESID) read(512) β†’ physio computes iolen = 512-0 = 512 and copies the stale bounce bytes out before noticing B_ERROR. Observed: read = -1 errno=5 with 510/512 stale kernel bytes in the buffer (pattern dump in run.log).

T3 β€” cross-context disclosure (run_t3.log): six root feeders (pinned one per CPU) raw-read the hammer2 root disk through physio; nobody spins failing 128KB reads on /dev/dfp. Reproduced twice (attempt 426 and attempt 1 under churn): 4096 bytes of /dev/vbd0s1d contents matched the root-owned reference β€” nobody cannot open that device (640 root:operator). leak_sample_t3.bin block 0 begins … 32 4d 41 48 β€” the big-endian "HAM2" hammer2 volume-header magic: raw on-disk data of the root filesystem, disclosed by a failed unprivileged read.

T2 β€” stale-resid underflow (run_vuln_t2.log): EOF completion poisons b_resid=65536; erroring read(512) β†’ iolen = 512-65536 β†’ (size_t)0xFFFF…F0180 β†’ physio attempts a ~2^64-byte copyout (kern_physio.c:115). On x86-64 std_copyout's wrap check (sys/platform/pc64/x86_64/support.s:247-249, addq %rdx,%rax; jc copyout_fault) rejects it instantly: EFAULT returned to a 512-byte read whose buffer was fully resident and mapped β€” anomalous, clearly observed (errno=14 on attempt 1), but zero bytes copied. Consequence: correctness bug + spurious EFAULT; not a mass leak on x86-64. (Pass-2 note: the same wrap check bounds DF-0249's driver-side b_resid>b_bcount variant to EFAULT-on-copyout on x86-64.)

No panic occurred in any run; guest stayed up throughout.

Impact

Kernel information disclosure, cross-context: up to MAXPHYS (128KB) per failing read of whatever the recycled pbuf last carried β€” other processes' raw disk I/O (file contents, filesystem metadata), CAM pass-through payloads, disklabel-scan sectors. DoS/corruption: none beyond the T2 EFAULT anomaly (uio bookkeeping is internal). Not a write primitive; no uid0 chain (leak-only class).

Fix validation (fix.diff β€” bp->b_resid = 0; per transfer + bzero()

of the USERSPACE read bounce buffer before dispatch)

Patched kernel built in-guest (make nativekernel, #1 Fri Sep 4 13:57:06), rebooted, PoC rebuilt and rerun (run_fixed_t1t2.log):

test stock #0 kernel patched #1 kernel
T1 LEAK 510/512 stale bytes clean, buffer zeroed, EIO
T2 EFAULT (underflow fired) clean EIO, no EFAULT, 400 attempts
T3 LEAK 4096B root-disk bytes 0 matches / 5000 attempts under churn

Raw I/O otherwise functional (NORMAL reads still return data; system boots and runs hammer2 root on the patched kernel). Guest reset to clean-source snapshot after validation.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to /usr/src in-guest, nativekernel built+installed, rebooted into #1: T1 disclosed 0/512 bytes (was 510/512), T2 never EFAULTed in 400 attempts (was instant), T3 matched 0 bytes in 5000 attempts under 6-CPU root-disk churn (was 4096B root-disk leak). No functional regression.

run_fixed_t1t2.log (all three tests on patched kernel), fix.diff, VERDICT.md fix table
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Sep 4 13:57:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user with read perm on a physio-backed device -> (victim pbuf previously carried root's raw-disk read / CAM pass-through data; LIFO pbuf_mem pool recycling) -> one device-error completion of the virtio_blk/xdisk class (b_resid untouched, iolen = b_bcount - stale) -> physio copyout of stale bounce buffer before B_ERROR check -> up to 128KB of another context's kernel-resident I/O data per failed read

Evidence (decisive lines)

["run.log: T1 'LEAK -- failed read disclosed 510/512 stale kernel bounce-buffer bytes' + pattern hexdump despite errno=5", "run_t3.log: 'CROSS-CONTEXT LEAK at attempt 426 -- unprivileged FAILED read disclosed 4096 bytes of raw root-disk contents' (also hit at attempt 1 on rerun)", "leak_sample_t3.bin: block 0 begins '... 32 4d 41 48' = big-endian HAM2 hammer2 volume-header magic from /dev/vbd0s1d (unreadable by the triggering user)", 'run_vuln_t2.log: errno=14 (EFAULT) on a 512-byte read into a fully resident 256MB mapping = the ~2^64 copyout attempt, rejected by support.s:247-249', 'run_fixed_t1t2.log: with fix.diff kernel #1 -- T1 zeros, T2 clean EIO 400 attempts, T3 zero matches in 5000 attempts']

PoC changes

Seed had no runnable PoC. Wrote dfp.c KLD (stock physread/physwrite + strategy modes mirroring virtio_blk error/ dscheck EOF completions), poc.c (T1/T2/T3 + leak sampling), feeder.c (per-CPU root raw-disk churn for the cross-context race). Fixed along the way: dev_ops_add() no longer exists in DF 6.5 (make_dev only), no sys/ioctl.h in kernel (ioccom.h), device_if.h/bus_if.h SRCS, anon mmap must be prefaulted for copyout evidence, T3 leak path moved to /tmp (nobody unwritable in /tmp/dfp), nobody cannot usched_set (SYSCAP) so pinning is root-side only + unpriv spins.

Verified recommended fix

kern_physio.c: initialize bp->b_resid=0 per transfer iteration and bzero() the USERSPACE read bounce buffer before dispatch; harden initpbuf() to also clear b_resid

Verdict

physio consumes a b_resid value that neither it, initpbuf() (vm_pager.c:383) nor reinitbufbio() (vfs_bio.c:743) initializes, and copyout (kern_physio.c:113-121) runs before the B_ERROR check (line 128). With any strategy error-completion that skips b_resid -- in-tree: virtio_blk.c:918-927 error completions, xdisk.c:943/1035/1250 timeouts/lost-links -- a failing raw read copies the recycled pbuf's previous contents to the user. Reproduced as uid 65534: deterministic 510/512 stale-bytes disclosure (T1), and 4096 bytes of root-only raw hammer2 root-disk data (HAM2 volume-header magic) disclosed via a failed read while root feeders raw-read /dev/vbd0s1d (T3). The stale> bcount variant underflows iolen to ~2^64 but x86-64 std_copyout's address-wrap check (support.s:247-249) bounds it to a spurious EFAULT (T2, observed) -- which also bounds the related DF-0249 driver-side mechanism. Leak-only class; no write primitive.