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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5288 Β· 12 files
FileTypeDescriptionSize
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.md
↓ download 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.md
↓ download raw

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

fixed
baseline reproduced→ patch + rebuild →patched clean

With 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
↓ fix.diffper-fix-DF-5288

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).