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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5656 Β· 11 files
FileTypeDescriptionSize
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:

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.

VERDICT.md
↓ download raw

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)

  1. usb_quirk is 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.
  2. usb_template IS live-loadable/unloadable β€” sys/conf/files:3032 (optional usb_template). Decisive measurement (run.log): 12/12 kldunload usb_template iterations took exactly 1s each β€” that wall-clock second is the tsleep(...,hz) grace period itself, measured. Nothing else in an unload path costs a fixed second.
  3. Unprivileged consumer call-rate established: as uid=1001 via /dev/usbctl (crw-r--r-- root:operator), USB_DEV_QUIRK_GET dispatch 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 being kldunload usb_quirk).
  4. Corruption not manifested: no panic across 12 load/unload cycles plus baseline runs; guest stayed up (uptime clean, 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 consumer usb_temp_unsetup_p fires only during bus/device teardown; the gadget sites are dead code without device-mode controller hardware). Racing kldunload usb_template against kldunload 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_ptr NULL-deref: every consumer is guarded β€” devclass_get_device NULL-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-99 stubs 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 inside usb_quirk_ioctl; GET/NAME_GET expose only the static quirk table (public data).
  • usb_bus_unload sets usb_devclass_ptr = NULL β€” in-kernel core (sys/conf/files:2865), so shutdown-only on any device usb kernel; with NULL-safe consumers this is not triggerable into a deref.
  • Cosmetic: usb_temp_unload/usb_bus_unload sleep 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_testable
baseline no→ patch + rebuild →patched clean

Patched-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.
↓ fix.diffper-fix-DF-5656

Confirmed kernel references

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.