USB_FS_UNINIT racing USB_FS_START/STOP/COMPLETE on a shared ugen fd: f->fs_xfer array kfree'd/NULLed without priv_lock while consumers snapshot f->fs_xfer[ep_index] unlocked β kernel page-fault/GPF panic (groomable use-after-free)
Summary
ugen_fs_uninit kfrees/NULLs fs_xfer with NO lock while a second thread inside a USB_FS command checks ep_index>=fs_ep_max unlocked, caches xfer=fs_xfer[ep_index] unlocked, then performs a dozen+ copyins (stretchable window) before dereferencing xfer in usbd_transfer_pending - stale once UNINIT freed the array+xfers. Guest-reproduced AS UNPRIV USER, 2/2 fresh boots: Fatal trap 12 (fault VA 0x130) and type-9 GPF at usbd_transfer_pending+0x3c, guest dead in DDB. The faulting pointer loads from freed 8*N-byte M_USB heap the same user sprays via repeated USB_FS_INIT - groomable UAF (forged usb_xfer -> transfer_start writes through xroot chain), escalation chain documented not completed. Detach-under-lock + revalidate-under-lock fix, validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5288 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5288.c | β | 3.5 KB | view raw | |
| build.sh | β | 88 B | view raw | |
| run.sh | β | 144 B | view raw | |
| run.log | β | 970 B | view raw | |
| panic.txt | β | 837 B | view raw | |
| panic_run2.txt | β | 460 B | view raw | |
| env.txt | β | 644 B | view raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| fix.diff | β | 5.8 KB | view raw | |
| fix_validation.md | β | 1.6 KB | β raw | |
| fix_build.log | β | 936 B | view raw | |
| run.patched.log | β | 49 B | view raw |
VERDICT β DF-5288
Status: REPRODUCED (unprivileged local user, fresh-boot guest) β
reproduced, impact panic (reliable kernel crash; the faulting pointer
is read from freed heap memory, so this is a groomable UAF, not just a NULL
deref).
Reproduction
Guest: DragonFly 6.5-DEVELOPMENT (with-src snapshot), QEMU x86_64,
-device usb-ehci + -device usb-mouse (ugen0.2; control-endpoint node
/dev/usb/0.2.0). Unprivileged user maxx (uid 1001, operator group,
0660 nodes β the standard libusb-access configuration).
./df5288 β thread A hammers USB_FS_START (control-endpoint fs transfer:
GET_STATUS, nFrames=2), the main thread cycles USB_FS_UNINIT β
USB_FS_INIT β USB_FS_OPEN.
Run 1 (fresh boot #1)
Fatal user address access from kernel mode from df5288 at ffffffff80a5438c Fatal trap 12: page fault while in kernel mode cpuid = 1; lapic id = 1 fault virtual address = 0x130 fault code = supervisor read data, page not present instruction pointer = 0x8:0xffffffff80a5438c current process = 931 (df5288) Stopped at usbd_transfer_pending+0x3c: movq 0x130(%rax),%rdi
Run 2 (fresh boot #2, snapshot-reverted guest, re-built binary)
kernel: type 9 trap, code=0 current process = 894 (df5288) Stopped at usbd_transfer_pending+0x3c: movq 0x130(%rax),%rdi
Both runs: guest dead in DDB, needs hard reset.
usbd_transfer_pending spans 0xffffffff80a54350.. (+0x3c = ...38c β).
The only caller reachable from this ioctl mix is
ugen_fs_copy_in() at sys/bus/u4b/usb_generic.c:1056 β its xfer local
was snapshotted from f->fs_xfer[ep_index] (usb_generic.c:1051) with no
lock, while ugen_fs_uninit() (usb_generic.c:978-981) concurrently
executed usbd_transfer_unsetup(f->fs_xfer, f->fs_ep_max); kfree(f->fs_xfer,
M_USB); f->fs_xfer = NULL; f->fs_ep_max = 0; β also with no lock.
rax in the faulting movq 0x130(%rax),%rdi is the stale xfer->xroot
load: the pointer chain runs through memory that UNINIT already freed
(the 8*N-byte fs_xfer array β an attacker-sprayable kmalloc β and/or the
xfer block itself). Run 1 faulted at VA 0x130 (NULL xroot read out of
zeroed freed memory); run 2 took a GPF on the same instruction (garbage
pointer). With heap grooming of the freed array (any 8..1016-byte M_USB
allocation pattern, e.g. repeated USB_FS_INIT), the stale xfer value is
attacker-chosen and usbd_transfer_start() would then write through
xfer->xroot->bus/flags_int β a write primitive chain, not pursued here
beyond the demonstrated crash.
Why the window is trivially winnable
ugen_fs_copy_in() performs one copyin() per frame length plus one per
buffer pointer plus the endpoint struct itself β a dozen-plus separate
fault-capable copyins between the unlocked f->fs_ep_max check at
usb_generic.c:1048 and the f->fs_xfer[ep_index] re-read at
usb_generic.c:1429 β while ugen_fs_uninit() needs just one unlocked
kfree() to land inside it.
Fix validation
See fix_validation.md / fix_build.log / run.patched.log β with
fix.diff (array detached under priv_lock in ugen_fs_uninit() +
locked revalidation in ugen_fs_copy_in/out, USB_FS_START,
USB_FS_STOP) applied to a rebuilt kernel, the exact same PoC survives
(the "survived N uninit cycles" path) where the unpatched kernel panicked
within seconds.
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 validation β DF-5288 (and combined with DF-5287)
Baseline (unpatched with-src kernel)
| run | result |
|---|---|
| #1 (fresh boot) | Fatal trap 12, VA=0x130, usbd_transfer_pending+0x3c, guest dead in DDB (panic.txt) |
| #2 (fresh boot, rebuilt binary) | kernel: type 9 trap, same RIP, guest dead (panic_run2.txt) |
The PoC never survived more than ~60 s unpatched.
Patched kernel (fix.diff applied to sys/bus/u4b/usb_generic.c)
Build: make -j 6 nativekernel KERNCONF=X86_64_GENERIC β BUILD_RC=0
(-Werror clean), make installkernel β INSTALL_RC=0, booted #1: Fri Oct
2 05:05:50 UTC 2026 (fix_build.log).
| check | result |
|---|---|
./df5288s (3000 uninit cycles) |
survived 3000 uninit cycles, rc=0 (run.patched.log) |
timeout 300 ./df5288 |
no panic, guest alive (rc=124 = timeout kill) |
FS functional smoke (fssmoke) |
FS_INIT β FS_OPEN β 5Γ (FS_START + FS_COMPLETE, first control transfer status=0) β FS_UNINIT, all complete, no hang |
Fix iteration note (honesty)
The FIRST generated version of the fix had a lock-balance mistake: early
goto complete paths in ugen_fs_copy_out() returned with priv_lock
still held, so the next ioctl panicked with
panic: lockmgr: locking against myself (trace: ugen_ioctl β
lockmgr_exclusive Γ2) β caught by the FS smoke test during validation.
The corrected fix.diff in this pack releases priv_lock exactly once on
every exit path (done_locked: / guarded complete:) and also preserves
the original "returns holding priv_lock" contract of ugen_transfer_setup()
for the DF-5287 half. The corrected kernel passed all checks above.
Fix verification
fixedWith fix.diff applied to a rebuilt -Werror kernel (BUILD_RC=0, INSTALL_RC=0, kern.version #1 Fri Oct 2 05:05:50 UTC 2026), the identical PoC survives 3000 uninit cycles (rc=0, 'survived 3000 uninit cycles') plus a 300 s hammer with no panic, and the USB_FS ioctl path remains fully functional (smoke test: INIT/OPEN/5x START+COMPLETE/UNINIT all succeed, first control transfer status=0). Baseline panicked in under 60 s on 2/2 fresh-boot runs.
fix_validation.md, run.patched.log, fix_build.log
Confirmed kernel references
Detail
Exploit chain
unpriv user opens ugen control node -> USB_FS_INIT + USB_FS_OPEN -> thread A hammers USB_FS_START (ugen_fs_copy_in caches xfer = f->fs_xfer[ep] with no lock, then performs a dozen+ copyins) while thread B issues USB_FS_UNINIT (kfree(f->fs_xfer) + NULL, no lock) -> A resumes with stale xfer -> usbd_transfer_pending() dereferences freed/garbage pointer -> kernel page fault / GPF -> panic (groomable UAF: forge the freed array slot -> usbd_transfer_start writes through the forged chain)
Evidence (decisive lines)
panic.txt (trap 12, RIP usbd_transfer_pending+0x3c, VA 0x130), panic_run2.txt (trap 9, same RIP), run.log, VERDICT.md, fix_validation.md, run.patched.log ('survived 3000 uninit cycles'), fix.diff (git-apply clean)
PoC changes
PoC written fresh for this finding (no seed). Uses the control endpoint (ep_no 0) with a GET_STATUS request so every USB_FS_START completes immediately and the race window is re-armed continuously without device traffic.
Verified recommended fix
Detach the fs_xfer array under priv_lock in ugen_fs_uninit() before freeing, and revalidate fs_ep_max/fs_xfer under priv_lock in ugen_fs_copy_in/out and the USB_FS_START/STOP ioctl paths (see fix.diff).
Verdict
REPRODUCED twice on fresh-boot guests as unprivileged user maxx (uid 1001, operator group, 0660 ugen nodes): ugen_fs_uninit() kfrees f->fs_xfer and NULLs it / fs_ep_max without priv_lock while a concurrent USB_FS_START on the same fd snapshots f->fs_xfer[ep_index] unlocked inside ugen_fs_copy_in() (the copyin-per-element loop stretches the window arbitrarily). Kernel dies at usbd_transfer_pending+0x3c (movq 0x130(%rax),%rdi) reading through the stale pointer: run 1 Fatal trap 12 fault VA 0x130 (current process = df5288), run 2 type 9 GPF at the same RIP; both guests frozen in DDB. The stale pointer is read out of freed 8*N-byte M_USB heap (attacker-sprayable via repeated USB_FS_INIT), so beyond the demonstrated crash this is a groomable use-after-free; uid0 escalation was not pursued within budget. Fix (array detached under priv_lock + locked revalidation of every consumer) validated on a rebuilt kernel: the identical PoC survives 3000 uninit cycles plus a 300 s hammer with zero panics, and the FS ioctl path stays fully functional (FS_INIT/OPEN/START/COMPLETE/UNINIT smoke test).
No comments yet.