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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5182 Β· 12 files
FileTypeDescriptionSize
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") although gen was 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 by autoremove_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
VERDICT.md
↓ download raw

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-200 ticks), returned 0 while gen != 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

  1. the condition expression has real width between reading the awaited state and returning (all non-trivial drm conditions), or
  2. 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):

  1. 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 the TDF_TSLEEPQ re-validation inside tsleep() itself, kern_synch.c:668-676);
  2. make wake_up_process() report whether a wakeup was actually delivered (sched.h), so autoremove_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.sh
  • build.log β€” full -Werror build 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/TSC
  • fix.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_testable
baseline reproduced→ patch + rebuild →patched clean

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

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