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

usb_handle_request(): unguarded 'max_len -= off' offset subtraction relies on provider-length immutability β€” latent uint16 wrap yields an out-of-object DMA window (up to ~4 KiB/fragment OOB read on IN, mirror OOB write on OUT via a lying sub-driver); hardening

Summary

The fragment computation max_len -= off has no underflow guard: safety rests on the exact mod-2^16 cancellation with the dispatcher's *plen += off AND the assumption that a provider's length never shrinks below the transferred offset within one transfer - nothing enforces the second. KLD rig on the production code: a shrinking provider wraps max_len and the clamps (max_data_length, rem) do not know the provider's object size, so the zero-copy frame window escapes the object by up to ~4KiB/fragment (IN = OOB DMA read streaming adjacent kernel memory to the hostile host; OUT via a lying kobj sub-driver = host-controlled OOB write). All in-tree providers are pure functions of immutable setup bytes (verified), so Info/hardening. 41/41 conformant rig cases pass + 4 latent primitives observed; guard-the-subtraction fix validated byte-identical on conformant paths, contained on the hostile ones.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5426 Β· 12 files
FileTypeDescriptionSize
neg_usbhr.c β€” 35.0 KB view raw
Makefile β€” 153 B ↓ download
build.sh β€” 127 B view raw
run.sh β€” 120 B view raw
build.log β€” 6.0 KB view raw
run.log β€” 10.2 KB view raw
fix.diff β€” 381 B view raw
fix_build.log β€” 6.2 KB view raw
fix_run.log β€” 8.2 KB view raw
env.txt β€” 408 B view raw
VERDICT.md β€” 9.3 KB ↓ raw
verdict.json β€” 5.7 KB view raw
VERDICT.md
↓ download raw

DF-5426 VERDICT β€” sys/bus/u4b/usb_handle_request.c pass 2

Mode: audit (pass 2, GLM 5.3). File: 804 LOC, gadget EP0 engine. Result: 1 Info hardening finding (this ID) + a verified negative result for everything else. Live evidence: production code executed in a KLD rig on the QEMU guest (DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026), 41/41 containment expectations met twice, zero guard-zone hits, zero panics, module load/unload clean.

What was audited and how the candidates died

The engine demultiplexes host-issued EP0 setups (req is 8 bytes of fully host-controlled data re-read from frbuffers[0] each callback, usb_handle_request.c:512), computes a per-fragment data length, and either copies into the ep0 DMA window (usbd_copy_in, :781) or aims the hardware at a provider buffer zero-copy (usbd_xfer_set_frame_data, :785). Fragment offsets derive from off = wLength - control_rem (:514-522) where control_rem is managed by usbd_setup_ctrl_transfer (usb_transfer.c:1507-1654: seeded once from wLength at :1548/:1464, monotone, len <= control_rem enforced at :1608, force_short_xfer -> control_rem = 0 at :1616-1617).

Candidate kill list (all traced at path:line, then live-checked):

  1. :746 max_len -= off underflow (the ustorage pass-2 question). Three separate proofs it cannot fire in-tree: * Iface/driver path: the dispatcher does *plen += off (usb_handle_request.c:285) before returning, so max_len = (plen + off) - off ≑ plen (mod 2^16) β€” exact cancellation, for any driver, however lying. Rig: D01-D04 (conformant) and P2 (lying 1024β†’300) confirm max_len == plen. * Internal paths (GET_CONFIG len 1 :654, GET_STATUS len 2 :672, GET_EP_STATUS len 2 :736): off advances only by bytes previously delivered, each ≀ max_len = len - off; hence off ≀ len by induction. Rig I01-I09. * Template path (:644, full-length contract): same induction, providers are pure functions of the immutable re-read setup. * *The one feeder that can push off past the provider length β€” force_short_xfer zeroing control_rem (usb_transfer.c:1616) β€” forces rem == 0, and the if (max_len > rem) clamp (:753) zeroes max_len. Rig I02/I06/T02/T03/P1c.
  2. :778-792 copy/DMA bounds. max_len ≀ min(1024, rem, provider_len - off); ep0 DMA geometry is 8 (setup) + 1024 (USB_EP0_BUFSIZE, usb_dragonfly.h:84; proxy rounding + REQ_SIZE, usb_transfer.c:666-727). usbd_copy_in therefore lands inside the window (rig mcopy-window check, I01-I09), and zero-copy windows stay inside provider objects for all conformant providers (T01-T12, D01- D04). GET_INTERFACE is served zero-copy from &iface->alt_index (:339) β€” 1 byte, contained (rig F03).
  3. Uninitialized stack reads. temp union fully initialized on every read path (:652, :670, :732-734); completion-state gating (:543, :741) prevents stale reads. Rig E01-E03.
  4. Sub-dev TOCTOU while the xfer lock is dropped (:232): the enum lock is held across dispatch (:235-352) and detach (usb_unconfigure, usb_device.c:486-524) takes the same lock. No window.
  5. Broadcast iface iteration (:295-297): usbd_get_iface is bounds-checked (usb_device.c:2237-2244); iface_index is uint8_t and the loop ends at the first NULL/idesc-NULL iface. Rig F06 (walks 2 valid + stops at idesc==NULL), F04/F05 (wIndex 3/255).
  6. SET_INTERFACE/alt, endpoint halts, set-address, set-config: alt validated against usbd_get_no_alts over the kernel-built config (:193, :314-329, rig F01/F02); usbd_get_ep_by_addr masked and bounded (usb_device.c:177-217); usbd_set_endpoint_stall NULL-ep-safe (usb_device.c:976-986, rig I13-I15); address β‰₯ 0x80 stalled and 7-bit masked (:677/:685, rig I10-I12); conf_no-- guarded by the UNCONFIG branch (:156-164) and the descriptor lookup bounds the index (rig: SET_CONFIG not runtime-driven β€” it needs a configured device graph; static trace only).
  7. Scratch/template lifecycle: descriptors live in the udev->usb_template_ptr heap block / static string tables; freed only from usb_free_device after usbd_transfer_unsetup of the ctrl xfers (usb_device.c:2183β†’2186) β€” no host-reachable UAF window. Vendor/string providers are pure functions of req (usb_template_msc.c:173-193, usb_template_mouse.c:…, modem NULL).
  8. Stale zero-copy frame pointers: both frame buffers are reset to local_buffer+{0,8} on every callback (:507-508).
  9. Diff vs FreeBSD main: functionally byte-identical (headers/ cosmetics only) β€” no missed upstream fix.

The finding (DF-5426, Info, hardening)

max_len -= off (:746) has no explicit underflow guard. The in-tree safety rests on two implicit invariants β€” the mod-2^16 cancellation (:285) and provider-length immutability within one control transfer. Nothing in the file enforces the second. If a provider's length ever shrinks below the already-transferred offset (out-of-tree usb_temp_get_desc_p override, or a future in-tree provider that rebuilds descriptors per call), max_len wraps; the :750/:753 clamps bound it to 1024 (and rem) but not to the provider object: the frame window [src_zcopy + off, +max_len) escapes the object by up to 1024 + off - real_len bytes per fragment.

Rig proof on the production code (baseline run): * P1a — provider shrinks 4096→100 at off=1024, wLength=4096: fragments 2..4 read [base+1024, base+2048), [+2048, +3072), [+3072, +4096) from a 100-byte object — escapes of 1948/2972/3996 bytes (IN direction ⇒ OOB DMA read of adjacent kernel memory to the hostile host). esc_bytes=8916 total. * P1b — shrink 1024→4 at off=1024: escapes of 2044/3068/4092 bytes. esc_bytes=9204. * P1c — shrink 200→4 with wLength=0xFFFF: the force_short_xfer termination (:764-770 + usb_transfer.c:1616) ends the transfer after fragment 1 — this shape cannot reach fragment 2 (contained). * P2 — the same class via a lying sub-driver through the real kobj USB_HANDLE_REQUEST dispatch: plen 1024 then 300 at off>0 (cancellation makes max_len = 300 exactly); window escapes the driver's 1024-byte buffer by 300 bytes per fragment; for an OUT (host→device) request the same window is the DMA write target — host-controlled heap overflow past the driver object. Pure driver-contract violation; no in-tree driver does this (g_audio.c:544-620, umodem.c, ustorage_fs.c, g_ all use the offset==0 ? size : 0 idiom). * P3* — driver sets *plen but not *pptr: NULL frame pointer with nonzero length (:785 USB_ADD_BYTES(NULL, off)).

None of P1/P2/P3 is reachable through in-tree code (all in-tree providers are pure functions of the immutable setup bytes, and the function-pointer usb_temp_get_desc_p is only writable by kernel code). Hence Info severity / defense-in-depth, not an exploit path.

Fix and validation

fix.diff (authored after verification, never applied to sys/):

-   max_len -= off;
+   if (max_len < off) {
+       /* length wrapped around - avoid underflow */
+       max_len = 0;
+   } else {
+       max_len -= off;
+   }

Validated by rebuilding the identical rig against a patched copy of the production file (md5 7d86c182…):

  • baseline: contained=41 failed=0 latent-primitives=4 (P1a/P1b wrap escapes present),
  • patched: contained=43 failed=0 latent-primitives=2 β€” P1a/P1b now escaped=0, terminating cleanly with a status stage after the honest prefix (frags=3 sent=1024); all 41 conformant cases byte-identical (the guard is a no-op when max_len >= off, which the induction guarantees for conformant providers).
  • P2/P3 persist by design: they are third-party driver-contract violations that this layer cannot detect (the kernel does not know the provider's real object size); the :750/:753 clamps remain the only bound.

Rig-harness notes (honesty section)

  • The rig models the callback context: xfer lock held across usb_handle_request() (the usb_handle_set_stall / usb_handle_iface_request unlock/relock paths give it back held).
  • control_rem lifecycle replicated exactly (init-once-from-wLength, len<=control_rem assert, force_short β†’ 0); a mismatch would have printed BOOKKEEPING VIOLATION (never fired).
  • Two rig-side iteration artifacts were fixed during bring-up (completion-fragment counting, GET_INTERFACE zero-copy classification); the production code was never modified for the baseline runs.
  • One guest panic occurred during harness bring-up and was a RIG bug (missing lock discipline), not production behavior; the guest was reset (vm.sh reset with-src) and all reported runs are from the clean snapshot.
  • usb_handle_set_config / remote-wakeup paths were traced statically only (they need a configured device graph / bus explore machinery); neither manipulates host-controlled lengths.

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

Rig-level fix validation (the patched unit is exactly the function the rig executes): baseline shows the P1a/P1b wrap escapes (esc_bytes 8916/9204); the identical rig built against the patched production file shows escaped=0 for both, clean status-stage termination after the honest prefix, contained=43/43, all 41 conformant cases byte-identical. P2/P3 (third-party driver-contract violations) intentionally remain - not addressable in this file.

fix.diff; fix_build.log (patched md5 7d86c18229c4456b33c8cdec43eae971); fix_run.log lines 'P1a SHRINK 4096->100 ... contained', 'P1b ... contained', 'P1a frags=3 sent=1024 escaped=0', 'SUMMARY: contained=43 failed=0 latent-primitives=2', RUN_RC=0
↓ fix.diffper-fix-DF-5426

Confirmed kernel references

Detail

Exploit chain

Not reachable in-tree. Demonstrated latent chain (rig only): hostile USB host issues GET_DESCRIPTOR with wLength == descriptor length (multi-fragment data stage) -> fragment 1 delivers 1024 bytes -> provider that returns a smaller length for later fragments makes max_len = len - off wrap (uint16) -> :750/:753 clamps to 1024/rem but not to the object -> usbd_xfer_set_frame_data(:785) aims the controller DMA at src_zcopy+off for 1024 bytes -> OOB read of adjacent kernel memory streamed to the host (leak); the mirror-image ctrl-WRITE path through a lying sub-driver aims host-controlled DMA writes past the driver buffer (heap overflow). Requires a length-shrinking provider, i.e. out-of-tree kernel code - no in-tree trigger exists.

Evidence (decisive lines)

run.log: 'P1a ... ESCAPE 1948/2972/3996 bytes', 'P1b ... ESCAPE 2044/3068/4092 bytes', 'SUMMARY: contained=41 failed=0 latent-primitives=4', RUN_RC=0 (twice); fix_run.log: 'P1a/P1b ... escaped=0', 'SUMMARY: contained=43 failed=0 latent-primitives=2'; build.log/fix_build.log (production md5 a972d0a7efbc45870ba3741ffb92c8a4, patched 7d86c18229c4456b33c8cdec43eae971); VERDICT.md for the full candidate kill list

PoC changes

Seed was a concept only; the rig (neg_usbhr.c) was written from scratch: #includes the production usb_handle_request.c, synthesizes xfer/root/udev/bus with guard-patterned buffers, replicates the usbd_setup_ctrl_transfer control_rem lifecycle, overrides usb_temp_get_desc_p with scripted hostile providers, and drives the real USB_HANDLE_REQUEST kobj dispatch through a bare kobj device. Two harness iterations were needed (lock-discipline for the callback context; completion-fragment and zero-copy classification in expectations).

Verified recommended fix

Guard the offset subtraction in usb_handle_request() tr_valid: 'if (max_len < off) max_len = 0; else max_len -= off;' - behavior-preserving for all conformant providers, turns any provider-length wrap into a clean status-stage termination (see fix.diff).

Verdict

The gadget EP0 engine usb_handle_request() is CONTAINED for every in-tree provider and driver: 41/41 live containment expectations met against the production code (twice, no panic, no guard-zone hits), and the ustorage pass-2 question about 'max_len -= off' (:746) is answered with three independent proofs (exact mod-2^16 cancellation with '*plen += off' at :285 for the driver path; off<=len induction for internal/template paths; the force_short->control_rem=0 feeder always lands rem==0 where :753 clamps). The filed finding is the missing explicit underflow guard at :746: the rig demonstrates on the production code that a provider length that shrinks below the already-transferred offset wraps max_len and yields an out-of-object DMA window of up to ~4 KiB per fragment (P1a: escapes 1948/2972/3996 bytes; P1b: 2044/3068/4092) - a latent OOB DMA read (IN) / write (OUT, P2 via real kobj devmethod dispatch) primitive gated on a contract-violating out-of-tree usb_temp_get_desc_p provider or gadget driver, since all in-tree providers are pure functions of the immutable setup bytes. Info severity / hardening; fix.diff adds the two-line guard and the fix-validated rig shows the primitive gone (contained=43, escaped=0) with all 41 conformant behaviors unchanged.