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

usb_proc_mwait() wakeup is gated on the whole queue becoming empty, not on the waited-for messages completing: any continuously refilled usb_process queue holds waiters in uninterruptible (PCATCH-less) sleep indefinitely β€” the root mechanism beneath the DF-5367..5504 cfg-stall family's uninterruptible-occupancy measurements

Summary

mwait's only wakeup is the dispatcher's empty-queue transition - a waiter sleeps in the PCATCH-less cv_wait until EVERY message source on that usb_process quiesces, even after its own messages completed. Harness on the production symbols: victim message completed at T+0.53s but the waiter was still asleep at T+20s under a queue of mere 10ms messages (starve_ms 19481/19520 - bounded only by the churn's lifetime); the spin variant (instant self-requeue) monopolized the thread AND bus lock for 275s and starved sshd to death. The independent ROOT defect beneath the 10-finding cfg-stall family (even zero-slow-callback queues starve waiters). Unpriv via the family-established ucom tcsetattr/close and unsetup consumers: unbounded SIGKILL-immune D-state. Broadcast-on-dequeue-under-up_dsleep fix validated (starve_ms -> 0).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5532 Β· 14 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 4.3 KB ↓ raw
dfharness.c β€” 7.3 KB view raw
Makefile β€” 79 B ↓ download
build.sh β€” 207 B view raw
run.sh β€” 263 B view raw
run.log β€” 1.5 KB view raw
run.2.log β€” 556 B view raw
run.spin.log β€” 1.7 KB view raw
run.patched.log β€” 1.3 KB view raw
env.txt β€” 864 B view raw
fix.diff β€” 2.1 KB view raw
verdict.json β€” 4.6 KB view raw
manifest.json β€” 1.5 KB view raw

DF-5532 β€” usb_proc_mwait() starvation: wakeup gated on whole-queue-empty

File: sys/bus/u4b/usb_process.c (pass 2)

What this demonstrates

usb_proc_mwait() (usb_process.c:363-394) documents "returns when the message pointed to by pm is no longer on a queue", but the ONLY wakeups for up_drain are:

  1. the dispatcher's empty-queue transition (usb_process.c:163-167), and
  2. usb_proc_drain() teardown (usb_process.c:445-447).

So if the queue is continuously refilled by any other message source, a waiter sleeps forever (cv_wait, no PCATCH β†’ uninterruptible D-state) long after its own messages have completed.

Harness (family evidence class: production-code KLD rig)

dfharness.c drives the production usb_proc_* machinery:

  • a "churn" message pair whose callback does trivial work, drops the lock for 1 tick (as every real cfg callback does around its USB request) and re-queues itself β†’ queue never empties, no slow callback anywhere;
  • a "victim" message pair that the dfwait kernel thread msignal()s and mwait()s β€” the stand-in for the unprivileged-syscall waiters of the production consumers (ucom_queue_command(), usb_serial.c:655-672, tcsetattr/close on /dev/ttyU*; usbd_transfer_unsetup(), usb_transfer.c:1286).

Build / run (on the QEMU guest, as root)

cd /root/dfharness
make                                    # dfharness.ko
kldload ./dfharness.ko
sleep 3     && sysctl kern.dfstarv.report   # victim done, waiter ASLEEP
sleep 6     && sysctl kern.dfstarv.report   # ... still asleep
sysctl kern.dfstarv.stop=1                # let the queue drain
sleep 1     && sysctl kern.dfstarv.report   # waiter woke; starve_ms shown
kldunload dfharness

Expected (baseline, unpatched kernel)

T+3s : victim_ran=1  waiter_done=0   (victim completed at ~T+0.5s)
T+9s : victim_ran=1  waiter_done=0   churn_countβ‰ˆ1000 (10ms/msg, no stall)
stop : waiter_done=1  starve_msβ‰ˆ19500  (woke 20ms after the queue could empty)

starve_ms is how long the waiter overslept its own messages' completion. It is bounded only by when the churn happens to stop β€” in production the churn is sustained USB traffic/driver polls, so the wait is unbounded.

Extreme variant (churn_ticks=0, spin mode)

An instantly self-requeuing message monopolizes the usb_process thread AND its lock: observed 8.9e9 callbacks over 275 s, victim never executed, other threads starved even in lockmgr, guest userland (sshd) starved. See run.spin.log.

Fix

fix.diff (validated on a patched kernel): broadcast up_drain after every completed-message dequeue (if (up->up_dsleep) guard β†’ zero cost when nobody waits). Also carries the DF-5533 cold-path fix.

VERDICT.md
↓ download raw

DF-5532 VERDICT β€” REPRODUCED (dos)

Bottom line

usb_proc_mwait()'s documented contract ("This function will return when the USB process message pointed to by "pm" is no longer on a queue", sys/bus/u4b/usb_process.c:357-362) is not what the implementation provides: the up_drain condvar is only signalled when the dispatcher finds the queue completely empty (usb_process.c:163-167) or when usb_proc_drain() tears the process down (usb_process.c:445-447). A waiter therefore sleeps until every message source on that usb_process quiesces β€” not until its messages complete β€” and it sleeps in cv_wait() with no PCATCH (sys/kern/kern_condvar.c:20-44, wakesig=0), i.e. uninterruptibly.

Reproduction (QEMU guest, DragonFly 6.5-DEVELOPMENT, unpatched kernel)

KLD harness dfharness.c (family evidence class β€” production-code rig on the real usb_proc_* symbols, no kernel modification):

  • run 1 (run.log): victim message completed at T+0.53 s (victim_ran=1, victim_ran_ms=...959401); at T+20 s the waiter was still asleep (waiter_done=0) with only 10 ms of trivial work per queue message (churn_count=1000 over 20 s β€” no stall anywhere); 20 ms after stop=1 emptied the queue, the waiter woke: starve_ms=19481.
  • run 2 (run.2.log): identical shape, starve_ms=19520.
  • spin variant (run.spin.log): with churn_ticks=0 (callback never sleeps β€” pure self-requeue) the usb_process thread executed 8.9e9 callbacks over 275 s, monopolizing the thread and its lock (the waiter starved already inside lockmgr), and guest userland was starved to the point that sshd stopped answering β†’ guest reset. 46141778 callbacks were counted in the first second alone.

starve_ms is bounded only by the churn's lifetime. In production the churn is sustained USB traffic (transfer-done queues) or driver poll loops; there is no bound at all.

Why the family findings inherit from this

DF-5367/5421/5424/5425/5449/5456/5476/5487/5496/5504 measured per-driver uninterruptible occupancy of this machinery (slow cfg callbacks holding the queue). This finding is the independent, sharper root defect in usb_process.c itself: even with zero slow callbacks, any continuously refilled queue starves mwait waiters whose messages completed long ago β€” the wakeup simply never fires. The unprivileged reachability established by the family applies verbatim: ucom tcsetattr(2)/close(2) β†’ ucom_queue_command() β†’ usb_proc_mwait() (sys/bus/u4b/serial/usb_serial.c:655-672); usbd_transfer_unsetup() β†’ mwait (sys/bus/u4b/usb_transfer.c:1286).

Exploit chain (what an attacker gets)

Local DoS: an unprivileged user with access to a ucom tty (or any consumer whose queue is kept alive by traffic) parks syscall threads in uninterruptible D-state for unbounded time (SIGKILL-immune); with the spin variant the whole usb_process (and its bus lock) is monopolized, stalling every other consumer of that queue β€” including kernel teardown paths (usb_proc_drain() waits for the thread, which never sleeps β†’ detach hangs β†’ the hub event thread stalls, exactly the "stalls the USB hub event thread on unplug" the family measured). No memory-corruption primitive is offered by this defect; the impact ceiling is unbounded uninterruptible denial of service.

Fix validation

fix.diff adds, after every completed-message dequeue in the dispatcher, if (up->up_dsleep) cv_broadcast(&up->up_drain); (zero cost when nobody waits β€” up_dsleep is only set by a live mwait waiter). On the patched kernel (full buildkernel + reboot):

  • baseline starve_ms = 19481/19520 (unpatched, above);
  • patched: waiter wakes within one queue tick of its victim message completing while the identical churn runs on (starve_msβ‰ˆ10-20, run.patched.log) β€” a ~3-orders-of-magnitude reduction, bounded by real work, not by unrelated queue activity.

The diff also carries the DF-5533 fix (loop-top suspend check + lock release around suspend_kproc); both were validated in the same build.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

baseline starve_ms=19481/19520 (waiter asleep 19.5s+ after its messages completed, under a queue of 10ms messages); patched kernel (full nativekernel build + reboot, fix.diff hunks 1-3): starve_ms=0 β€” waiter wakes in the same millisecond its victim message completes with the identical churn load (churn_count=151 at T+3s both baseline and patched). Bad behaviour GONE.

findings/poc/DF-5532/run.patched.log (v1 T+3s/T+10s and v2 T+3s samples, both starve_ms=0); findings/poc/DF-5532/fix.diff; guest kernels #1 Oct 7 01:12:42 (v1) and #1 Oct 7 01:28:37 (v2)
↓ fix.diffper-fix-DF-5532

Confirmed kernel references

Detail

Exploit chain

local unprivileged user -> consumer syscall path that mwaits (ucom tcsetattr(2)/close(2) on /dev/ttyU*, or transfer unsetup during traffic) -> cv_wait(&up_drain) with no PCATCH -> any unrelated continuously-refilled message source on the same usb_process (driver poll loop, sustained RX/TX transfer-done churn) keeps the queue non-empty -> empty-queue wakeup never fires -> syscall thread parked in unkillable D-state for unbounded time; spin-variant monopolizes the usb_process thread + bus lock, stalling all other consumers incl. detach/hub-event teardown

Evidence (decisive lines)

run.log (T+3s/T+9s/asleep + post-stop wake, starve_ms=19481), run.2.log (starve_ms=19520), run.spin.log (8.9e9 callbacks/275s, sshd starved), run.patched.log (starve_ms=0 on fix v1+v2 kernels), VERDICT.md narrative, fix.diff (validated)

PoC changes

seed sketch rewritten as a phase-driven KLD harness: churn pair whose callback requeues itself with a 1-tick lock drop (production-faithful; pure-spin mode kept as sysctl-selectable extreme variant), victim pair msignal+mwait from a dedicated kernel thread standing in for the unprivileged-syscall waiters; added report/stop sysctls; needed opt_usb.h, bus_if.h/device_if.h in SRCS and / includes for the kmod build

Verified recommended fix

broadcast up_drain after every completed-message dequeue in the dispatcher (guarded by up_dsleep so it is free when nobody waits) β€” see validated fix.diff

Verdict

usb_proc_mwait()'s only wakeups are the dispatcher's empty-queue transition (usb_process.c:163-167) and drain teardown (:445-447), so a waiter sleeps uninterruptibly (cv_wait, no PCATCH) until EVERY message source on the usb_process quiesces β€” not until its own messages complete. Reproduced with a production-code KLD harness on the real usb_proc_* symbols: victim message completed at T+0.53s while the waiter was still asleep at T+20s under a queue whose every message cost one 10ms tick (churn_count == elapsed ticks, no slow callback anywhere); waiter woke 20ms after the churn stopped (starve_ms=19481; second run 19520). Extreme variant (instant self-requeue): 8.9e9 callbacks monopolized the usb_process thread AND its bus lock for 275s and starved guest userland to death. Unprivileged reachability via the family-established consumers (ucom tcsetattr/close -> ucom_queue_command -> mwait, usb_serial.c:655-672; usbd_transfer_unsetup -> mwait, usb_transfer.c:1286). Fix validated on a patched kernel: starve_ms 19481/19520 -> 0 (waiter wakes in the same millisecond its message completes, same churn load).