vkd_doio performs COW-mode bcopy and backing-file pread/pwrite with no driver-level bounds check on bio_offset/b_bcount (latent arbitrary-offset OOB R/W in the vkernel host process; shielded today only by dscheck)
Summary
COW-mode bcopy(sc->map_buf + bio_offset,...) and raw-offset pread/pwrite never compare against sc->size - pwrite past EOF silently extends the host backing file. Shielded today by dscheck (empirically proven inside a purpose-built vkernel on the guest: guest-root OOB raw reads/writes at EOF/size+512/1TB all rejected, host process survived, backing unchanged). In-tree dscheck-bypass precedent exists (fd.c part>=128) - the day vkd grows a raw node this becomes a guest-sandbox break. In-driver bounds reject + resid clamp fix (validated, no regression).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5010 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| vkd_bounds.c | β | 2.7 KB | view raw | |
| vkd_oob.c | β | 3.3 KB | view raw | |
| build.sh | β | 137 B | view raw | |
| run.sh | β | 190 B | view raw | |
| build.log | β | 595.7 KB | β download | |
| build-fix.log | β | 25.8 KB | view raw | |
| run.log | β | 2.3 KB | view raw | |
| run.2.log | β | 2.1 KB | view raw | |
| run.3.log | β | 2.1 KB | view raw | |
| run.4-fixed.log | β | 2.1 KB | view raw | |
| env.txt | β | 411 B | view raw | |
| fix.diff | β | 1.4 KB | view raw | |
| verdict.json | β | 4.6 KB | view raw |
DF-5010 β vkernel vdisk (vkd) lacks driver-level I/O bounds checking
File: sys/dev/virtual/vkernel/disk/vdisk.c
Class: CWE-787/CWE-125 (latent), defense-in-depth
Severity: Info (hardening) β NOT exploitable on any currently reachable path
Verification: built a full vkernel64 inside the QEMU DF guest, booted it with a
COW disk (-R), and fired 512-aligned raw OOB reads/writes at /dev/vkd1 and
/dev/vkd2 as guest root (PID 1). All were rejected by the generic slice layer
(dscheck()) before reaching vkd_doio(); the vkernel host process survived
every attempt and the backing files never grew.
What the trigger does
vkd_bounds.c runs as PID 1 inside the vkernel (via -e init_path=/poc):
- mounts devfs,
pwrite/pread512-byte blocks at: offset 0 (in bounds), exact EOF,size+512, and1 TBon both the plain (-r) and COW (-R) disks.
Expected (and observed) on a stock kernel:
- in-bounds I/O succeeds;
- OOB I/O returns
-1/EINVAL(silentdscheckbounds rejection,sys/kern/subr_diskslice.c:253β294); - the vkernel process survives;
plain.imgnever grows; - when PID 1 exits the vkernel panics with
panic: Going nowhere without my init!β expected and harmless.
vkd_oob.c is the earlier unaligned variant (4-byte writes); it demonstrates
the alignment rejection (dscheck(vkdN): b_bcount 4 is not on a sector
boundary, see run.log).
How to reproduce (inside the DF QEMU guest, as root)
# 1) build the vkernel (see build.sh; note: vkernel64 link is currently
# broken in HEAD β kern_mib.c needs __build_id_start/end which the
# vkernel conf/Makefile SYSTEM_LD override never defines; work around
# with -Wl,--defsym, see build.sh)
# 2) build a UFS root image containing the static PoC at /poc (build.sh)
# 3) run:
sysctl vm.vkernel_enable=1
cd /root/vk
timeout 90 ./vkernel64 -m 256M -r root.img -r plain.img -R cow.img \
-e init_path=/poc < /dev/null > run3.log 2>&1
# 4) observe: "vkd2: COW disk", "init died (signal 0, exit 0)", no kernel
# fault, plain.img still 16777216 bytes.
Bottom line
vkd_doio()(vdisk.c:303-347) performsbcopy(sc->map_buf + bio_offset, β¦)(COW mode) andpread/pwrite()(regular mode) with no validation ofbio_offset/b_bcountagainstsc->size.- Every currently reachable path into
vkdstrategy()is bounds-checked bydscheck()first β including the whole-disk nodes, becausedisk_create()exposes only the disk-core (disk1_ops/disk2_ops) covering devices in devfs (sys/kern/subr_disk.c:691-712). - The in-tree precedent for bypassing dscheck exists: whole-disk raw-access
partitions β₯128 (
sys/kern/subr_diskslice.c:171-175, created e.g. byfd.c:1036-1054) are passed to the driver unbounded. If vkd ever grows such nodes (or any bio source skips dscheck), COW mode turns into an arbitrary-offset OOB read/write inside the vkernel host process. - The fix (see
fix.diff) adds the driver-level check and also clamps theb_residcomputation forpread/pwrite == -1(error) returns, which today computesresid = bcount + 1; harmless on x86-64 becausestd_copyout's address-wrap guard (sys/platform/pc64/x86_64/support.s:247-249) turns the resulting(size_t)-1copyout length into an immediateEFAULT.
DF-5010 VERDICT β vdisk missing bounds check: shielded by dscheck(); not exploitable today; hardening fix validated
Reproduced?
The OOB itself: NO β and that is the verified security conclusion.
The driver-level missing check is real, but every path a guest can drive into
vkdstrategy() is bounded by the generic slice layer before vkd_doio() sees
the bio. Verified three ways:
- Code trace (the shield):
* Every devfs-visible vkd node is a disk-core covering device
(
disk1_ops/disk2_ops,sys/kern/subr_disk.c:716-745), whosed_strategyisdiskstrategyβdscheck()(sys/kern/subr_disk.c:1240-1246,sys/kern/subr_diskslice.c:91). *dscheck()rejects negative offsets (subr_diskslice.c:110), enforces sector alignment (:131-143), and boundssecno + nsecagainst the slice/disk end with truncate/EOF/EINVAL semantics (:253-277). * The only dscheck bypass in the system β whole-disk raw-access partitions β₯ 128, "fed directly through to the device with no further interpretation" (subr_diskslice.c:171-175) β requires dev_t minors that vdisk never creates (onlyfd.c:1036-1054creates such nodes). * The dump path is separately bounded (diskdump()βdisk_dumpcheck()+ explicit range check, sys/kern/subr_disk.c). - Empirical, run 1 (
run.log, unaligned 4-byte I/O): kernel printeddscheck(vkd1): b_bcount 4 is not on a sector boundaryanddscheck(vkd2): β¦for every attempt β proving dscheck is interposed on the raw whole-disk nodes the PoC opened. - Empirical, runs 2-3 (
run.2.log,run.3.log, 512-aligned): all six OOB attempts (write @1 TB, write @size+512, read @1 TB, on both plain and COW disks) completed without any kernel-mode fault β a wild COWbcopywould have killed the vkernel host process before "init died" appeared β the PoC ran to completion (init exit 0), andplain.imgnever grew (16777216 bytes before and after; md5 unchanged): the 1 TBpwritenever reached the backing file. The aligned bounds-rejection path in dscheck (:259 β :294) is silent, which is why no diagnostic appears.
The latent hazard (why this is still filed)
- COW mode (
-R, flags & 1):vkd_doio()doesbcopy(sc->map_buf + bio->bio_offset, bp->b_data, bp->b_bcount)(vdisk.c:310-314, :322-326) βsc->map_bufis a host-processmmapof lengthsc->size(vdisk.c:133-141). An unbounded bio is an arbitrary-offset OOB read/write inside the vkernel host process (guest-root β host-process memory corruption; VM-sandbox break). - Regular mode:
pwrite()at an unbounded offset silently extends the host backing file (sparse growth to attacker-chosen size β host disk-fill). - DragonFly's own
fd.cdemonstrates that dscheck-bypassing raw partitions are a supported driver feature; the day vkd grows them, the missing check becomes exploitable. FreeBSD's md(4) checks bounds in-driver for exactly this reason.
Secondary finding (killed): b_resid underflow
bp->b_resid = bp->b_bcount - n (vdisk.c:346) with n = -1 (pread/pwrite
error, e.g. NFS-backed disk going down, ENOSPC) computes resid = bcount + 1.
physio() then computes iolen = b_bcount - b_resid = (size_t)-1 and calls
copyout(bp->b_data, ubase, iolen) (sys/kern/kern_physio.c:104). On x86-64
std_copyout pre-checks address wrap (udaddr + len carry β immediate
EFAULT, sys/platform/pc64/x86_64/support.s:247-249), so no bytes are
copied β the disclosure is dead; only uio accounting wraps (loop exits with
the I/O error). Clamped anyway in fix.diff for correctness.
Fix validation
fix.diffapplied to the guest's/usr/src(checksum-identical to the audit tree), vkernel rebuilt (build-fix.log, 21:35 kernel.stripped), rerun (run.4-fixed.log): identical clean behaviour β COW disk armed, PoC completed (init exit 0), vkernel survived,plain.imgmd5 unchanged. No regression; the driver-level rejection is now armed as defense-in-depth.- Baseline vs patched: baseline (stock) did not reproduce the OOB (shielded); patched also does not reproduce; in-bounds I/O unaffected.
Environment / build notes
- Guest: DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (see
env.txt). - vkernel64 is currently un-buildable in HEAD:
conf/Makefile'sSYSTEM_LDoverride drops the-Wl,-T,ldscript.x86_64that defines__build_id_start/__build_id_end, whichkern/kern_mib.c:102-103now references β link failure (seebuild.log). Worked around guest-side with-Wl,--defsym(not part of the fix β upstream should either re-add the ldscript to SYSTEM_LD or guard kern_mib.c). - vkernel assets:
sysctl vm.vkernel_enable=1,/var/vkernelmust exist.
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
fixedfix.diff applied to guest /usr/src (checksum-identical source), vkernel rebuilt, same PoC rerun: identical clean behaviour (COW disk armed, PoC completed, vkernel survived, backing file unchanged) - no regression, driver-level rejection now armed as defense-in-depth. Baseline never reproduced the OOB (dscheck shield), so patched-vs-baseline shows no behavioural delta by design.
build-fix.log (rebuild with patched vdisk.c), run.4-fixed.log (patched run: 'vkd2: COW disk', 'init died (signal 0, exit 0)', plain.img md5 unchanged)
Confirmed kernel references
- sys/dev/virtual/vkernel/disk/vdisk.c:303-347
- sys/dev/virtual/vkernel/disk/vdisk.c:310-314
- sys/dev/virtual/vkernel/disk/vdisk.c:322-326
- sys/dev/virtual/vkernel/disk/vdisk.c:346
- sys/kern/subr_diskslice.c:91-304
- sys/kern/subr_diskslice.c:171-175
- sys/kern/subr_diskslice.c:253-277
- sys/kern/subr_disk.c:691-745
- sys/kern/subr_disk.c:1240-1246
- sys/kern/kern_physio.c:69
- sys/dev/disk/fd/fd.c:1036-1054
- sys/platform/pc64/x86_64/support.s:247-249
Detail
Evidence (decisive lines)
run.log (run1: dscheck alignment rejections printed for /dev/vkd1,/dev/vkd2), run.2.log + run.3.log (aligned OOB: no fault, init exit 0, plain.img unchanged at 16777216 bytes), run.4-fixed.log (patched kernel: identical clean behaviour), VERDICT.md (full path analysis), build.log (vkernel build incl. HEAD link breakage of vkernel64)
PoC changes
Initial seed sketch had wrong syscall expectations (4-byte unaligned raw I/O, which dscheck rejects on alignment before bounds). Final trigger uses 512-byte aligned pwrite/pread on the whole-disk nodes of both a plain (-r) and a COW (-R) disk, runs as PID1 via -e init_path=/poc (no userland needed; mounts devfs itself), and logs to unbuffered stderr because the vkernel userland console is unreliable with stdin=/dev/null. vkernel64 also needed a guest-side build workaround (defsym __build_id_start/end) because HEAD's vkernel link is broken.
Verified recommended fix
Add driver-level bounds check in vkd_doio() rejecting bio_offset<0 or bio_offset+b_bcount>sc->size with EINVAL, and clamp n<0 to 0 before computing b_resid (see fix.diff)
Verdict
not_reproduced
No comments yet.