wait_event() family evaluates the condition before tsleep_interlock() and autoremove_wake_function() unlinks entries on wakes that were never delivered: in-window wake is lost, stranded sleeper burns its full timeout and wait_event_timeout() returns 0 (timeout) while the condition is true
Summary
The macro checks the condition BEFORE posting the interlock, and autoremove unlinks on wake_up_process's constant-1 return - a wake landing in the window is dropped AND no later wake can reach the auto-unlinked sleeper: wait_event_timeout returns 0 (timeout) with the condition true. KLD compiling the REAL linux_wait.c: 9/9 windowed rounds burned the full timeout with the condition true, rescue wakes unreachable, 8/8 controls correct; the honest 1.67M-round random-timing negative pins production entry (condition width/preemption, not external timing). Spurious -ETIME/-EAGAIN error paths across drm fence/vblank/gmbus/reset waits. Reorder-interlock + conditional-unlink fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5182 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.7 KB | β raw | |
| VERDICT.md | β | 6.3 KB | β raw | |
| lwqdemo.c | β | 12.1 KB | view raw | |
| Makefile | β | 179 B | β download | |
| build.sh | β | 160 B | view raw | |
| run.sh | β | 133 B | view raw | |
| build.log | β | 6.6 KB | view raw | |
| run.log | β | 3.3 KB | view raw | |
| env.txt | β | 228 B | view raw | |
| fix.diff | β | 1.5 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 4.4 KB | view raw |
DF-5182 β wait_event family lost wakeup (check-before-interlock + unconditional autoremove unlink)
File: sys/dev/drm/linux_wait.c (+ macro in sys/dev/drm/include/linux/wait.h:110-169)
Severity: Medium β spurious timeouts / stalled waits in drm sync paths
Verdict: REPRODUCED (deterministic instantiation, 9/9 windowed rounds)
What this is
A KLD module (lwqdemo.ko) that compiles the real, unmodified
sys/dev/drm/linux_wait.c into the module (the guest kernel does not
include drm) and drives the real wait_event_timeout() macro from
<linux/wait.h> against it.
Build
cd /root/lwqdemo
make SYSDIR=/usr/src/sys # -> lwqdemo.ko (-Werror clean)
lwqdemo.c, Makefile and a pristine copy of sys/dev/drm/linux_wait.c
must be in the directory. See build.sh.
Run
kldload ./lwqdemo.ko
# ... experiments run for ~25 s (8 misreports cap) ...
dmesg | grep LWQ # MISREPORT lines + SUMMARY
kldunload lwqdemo # allowed after SUMMARY printed
See run.sh.
Expected output (decisive lines)
LWQ: MISREPORT #1 (windowed) dt=199 ticks: wait_event_timeout returned 0 (timeout)
but condition gen 1 != 0 is TRUE - the in-window wake was lost and the rescue
wake could not find the auto-removed entry
...
LWQ: ================= SUMMARY =================
LWQ: rounds_done=17 fast=8 control_ok=8 misreports=9 rescues_fired=9
LWQ: verdict: REPRODUCED - ... control rounds behave correctly
- every windowed round (wake delivered between the condition's state
read and
tsleep_interlock) burns its full 2 s timeout and returns 0 ("condition never became true") althoughgenwas bumped before the wake β 9/9 rounds; - a rescue
wake_up()fired ~100 Β΅s into each stranded round and could not reach the sleeper (rescues_fired=9, still misreported) β the wait entry had been unlinked byautoremove_wake_function(); - every control round (wake delivered after the interlock) returned
immediately with
ret > 0β 8/8, proving the harness plumbing and that out-of-window wakes behave correctly.
The log also contains the earlier statistical attempt (same module family, timing-only waker): 1,666,602 rounds, 0 misreports β negative evidence that matters: an external timing-only waker cannot beat the waiter's conditionβinterlock path, so the bug is entered in production via condition width or waiter preemption (see VERDICT.md).
Experiment 2 (shared module) β DF-5183
The same module proves the MAX_SCHEDULE_TIMEOUT truncation:
LWQ-F3: tsleep(timo=(int)MAX_SCHEDULE_TIMEOUT=-1) returned rv=35 after 2 ticks
with NO waker - 'infinite' timeout actually expires every tick
DF-5182 β Verification Verdict
Status: reproduced
Impact: dos (spurious-timeout misreport + stalled wait; per-call, bounded
by the caller's timeout; poll variants self-heal β€1 s late)
Confidence: certain (mechanism deterministically instantiated against the
unmodified in-tree linux_wait.c; real-world entry probability reasoned
below)
The bug
__wait_event_common() (sys/dev/drm/include/linux/wait.h:110-169) checks
the condition before interlocking the sleeper:
while (1) {
__wait_event_prefix(&wq, flags); /* wait.h:125 */
if (condition) /* wait.h:127 */
break;
tsleep_interlock(current, flags); /* wait.h:130 */
ret = tsleep(current, PINTERLOCKED|flags, "lwe", timeout); /* :133 */
A wake_up() (wait.h:67-74 β __wake_up_core() linux_wait.c:49-62 β
autoremove_wake_function() linux_wait.c:37-47) whose wakeup(task)
executes after the condition evaluation but before tsleep_interlock()
finds nobody interlocked and is dropped. Worse,
autoremove_wake_function() unlinks the wait entry because
wake_up_process() (sched.h:229-243) always returns 1 ("Always
indicate the process was woken up") even when it delivered nothing β so
every subsequent wake_up() walks past the stranded sleeper too. The
sleeper burns its whole timeout and wait_event_timeout() returns 0
("condition never became true") although the condition was stored true
before the wake. Linux closes this with the task-state interlock
(prepare_to_wait_event sets state, and schedule() refuses to sleep a
RUNNING task); the DragonFly shim's tsleep(9) ignores
task_struct.state, so that safety net does not translate β only the
tsleep_interlock/PINTERLOCKED pair does, and it is ordered one step
too late.
How it was reproduced
Module lwqdemo.ko compiles the pristine sys/dev/drm/linux_wait.c and
calls the real macro. The wait condition helper models a condition with
non-trivial width between its state read and its return (as production
drm conditions have β e.g. radeon_fence_any_seq_signaled() reads
RADEON_NUM_RINGS atomic64 counters, radeon_fence.c:507-511; i915 uses
wait_event_interruptible_timeout(error->reset_queue, ...) in
i915_gem.c:120). In "windowed" rounds the waker is admitted into that
slot by a window_open/poke handshake and performs exactly the
production protocol: gen++ (condition true) then wake_up().
- 9/9 windowed rounds: first wake lost (delivered between the state
read and the interlock), entry auto-removed, sleeper burned the full
2 s (
dt=199-200ticks), returned 0 whilegen != g0. - 9 rescue wakes fired ~100 Β΅s into the stranded rounds β none could reach the sleeper (entry unlinked). Without the unconditional unlink the rescue would have woken it; that contrast is the autoremove half of the proof.
- 8/8 control rounds (bump+wake after the interlock) returned
immediately with
ret > 0β the harness plumbing is sound and out-of-window wakes work normally.
Negative result that sharpens the impact claim
A pure timing attack (randomized-offset external waker, 100-cycle sweep
steps over 0-30000 TSC cycles, 6-vCPU KVM guest) completed
1,666,602 rounds with 0 misreports (recorded in run.log, first
summary). Reason: the waker's storeβwakeup() delivery pipeline
(lockmgr + list walk + two spinlocks + sleep-queue scan, β³500 ns) is
always slower than the waiter's conditionβinterlock path (β20
instructions), so a same-cost waker cannot land inside a zero-width
window. The window is entered in production when
- the condition expression has real width between reading the awaited state and returning (all non-trivial drm conditions), or
- the waiter is preempted/descheduled between check and interlock (interrupt, IPI, scheduler β ordinary on a loaded GPU box), which widens the window by the handler duration.
Combined with the volume of drm waits (every fence wait, vblank wait,
gmbus transaction, reset wait), lost wakes are an under-load event, not a
laboratory-only one. Consequences: wait_event_timeout() callers take
their timeout error path spuriously β e.g. radeon fence waits report
-ETIME and can trigger needless GPU-reset escalation
(radeon_fence.c:507-535), i915 i915_reset_wait-style users return
-ERESTARTSYS/-EAGAIN to unprivileged render clients (i915_gem.c:120),
intel_dp gmbus waits fail an AUX transfer (intel_dp.c:1137) β plus the
stall itself for the caller's full timeout.
Reachability / threat model
The wait primitives are kernel-internal; unprivileged users reach them through drm ioctls on real GPU hardware (render nodes on some setups, console user otherwise). The QEMU guest has no drm device, so the PoC drives the primitives directly from a root-loaded KLD β the demonstration is of the kernel primitive, while the in-tree call sites above define the production exposure.
Fix
Authoritative fix.diff in this directory (two parts):
- reorder the macro so
tsleep_interlock()precedes the condition check (the standard DragonFly interlock idiom β a wake delivered after the interlock is caught by the sleep-queue interlock or by theTDF_TSLEEPQre-validation insidetsleep()itself, kern_synch.c:668-676); - make
wake_up_process()report whether a wakeup was actually delivered (sched.h), soautoremove_wake_function()only unlinks entries that were really woken (linux_wait.c:43-44).
Not validated by a kernel rebuild (Medium logic/race finding; the diff is kept minimal and mechanical).
Artifacts
lwqdemo.cβ final harness source (sha256 1978c8c3c8887476ea99e9125e41040d68b2484aa4895ca87ab0ad09d9850632)Makefile,build.sh,run.shbuild.logβ full-Werrorbuild of the module incl. pristine linux_wait.c (rc=0)run.logβ full dmesg: statistical run (1.67 M rounds, 0) AND deterministic run (9 misreports, 8 controls, 9 rescues, SUMMARY)env.txtβ guest uname/compiler/vcpus/TSCfix.diff
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_testablefix.diff authored (wait.h reorder + sched.h wake_up_process return + linux_wait.c comment) but not kernel-rebuild-validated; Medium logic/race finding, changes are mechanical.
fix.diff in this pack
Confirmed kernel references
Detail
Exploit chain
unprivileged render client -> drm ioctl -> wait_event_timeout()/wait_event_interruptible_timeout() in kernel -> wake from another CPU lands between condition evaluation and tsleep_interlock (condition has width or waiter preempted) -> wakeup dropped + wait entry auto-removed -> caller's full timeout burned and retval==0 (spurious timeout) or <=1s-stall for poll variants -> caller error paths (fence timeout escalation, reset-wait EAGAIN, gmbus AUX failure). No memory corruption; per-event DoS/functional-error class.
Evidence (decisive lines)
run.log: 9 'MISREPORT (windowed) dt=199 ticks ... gen N != N-1 is TRUE' lines + SUMMARY 'rounds_done=17 fast=8 control_ok=8 misreports=9 rescues_fired=9' + earlier statistical run 'rounds_done=1666602 ... misreports=0'; VERDICT.md full narrative; lwqdemo.c harness (real macro + real linux_wait.c linked in).
PoC changes
Seed concept reworked entirely: harness is a KLD compiling the real linux_wait.c (guest kernel has no drm symbols); v1/v2 used an external timing-swept waker (1.67M rounds, 0 hits - documented as negative evidence that the window needs condition-width/preemption); v3 admits the waker inside the condition-expression window via a window_open/poke handshake, modeling production conditions with non-trivial width, with interleaved control rounds proving correct out-of-window behavior.
Verified recommended fix
Order tsleep_interlock() before the condition check in __wait_event_common (wait.h) and make wake_up_process() return whether a wakeup was delivered so autoremove_wake_function() only unlinks entries that were actually woken.
Verdict
REPRODUCED on the QEMU guest via a KLD (lwqdemo.ko) that compiles the pristine sys/dev/drm/linux_wait.c and drives the real wait_event_timeout() macro. In 9/9 'windowed' rounds where the waker performed the production protocol (store condition true, then wake_up()) inside the slot between the condition's state read and tsleep_interlock(), the wake was dropped, autoremove_wake_function() unlinked the wait entry (wake_up_process() always returns 1), a rescue wake_up() ~100us later could not reach the sleeper, the waiter burned its full 2s timeout and wait_event_timeout() returned 0 ('condition never became true') although gen had been bumped before the wake. 8/8 control rounds (wake after the interlock) returned immediately with ret>0, proving the harness. A pure timing attack over 1,666,602 rounds produced 0 hits - the window is entered in production via condition width (e.g. radeon_fence_any_seq_signaled) or waiter preemption, both ordinary on loaded GPU systems. Impact: spurious timeout error paths and stalled waits in drm sync (radeon fence waits -> needless GPU-reset escalation, i915 reset waits -> -EAGAIN/-ERESTARTSYS to render clients, intel_dp gmbus failures).
No comments yet.