USB dynamic dispatch unload hooks use a fixed 1-second tsleep as the only grace between live consumers and freed module text
Summary
The unload hooks reset the five global dispatch pointers then 'wait' with tsleep(...,hz) - the code's own 'XXX this is a tradeoff' - while consumers (unprivileged /dev/usbctl quirk GET, probe paths, usbd_free_device on every detach) read and call unlocked: a consumer delayed >1s across the pointer read/call or stuck inside the callee while root unloads resumes into freed module text; the usb_quirk sink additionally runs lockuninit (KKASSERT panic / module-data UAF write) right after the grace. Live-verified: every kldunload took exactly 1.00s (12/12 - the grace is the ONLY protection) and an unpriv uid=1001 spammer pushed 156,989 dispatch calls in ~1s (the consumer rate). 12 load/unload cycles did not hit the window - honest not_reproduced; usb_quirk is compiled into GENERIC (shutdown-only) while usb_template.ko is live-unloadable. global-rwlock quiescence fix (consumers SHARED across read+call, unloads EXCLUSIVE) git-apply-clean.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5656 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.7 KB | β raw | |
| VERDICT.md | β | 7.3 KB | β raw | |
| quirk_get_spam.c | β | 2.2 KB | view raw | |
| race_root.sh | β | 1.4 KB | view raw | |
| build.log | β | 397 B | view raw | |
| run.log | β | 382 B | view raw | |
| run.2.log | β | 622 B | view raw | |
| env.txt | β | 417 B | view raw | |
| fix.diff | β | 4.7 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 4.9 KB | view raw |
DF-5656 β usb_dynamic.c unload "grace period" is a 1s tsleep, not synchronization
File audited (pass 2): sys/bus/u4b/usb_dynamic.c (144 LOC)
What the bug is
usb_quirk_unload / usb_temp_unload / usb_bus_unload
(sys/bus/u4b/usb_dynamic.c:101-144) reset five globally-visible dispatch
pointers (usb_test_quirk_p, usb_quirk_ioctl_p, usb_temp_get_desc_p,
usb_temp_setup_by_index_p, usb_temp_unsetup_p) and then "wait for CPU to
exit the loaded functions" via tsleep(usb_quirk_unload, 0, "WAIT", hz) β a
fixed 1-second sleep that is the only thing standing between an
in-flight consumer and module text that kldunload frees immediately after
the SYSUNINIT returns. The code itself admits it: /* XXX this is a tradeoff */.
Consumers read the pointer and make the indirect call with no lock:
sys/bus/u4b/usb_dev.c:1710βusb_quirk_ioctl_p(USB_DEV_QUIRK_GET is unprivileged: world-readable/dev/usbctl, no caps check on GET)sys/bus/u4b/usb_device.c:2614βusb_test_quirk_p(device probe path)sys/bus/u4b/usb_device.c:1727βusb_temp_setup_by_index_psys/bus/u4b/usb_device.c:2186βusb_temp_unsetup_p(fires fromusbd_free_deviceon every device detach, host side)sys/bus/u4b/usb_request.c:475-495, 1239-1247(viausbd_get_hr_func, gadget side) andsys/bus/u4b/usb_handle_request.c:644
Any consumer preempted between pointer-load and call (or blocked inside the
callee) for >1s while the module unloads calls freed module text (UAF).
For usb_quirk there is a second sink: usb_quirk_uninit
(sys/bus/u4b/quirk/usb_quirk.c:1089-1095) runs
lockuninit(&usb_quirk_lock) right after the grace; DragonFly's production
lockuninit KKASSERTs (panic) if any thread still holds/waits on the lock,
and the lock itself lives in module data (UAF write if the assert is compiled
out).
Threat model / severity
The unloading party must be privileged (kldunload), so this is Low:
root can turn its own module unload into a panic or, theoretically, execution
of freed text. On GENERIC kernels (device usb) usb_quirk.c is compiled in
(sys/conf/files:3028), so its hooks run at shutdown only; usb_template.ko
is the one live-unloadable module (sys/conf/files:3032). On custom
all-modular USB builds the quirk race is live.
Reproduce on the guest
# push quirk_get_spam.c + race_root.sh to the guest, then as root: cd /root/df5656 cc -O2 -o quirk_get_spam quirk_get_spam.c ./race_root.sh 20 8 # quirk side (will show: usb_quirk is IN-KERNEL) # template grace-period measurement (decisive, 12/12 unloads = exactly 1s): i=0; while [ $i -lt 12 ]; do kldload usb_template; u0=$(date +%s); \ kldunload usb_template; u1=$(date +%s); echo "unload=$((u1-u0))s"; i=$((i+1)); done
Expected (and observed): every kldunload usb_template blocks 1.00s β
that second is the tsleep grace period, measured live. kldload usb_quirk
fails with "module already loaded or in kernel" on GENERIC. An unprivileged
uid=1001 process pushed 156,989 dispatch calls through /dev/usbctl in ~1s.
Observed verdict
Corruption/panic not reproduced (window = preempted-between-load-and-call
during a root-initiated unload; not forceable unprivileged, and on this guest
the only live-unloadable module's host-side consumer fires only at bus
detach). The defect itself is certain from code and its 1s grace was measured
live. Full narrative in VERDICT.md.
Fix
fix.diff β replaces the tsleep with a real quiescence barrier: one global
usb_dyn_lock; consumers hold it SHARED across pointer-read and indirect
call (USB_DYN_RLOCK()/USB_DYN_RUNLOCK()); unload hooks take it EXCLUSIVE
around the pointer resets. git apply --check clean against this tree.
DF-5656 β VERDICT
Status: not_reproduced (mechanism proven live; corruption not manifested). Impact: none observed. Finding stands: Low (root-gated race), CWE-367/CWE-416.
1. What was audited
sys/bus/u4b/usb_dynamic.c β the whole file: five core-owned stubs, the six
dynamic dispatch globals, and the three SYSUNINIT unload hooks
(usb_quirk_unload :101-114, usb_temp_unload :116-130, usb_bus_unload
:132-144). The task prompt's guessed symbols (usb_load_one_unload,
usb_peer_restore) do not exist anywhere in sys/ (verified by grep).
2. The defect (code-evident)
All three hooks follow the pattern:
usb_test_quirk_p = &usb_test_quirk_w; /* reset dispatch pointers */
usb_quirk_ioctl_p = &usb_quirk_ioctl_w;
/* wait for CPU to exit the loaded functions, if any */
/* XXX this is a tradeoff */
tsleep(usb_quirk_unload, 0, "WAIT", hz); /* usb_dynamic.c:113,129,143 */
After the SYSUNINIT returns, kldunload (linker_file_unload) frees the
module's text and data. The 1-second tsleep is not a synchronization
primitive: a consumer that loaded the old pointer into a register before the
reset and is then delayed >1s β preempted between pointer read and indirect
call, or blocked >1s inside the callee β resumes and calls/jumps into freed
module text. No wakeup(3) exists for the usb_quirk_unload ident anywhere in
sys/ (grep), so the sleep always burns the full second; nothing re-checks
anything after it.
Unlocked consumer sites (pointer read + indirect call, no barrier, no refcount):
| site | pointer | reachability |
|---|---|---|
| usb_dev.c:1710 | usb_quirk_ioctl_p |
unprivileged: USB_DEV_QUIRK_GET/USB_QUIRK_NAME_GET need no privilege (world-readable /dev/usbctl, no caps check in usb_static_ioctl; only ADD/REMOVE are gated) |
usb_device.c:2614 (usb_test_quirk) |
usb_test_quirk_p |
probe path, explore thread (device hotplug) |
usb_device.c:2186 (usbd_free_device) |
usb_temp_unsetup_p |
every device detach (bus teardown) |
| usb_device.c:1727 | usb_temp_setup_by_index_p |
gadget-side; dead on host-only HW |
| usb_request.c:475-495, 1239-1247 | usb_temp_get_desc_p (via usbd_get_hr_func) |
gadget-side; dead on host-only HW |
Second sink for the quirk module: usb_quirk_uninit (quirk/usb_quirk.c:1089)
calls usb_quirk_unload(arg) then lockuninit(&usb_quirk_lock).
lockuninit (kern/kern_lock.c) carries a production KKASSERT that the lock
has no holders/waiters β a thread still inside usb_test_quirk_by_info
(usb_quirk.c:735, holds usb_quirk_lock across the table scan) or blocked on
it when the grace expires trips the assert β guaranteed panic; with the assert
compiled out, lockmgr on freed module data is a UAF write.
3. Live verification (DragonFly 6.5-DEVELOPMENT #0 Jul 2 2026, X86_64_GENERIC)
usb_quirkis compiled into the GENERIC kernel βsys/conf/files:3028(bus/u4b/quirk/usb_quirk.c optional usb), and live:kldload usb_quirkβ "module already loaded or in kernel" (run.2.log); an unprivileged GET returns live quirk-table data (vid=0x0b05). On this default config the quirk unload hooks run at shutdown only.usb_templateIS live-loadable/unloadable βsys/conf/files:3032(optional usb_template). Decisive measurement (run.log): 12/12kldunload usb_templateiterations took exactly 1s each β that wall-clock second is thetsleep(...,hz)grace period itself, measured. Nothing else in an unload path costs a fixed second.- Unprivileged consumer call-rate established: as uid=1001 via
/dev/usbctl(crw-r--r-- root:operator),USB_DEV_QUIRK_GETdispatch ran 156,989 times in ~1-2s (~10^5/s) β on modular-quirk builds this is the unprivileged side of the two-party race (the privileged side beingkldunload usb_quirk). - Corruption not manifested: no panic across 12 load/unload cycles plus
baseline runs; guest stayed up (
uptimeclean, dmesg clean). Reason: the victim must be delayed >1s between pointer read and call during a root-initiated unload β not forceable by an unprivileged user, and on this guest the only live-unloadable module (usb_template) has no host-side consumer that fires on this event path (its host-reachable consumerusb_temp_unsetup_pfires only during bus/device teardown; the gadget sites are dead code without device-mode controller hardware). Racingkldunload usb_templateagainstkldunload xhci(root vs root) was considered and skipped: nanosecond window, negligible hit probability, and cycling the controller module risks wedging the guest for no evidentiary gain. This is an honest not_reproduced, not a refutation: the defect is structural and its grace period is proven live.
4. Negative results (classes hunted and killed in this file)
usb_devclass_ptrNULL-deref: every consumer is guarded βdevclass_get_deviceNULL-checks (sys/kern/subr_bus.c:950-958);usb_ref_device(usb_dev.c:201-204),usb_destroy_dev(usb_device.c:2029-2035),usb_needs_explore_all(usb_hub.c:2301-2306).- The concurrent first-writer poke
usb_devclass_ptr = devclass_find("usbus")(usb_controller.c:813-814) is benign: idempotent same-value store. - Stub info leak: none β
usb_dynamic.c:67-99stubs return constants; the unsetup stub frees+NULLs (no double-free; core allocator). - Pointer tearing/missing barriers: benign on x86_64 (aligned pointer stores).
- Missing privilege checks in dispatch: none reachable β quirk ADD/REMOVE are
caps_priv_check_self(SYSCAP_NODRIVER)-gated insideusb_quirk_ioctl; GET/NAME_GET expose only the static quirk table (public data). usb_bus_unloadsetsusb_devclass_ptr = NULLβ in-kernel core (sys/conf/files:2865), so shutdown-only on anydevice usbkernel; with NULL-safe consumers this is not triggerable into a deref.- Cosmetic:
usb_temp_unload/usb_bus_unloadsleep on the wrong ident (usb_quirk_unload, usb_dynamic.c:129/143) β no wakeup caller exists. - DF-5394 family (usb_controller.c attach race) not re-reported here.
5. Fix
fix.diff (git apply --check clean, never applied to sys/): one global
struct lock usb_dyn_lock; consumers wrap pointer-read plus indirect call
in USB_DYN_RLOCK()/USB_DYN_RUNLOCK() (shared); the three unload hooks take
the lock EXCLUSIVE around the pointer resets and drop the tsleep entirely β
exclusive acquisition proves no CPU is executing inside the module when the
hook returns. The gadget-side dispatch in usb_handle_request.c:644 is
deliberately NOT wrapped: it has no in-tree caller and would run from callback
context holding the BUS lock, which would introduce a BUSβdyn vs dynβBUS
inversion against usb_request.c; whoever wires that callback must acquire
dyn before BUS at their boundary. Fix validation (patched-kernel rebuild) was
not performed: the finding is Low / root-gated, the corruption itself never
reproduced, so there is no baseline bad behavior to observe disappearing β
fix_status not_testable, honestly.
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
not_testablePatched-kernel validation not performed: the finding is Low/root-gated and the corruption never reproduced on baseline, so there is no observable bad behavior whose disappearance a fix build would prove. fix.diff is coherent and git-apply --check clean; it removes the racy sleep entirely (exclusive lockmgr acquisition proves quiescence), which by construction closes the read-to-call gap and the lockuninit-on-live-lock hazard (usb_quirk.c:1093).
git apply --check fix.diff => APPLIES CLEAN (unmodified sys/ tree); diff hunks cover usb_dynamic.c hooks (tsleep removed, LK_EXCLUSIVE around resets), usb_dynamic.h (USB_DYN_RLOCK/RUNLOCK), and the five consumer dispatch sites.
Confirmed kernel references
- sys/bus/u4b/usb_dynamic.c:101-114
- sys/bus/u4b/usb_dynamic.c:116-130
- sys/bus/u4b/usb_dynamic.c:132-144
- sys/bus/u4b/usb_dev.c:1710
- sys/bus/u4b/usb_device.c:1727
- sys/bus/u4b/usb_device.c:2186
- sys/bus/u4b/usb_device.c:2614
- sys/bus/u4b/usb_request.c:336-341
- sys/bus/u4b/quirk/usb_quirk.c:1089-1095
- sys/conf/files:3028
- sys/conf/files:3032
Detail
Evidence (decisive lines)
run.log: 12x 'iter N: unload=1s' (the tsleep grace period measured live); run.2.log: '[spam] fd=3 (unprivileged uid=1001) ... done: ok=156989' (unprivileged dispatch through usb_quirk_ioctl_p at ~10^5/s) and 'kldload: can't load usb_quirk: module already loaded or in kernel' (quirk compiled into GENERIC); build.log: clean build of the consumer PoC; env.txt: guest uname + kldstat.
PoC changes
Seed PoC did not exist; authored quirk_get_spam.c from scratch. First build failed (ENOIOCTL is a kernel-internal -3 constant, not visible to userland); fixed by classifying the stub path by observed errno. Note: the guest libc's printf %f prints 0.0 for the atof'd duration argument (cosmetic tool quirk, loop duration honored).
Verified recommended fix
Replace the tsleep(1s) grace in usb_quirk_unload/usb_temp_unload/usb_bus_unload with a real quiescence barrier: global lockmgr lock held shared by consumers across pointer-read+call and exclusive by the unload hooks around the pointer resets (see fix.diff).
Verdict
The three SYSUNINIT unload hooks in sys/bus/u4b/usb_dynamic.c:101-144 reset the global USB dispatch pointers and then 'wait' with a fixed tsleep(...,hz) - a 1-second sleep that is the only thing preventing an in-flight consumer (usb_dev.c:1710 unprivileged quirk GET dispatch; usb_device.c:2614/2186/1727; usb_request.c:475-495) that is delayed >1s across the read-to-call gap from executing module text freed by kldunload. The grace period itself was measured live: 12/12 kldunload usb_template iterations each took exactly 1.00s (run.log). On the GENERIC guest usb_quirk is compiled in (sys/conf/files:3028; kldload refused 'already loaded or in kernel'), so the quirk variant is shutdown-only there; usb_template is the live-unloadable module (sys/conf/files:3032). The actual corruption did not manifest: the victim must be preempted >1s between pointer read and indirect call during a root-initiated unload - not forceable unprivileged, and the only live-unloadable module has no firing host-side consumer on this guest. Honest not_reproduced for the corruption; the structural defect is certain from code plus the live grace measurement. Secondary sink for the quirk module: usb_quirk.c:1089-1095 runs lockuninit(&usb_quirk_lock) right after the grace - production KKASSERT panic if a thread still holds/waits on the lock.
No comments yet.