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

USB copy engine enforces no bounds: usbd_get_page + usbd_copy_in/out/_user/frame_zero/usb_uiomove memcpy across the loaded extent with no clamp β€” single chokepoint for ~120 call sites (cross-object heap OOB read/write/zero + wild KVA/UINT32_MAX span + instant panic demonstrated via root KLD; in-tree precedent DF-0998/DF-2357)

Summary

usbd_get_page resolves any caller offset without consulting the loaded extent the same struct tracks, and all six copy primitives then memcpy/copyin/copyout min(res.length,len) to that unbounded address - a single driver-side length/offset slip becomes a direct kernel-heap OOB read/write/zero. Root-KLD-proven on the stock guest 3/3: 16-byte cross-object OOB WRITE (read back through the victim object), OOB read, frame_zero cross-object zeroing, get_page returning buffer+0xFFFFFF00/length=UINT32_MAX, stale physaddr from 12 pages past a 2-page load, and an instant Fatal trap 12 inside usbd_copy_out. No live unpriv trigger in-tree (ugen clamps; actlen truncated) but DF-0998/DF-2357 shipped exactly this slip class as 16-byte DMA overwrites - the chokepoint clamp would have caught both. Extent-clamp-in-get_page + zero-length-terminate loops fix, validated on rebuilt kernel (OOB gone, in-extent + ehci/xhci unaffected).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5381 Β· 17 files
FileTypeDescriptionSize
README.md β€” 3.0 KB ↓ raw
VERDICT.md β€” 6.3 KB ↓ raw
df5381.c β€” 11.5 KB view raw
Makefile β€” 73 B ↓ download
build.sh β€” 137 B view raw
run.sh β€” 160 B view raw
guest_build.sh β€” 76 B view raw
guest_run.sh β€” 136 B view raw
build.log β€” 65 B view raw
run.log β€” 3.7 KB view raw
run.2.log β€” 18 B view raw
run.fixed.log β€” 3.6 KB view raw
panic.txt β€” 3.0 KB view raw
env.txt β€” 326 B view raw
fix.diff β€” 1.7 KB view raw
verdict.json β€” 6.1 KB view raw
manifest.json β€” 1.4 KB view raw

DF-5381 β€” USB copy engine has no bounds enforcement (usb_busdma.c)

What this is

A root KLD harness that drives the in-kernel USB copy engine (usbd_get_page / usbd_copy_in / usbd_copy_out / usbd_frame_zero, sys/bus/u4b/usb_busdma.c) with offsets/lengths beyond the extent of the DMA buffer backing a usb_page_cache. All allocations are made through the engine's own public API (usb_dma_tag_setup + usb_pc_alloc_mem), i.e. fully legitimate page caches; only the copy offsets/lengths are out of extent β€” exactly what a single driver-side arithmetic slip produces.

Build (on the guest, as root; /usr/src must exist)

mkdir -p /root/df5381
cp df5381.c Makefile /root/df5381/
cd /root/df5381 && make -m /usr/src/share/mk        # -> df5381.ko

(guest_build.sh wraps this)

Run

kldload /root/df5381/df5381.ko
dmesg | awk '/DF5381/{f=1} f'
kldunload df5381

(guest_run.sh = one load/unload; run3.sh = three consecutive runs)

Expected output β€” UNPATCHED kernel (baseline, 3/3 runs identical)

[DF5381] T1 PASS cross-object OOB READ (read pcB's bytes via pcA offset 512)
[DF5381] T2 PASS cross-object OOB WRITE (planted 16 bytes into pcB via pcA offset 512)
[DF5381] T2b PASS write past object extent offset=200 accepted
[DF5381] T3 PASS cross-object OOB ZERO via usbd_frame_zero
[DF5381] T5 PASS engine hands out wild KVA +4GB-256 and length=UINT32_MAX
[DF5381] T6 PASS page-array walked 12 pages past a 2-page load; stale physaddr returned verbatim
[DF5381] RESULT 6/7 primitive tests passed

Additionally, the unguarded foreign-page variant (reading pcM.buffer+8192 where that page is an unmapped hole) took the whole kernel down β€” Fatal trap 12 ... Stopped at memcpy+0x55 β€” captured in panic.txt.

Expected output β€” PATCHED kernel (fix.diff applied, 3/3 runs identical)

All OOB primitives are refused:

T1 FAIL cross-object read mismatch      <- clamp fired, chk[] never written
T2 FAIL cross-object write not observed <- pcB still holds its sentinel = NOT corrupted
T3 FAIL zero not observed               <- pcB untouched
T5 ... length=0x00000000                <- no more wild KVA / UINT32_MAX span
T6 ... physaddr=0x0 length=0            <- stale page-array entry no longer served

(T2b "PASS" on both kernels is a write at offset 200 < 400-byte doubled allocation β€” inside the engine's own kmalloc'd block, safe by design. T4 "PASS" on the patched kernel is an unwritten-destination artifact: the copy loop breaks before touching leak[], which then holds stale harness stack β€” see VERDICT.md.)

Success criterion: on the unpatched kernel, bytes written through pcA at an out-of-extent offset are read back through the neighbouring pcB (cross-object corruption) and vice versa; on the patched kernel the neighbour's contents are never modified and no wild pointers are handed out, while in-extent copies (sentinel plant + read-back in T1 setup) keep working and the USB stack (ehci.ko/xhci.ko) loads normally.

VERDICT.md
↓ download raw

DF-5381 VERDICT β€” reproduced (engine primitive), fix validated

File: sys/bus/u4b/usb_busdma.c Finding: The USB DMA copy engine performs no bounds enforcement whatsoever. usbd_get_page() (usb_busdma.c:79-126) resolves any caller offset against the page cache without consulting the loaded extent that is already tracked in the same structure (page_offset_end / page_offset_buf, set at usb_busdma.c:545/616-617 and 427-428), and every copy primitive β€” usbd_copy_in (131), usbd_copy_in_user (159), usb_uiomove (216), usbd_copy_out (248), usbd_copy_out_user (276), usbd_frame_zero (304) β€” then memcpy/copyin/copyout/uiomoves min(res.length, len) bytes to/from that unbounded address (usb_busdma.c:140-147, 254-265).

Reproduced? YES β€” three faces of the primitive, all on the standard guest

Baseline kernel: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (x86_64, USB core compiled in; nm /boot/kernel/kernel shows T usbd_get_page/usbd_copy_in/usbd_copy_out/...).

A root KLD (df5381.c) builds 32 fully legitimate 200-byte usb_page_cache objects via the engine's own usb_dma_tag_setup() + usb_pc_alloc_mem() API (the same path usb_bus_mem_alloc_all uses), picks two heap-adjacent objects (KVA gap 512 = the kmalloc chunk), and drives the engine exactly as a buggy driver would:

  1. Cross-object kernel-heap OOB WRITE (3/3 runs) β€” usbd_copy_in(pcA, 512, pattern, 16) plants 16 bytes into pcB's allocation; read-back through pcB returns the planted bytes (run.log T2). This is precisely the DF-0998 / DF-2357 in-tree bug class (16-byte heap overwrites past USB DMA buffers that shipped in this tree), caught live.
  2. Cross-object kernel-heap OOB READ (3/3 runs) β€” usbd_copy_out(pcA, 512, ...) returns pcB's contents (run.log T1); usbd_frame_zero(pcA, 512, 16) zeroes them (T3). In a real driver this is how adjacent kernel heap is exfiltrated through /dev/ugen* (the DF-1103 / DF-2608 leak pattern).
  3. Instant kernel panic (1/1) β€” the same OOB read aimed at the page after a 2-page kmem buffer hit an unmapped kernel VA hole: Fatal trap 12: page fault ... fault virtual address = 0xfffff8011863e000 (= pcM.buffer+8192) ... Stopped at memcpy+0x55 from inside usbd_copy_out during kldload (panic.txt). Guest went down β€” a single miscalculated driver-side length is a kernel DoS.
  4. Unbounded pointers out of usbd_get_page (3/3) β€” get_page(pc, 0xFFFFFF00) hands out buffer + 0xFFFFFF00 with length = UINT32_MAX (T5); the multiseg page-array walk serves a stale/poisoned physaddr (0xdeadbeef0000) from 12 pages past a 2-page load (T6) β€” i.e. HCDs would program DMA descriptors from garbage.

No escalation chain is claimed: no live unprivileged trigger exists in the tree today. The one userspace-reachable client (usb_generic.c) clamps nFrames (usb_generic.c:1069) and sum(length) <= max_data_length (usb_generic.c:1166-1170), and usbd_transfer_done truncates actlen > sumlen (usb_transfer.c:2952-2959). The finding is therefore filed as Info / defense-in-depth: the engine is the single chokepoint for ~120 call sites, at least four of which (DF-0998, DF-2357, if_axge's device-derived hdr_off, if_urndis's device-derived rm_dataoffset) have already depended on that arithmetic being perfect everywhere.

Bonus observation (same contract class)

usb_pc_alloc_mem dereferences pc->tag_parent without a NULL check (usb_busdma.c:495 β†’ usb_dma_tag_find β†’ movzbl 0x3a(%rdi), rdi=0): an uninitialized page cache panics the kernel instantly (observed on the guest during harness bring-up; usb_transfer.c:842 is the only thing that guarantees tag_parent is set). Kernel-driver-contract issue only.

Fix validation β€” YES, fixed

fix.diff (git-apply-able against sys/bus/u4b/usb_busdma.c) adds: - a bound in usbd_get_page Case 1: offset β‰₯ page_offset_end - page_offset_buf β‡’ return length = 0, buffer = NULL, physaddr = 0; - break (or EFAULT for the _user/uiomove variants) on zero-length results in all six copy loops, so truncation terminates cleanly.

Applied to the guest's /usr/src, incremental nativekernel rebuild, installkernel, reboot into #1: Sat Oct 3 21:56:23 UTC 2026, exact same harness 3Γ—:

test baseline (unpatched) patched
T1 cross-object read PASS (sentinel read via pcA) FAIL β€” clamp fired, no copy
T2 cross-object write PASS (pattern in pcB) FAIL β€” pcB still holds sentinel β‡’ not corrupted
T3 cross-object zero PASS FAIL β€” pcB untouched
T5 wild KVA/UINT32_MAX length PASS length=0, no wild pointer
T6 stale physaddr served PASS physaddr=0, length=0
guest up up (USB stack loads: ehci.ko, xhci.ko)

In-extent engine copies keep working on the patched kernel (the T1 setup plants and reads back the sentinel through the engine itself), so the clamp does not break legitimate transfers. Two artifacts to not mis-read on the patched runs (both are proof the fix works): "T2b PASS" writes at offset 200 < the engine's own doubled 400-byte allocation (safe by design of the usb_busdma.c:526-528 doubling), and "T4 PASS" shows an unwritten destination buffer (the copy loop breaks before touching it; the printed bytes are stale harness stack β€” note they are byte-identical across runs, unlike a real read).

Residual gap (documented, out of scope of this diff): Case 2 ("Plain PIO", usb_busdma.c:119-125) caches have no extent tracked at all; fully closing them needs a new size field in struct usb_page_cache.

Harness changes vs. the seeded idea

No seed PoC existed; the harness was authored from scratch. Iterations: (1) first version panicked via the tag_parent NULL contract (kept as evidence, harness now wires tag_parent like usb_transfer.c:842); (2) second version's foreign-page read panicked on an unmapped hole (kept as panic.txt, read now skipped); (3) final version stable 3/3.

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

fix.diff applied to guest /usr/src, incremental nativekernel rebuild, installkernel, reboot into DragonFly 6.5-DEVELOPMENT #1 Sat Oct 3 21:56:23 UTC 2026, exact same harness 3x: all out-of-extent primitives refused (T1/T2/T3 no longer touch the neighbouring object - pcB retains its sentinel; T5 length=0, no wild KVA; T6 physaddr=0), in-extent copies still function, ehci.ko/xhci.ko load normally, guest stable.

run.fixed.log (3 runs); VERDICT.md baseline-vs-patched table; kernel identity in env.txt
↓ fix.diffper-fix-DF-5381

Confirmed kernel references

Detail

Exploit chain

Primitive chain (root KLD harness, no unpriv in-tree trigger found): (1) driver-side arithmetic slip supplies offset/len beyond the frame buffer extent into usbd_copy_in/out; (2) engine resolves pc->buffer+offset with no clamp -> arbitrary-direction kernel heap read/write past the DMA buffer into adjacent slab objects (T1/T2/T3: cross-object read/write/zero of a 512-byte-chunk neighbour, 3/3 runs); (3a) read variant: adjacent kernel heap flows to the USB device/userspace (ugen read path) - leak; (3b) write variant: forge adjacent object fields - DF-0998/DF-2357 shipped exactly this as 16-byte overwrites; (3c) large-offset variant: unmapped kernel VA -> instant fatal kernel page fault (panic.txt, guest down). Weaponization to uid=0 requires a live buggy call site; none found in this pass (ugen bounds enforced, HCD actlen truncated).

Evidence (decisive lines)

run.log (baseline 3 runs: T1/T2/T2b/T3/T5/T6 PASS each, RESULT 6/7); panic.txt (Fatal trap 12 at memcpy+0x55, fault VA 0xfffff8011863e000 = pcM.buffer+8192, from usbd_copy_out during kldload - guest down); run.fixed.log (patched kernel 3 runs: pcB keeps sentinel a0 a1 .. af = not corrupted, length=0/physaddr=0 for T5/T6, guest up); VERDICT.md (full narrative incl. table baseline-vs-patched); fix.diff (git-apply-able); env.txt; build.log

PoC changes

Authored from scratch (no seed). Three iterations: (1) wire pcs[].tag_parent like usb_transfer.c:842 or usb_pc_alloc_mem NULL-derefs (observed panic, kept as note); (2) T7 unguarded foreign-page read panicked on an unmapped kernel hole (kept as panic.txt; read now skipped with explanation); (3) final harness stable. Also required sys/condvar.h + struct forward-decls before usb_busdma.h, and sys/conf.h for DEV_MODULE under -Werror.

Verified recommended fix

Clamp usbd_get_page Case 1 to the loaded extent (page_offset_end - page_offset_buf) returning length=0/buffer=NULL/physaddr=0, and break (or EFAULT) on zero-length in all six copy loops - see fix.diff.

Verdict

The USB copy engine in sys/bus/u4b/usb_busdma.c enforces no bounds at all: usbd_get_page (usb_busdma.c:79-126) resolves any caller offset without consulting the loaded extent already tracked in the same struct (page_offset_end/page_offset_buf), and usbd_copy_in/_out/_user/frame_zero/usb_uiomove memcpy/copyin/copyout the full requested span (usb_busdma.c:140-147, 254-265). A root KLD driving the engine with legitimate usb_dma_tag_setup+usb_pc_alloc_mem page caches and out-of-extent offsets demonstrated, 3/3 runs each on the stock guest kernel: a 16-byte cross-object kernel-heap WRITE into the neighbouring kmalloc chunk (read back through the victim), a cross-object READ, cross-object ZEROing, usbd_get_page handing out buffer+0xFFFFFF00 with length=UINT32_MAX, and the multiseg page-array walk serving a stale physaddr from 12 pages past a 2-page load. The same OOB read aimed one page past a 2-page kmem buffer hit an unmapped kernel VA hole and took the kernel down (Fatal trap 12, memcpy+0x55 inside usbd_copy_out; panic.txt). This is the exact primitive behind the in-tree DF-0998 and DF-2357 16-byte USB DMA heap overwrites. No live unprivileged trigger exists today (ugen clamps nFrames at usb_generic.c:1069 and sum(length) at 1166-1170; actlen is truncated at usb_transfer.c:2952-2959), so the finding is filed as Info/defense-in-depth at the single chokepoint of ~120 call sites. fix.diff (extent clamp in usbd_get_page Case 1 + break/EFAULT on zero length in all six copy loops) was built into the guest kernel and validated: every OOB primitive is refused, the neighbouring object keeps its sentinel (uncorrupted), in-extent transfers still work, and the USB stack (ehci.ko/xhci.ko) loads normally.