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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5010 Β· 15 files
FileTypeDescriptionSize
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/pread 512-byte blocks at: offset 0 (in bounds), exact EOF, size+512, and 1 TB on 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 (silent dscheck bounds rejection, sys/kern/subr_diskslice.c:253β†’294);
  • the vkernel process survives; plain.img never 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) performs bcopy(sc->map_buf + bio_offset, …) (COW mode) and pread/pwrite() (regular mode) with no validation of bio_offset/b_bcount against sc->size.
  • Every currently reachable path into vkdstrategy() is bounds-checked by dscheck() first β€” including the whole-disk nodes, because disk_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. by fd.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 the b_resid computation for pread/pwrite == -1 (error) returns, which today computes resid = bcount + 1; harmless on x86-64 because std_copyout's address-wrap guard (sys/platform/pc64/x86_64/support.s:247-249) turns the resulting (size_t)-1 copyout length into an immediate EFAULT.
VERDICT.md
↓ download raw

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:

  1. 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), whose d_strategy is diskstrategy β†’ 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 bounds secno + nsec against 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 (only fd.c:1036-1054 creates such nodes). * The dump path is separately bounded (diskdump() β†’ disk_dumpcheck() + explicit range check, sys/kern/subr_disk.c).
  2. Empirical, run 1 (run.log, unaligned 4-byte I/O): kernel printed dscheck(vkd1): b_bcount 4 is not on a sector boundary and dscheck(vkd2): … for every attempt β€” proving dscheck is interposed on the raw whole-disk nodes the PoC opened.
  3. 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 COW bcopy would have killed the vkernel host process before "init died" appeared β€” the PoC ran to completion (init exit 0), and plain.img never grew (16777216 bytes before and after; md5 unchanged): the 1 TB pwrite never 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() does bcopy(sc->map_buf + bio->bio_offset, bp->b_data, bp->b_bcount) (vdisk.c:310-314, :322-326) β€” sc->map_buf is a host-process mmap of length sc->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.c demonstrates 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.diff applied 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.img md5 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's SYSTEM_LD override drops the -Wl,-T,ldscript.x86_64 that defines __build_id_start/__build_id_end, which kern/kern_mib.c:102-103 now references β†’ link failure (see build.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/vkernel must 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

fixed
baseline no→ patch + rebuild →patched clean

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

Confirmed kernel references

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