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)
PoC verification
Evidence pack
findings/poc/DF-5426 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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):
:746 max_len -= offunderflow (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, somax_len = (plen + off) - off β‘ plen (mod 2^16)β exact cancellation, for any driver, however lying. Rig: D01-D04 (conformant) and P2 (lying 1024β300) confirmmax_len == plen. * Internal paths (GET_CONFIGlen 1:654,GET_STATUSlen 2:672,GET_EP_STATUSlen 2:736):offadvances only by bytes previously delivered, eachβ€ max_len = len - off; henceoff β€ lenby 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 pushoffpast the provider length βforce_short_xferzeroingcontrol_rem(usb_transfer.c:1616) β forcesrem == 0, and theif (max_len > rem)clamp (:753) zeroesmax_len. Rig I02/I06/T02/T03/P1c.:778-792copy/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_intherefore 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_INTERFACEis served zero-copy from&iface->alt_index(:339) β 1 byte, contained (rig F03).- Uninitialized stack reads.
tempunion fully initialized on every read path (:652,:670,:732-734); completion-state gating (:543,:741) prevents stale reads. Rig E01-E03. - 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. - Broadcast iface iteration (
:295-297):usbd_get_ifaceis bounds-checked (usb_device.c:2237-2244);iface_indexisuint8_tand the loop ends at the first NULL/idesc-NULL iface. Rig F06 (walks 2 valid + stops at idesc==NULL), F04/F05 (wIndex 3/255). - SET_INTERFACE/alt, endpoint halts, set-address, set-config:
alt validated against
usbd_get_no_altsover the kernel-built config (:193,:314-329, rig F01/F02);usbd_get_ep_by_addrmasked and bounded (usb_device.c:177-217);usbd_set_endpoint_stallNULL-ep-safe (usb_device.c:976-986, rig I13-I15); addressβ₯ 0x80stalled 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). - Scratch/template lifecycle: descriptors live in the
udev->usb_template_ptrheap block / static string tables; freed only fromusb_free_deviceafterusbd_transfer_unsetupof the ctrl xfers (usb_device.c:2183β2186) β no host-reachable UAF window. Vendor/string providers are pure functions ofreq(usb_template_msc.c:173-193, usb_template_mouse.c:β¦, modem NULL). - Stale zero-copy frame pointers: both frame buffers are reset to
local_buffer+{0,8}on every callback (:507-508). - 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 nowescaped=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 whenmax_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/:753clamps remain the only bound.
Rig-harness notes (honesty section)
- The rig models the callback context: xfer lock held across
usb_handle_request()(theusb_handle_set_stall/usb_handle_iface_requestunlock/relock paths give it back held). control_remlifecycle replicated exactly (init-once-from-wLength,len<=control_remassert,force_short β 0); a mismatch would have printedBOOKKEEPING 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
fixedRig-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
Confirmed kernel references
- sys/bus/u4b/usb_handle_request.c:746
- sys/bus/u4b/usb_handle_request.c:285
- sys/bus/u4b/usb_handle_request.c:750
- sys/bus/u4b/usb_handle_request.c:753
- sys/bus/u4b/usb_handle_request.c:785
- sys/bus/u4b/usb_handle_request.c:644
- sys/bus/u4b/usb_transfer.c:1548
- sys/bus/u4b/usb_transfer.c:1608
- sys/bus/u4b/usb_transfer.c:1616
- sys/bus/u4b/usb_dynamic.h:48
- sys/bus/u4b/gadget/g_audio.c:544
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.
No comments yet.