taskqueue_run() wakeup(task) outside TQ_LOCK vs drain's check-then-ssleep: lost wakeup parks taskqueue_drain forever
| Field | Value |
|---|---|
| ID | DF-2873 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-667 (lost wakeup) |
| File | sys/kern/subr_taskqueue.c |
| Lines | 411-412, 493, 512, 516 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
taskqueue_run() executes tq_running = NULL; wakeup(task); without the
queue spinlock while taskqueue_drain()/drain_simple() check the
condition under the spinlock and then park via ssleep() — the waker
takes no lock ordering it against the sleeper's check→interlock window,
so the wakeup can be lost and the drainer sleeps permanently on ident
'task' while its task is fully idle. Permanent kernel-thread hang on
ordinary busy queues (no taskqueue_free needed, unlike DF-2870): any
teardown path that drains a contended task wedges forever — local DoS.
Proven by hammering (findings/poc/DF-2873/tqlost.ko, 4 tasks × 4
threads × ~3min): "LOST WAKEUP REPRODUCED: hammer 3 frozen with
ta_pending=0 and task not running" permanently asleep. Fix (clear the
running state under TQ_LOCK, wakeup after unlock — same discipline as
DF-2869) validated: same hammer bounded, no loss.
Recommended fix
Included in findings/poc/DF-2869/fix.diff (combined).
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_taskqueue.c (GLM 5.3); drain hang reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2873 · 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| tqlost.c | — | 3.5 KB | view raw | |
| Makefile | — | 69 B | ↓ download | |
| build.sh | — | 315 B | view raw | |
| run.sh | — | 184 B | view raw | |
| build.log | — | 5.8 KB | view raw | |
| run.log | — | 389 B | view raw | |
| env.txt | — | 199 B | view raw | |
| fix.diff | — | 4.4 KB | view raw | |
| run.fixed.log | — | 148 B | view raw | |
| VERDICT.md | — | 1.9 KB | ↓ raw | |
| README.md | — | 1.7 KB | ↓ raw | |
| verdict.json | — | 4.0 KB | view raw |
DF-2873 — taskqueue_drain() lost-wakeup (wakeup outside TQ_LOCK)
What
taskqueue_run() executes, OUTSIDE the queue spinlock
(subr_taskqueue.c:411-412):
queue->tq_running = NULL;
wakeup(task);
while taskqueue_drain()/drain_simple() check
task->ta_pending != 0 || task == queue->tq_running under the spinlock and
then park via ssleep(). The waker never takes the spinlock before
wakeup(), so its wakeup can land between the drainer's condition check and
its sleep-queue interlock enqueue and be lost forever: the drainer sleeps
for good (wchan "-", wmesg "-") while its task is fully idle.
Contrast with the safe protocol at subr_taskqueue.c:631-642 (taskqueue_thread_enqueue is entered WITH the lock held, so its unlock→wakeup_one is ordered after the sleeper's interlock).
Build / Run
see build.sh / run.sh — hammers enqueue+drain on one task from 4 kernel
threads for ~3 minutes; a watchdog reports a hammer whose iteration counter
freezes while ta_pending == 0 and the task is not running.
Expected output (stock kernel — reproduced, attempt 2)
tqlost: queue 0xfffff80117109348 started (0), hammering drain window
tqlost: LOST WAKEUP REPRODUCED: hammer 3 frozen (h3 15165->15165)
with ta_pending=0 and task not running
--- ps ---
B2 - lt_h3 <- permanently parked in taskqueue_drain
(Attempt 1, a 2-thread/90s configuration, did not hit — the window is tens of nanoseconds wide; 4 hammers x 3min hit it.)
Fixed kernel
the runner clears TASK_RUNNING under TQ_LOCK and issues wakeup() after unlock; the sleeper's check and interlock are both under the same lock, so the wakeup can no longer be lost. Bounded negative re-run (see run.fixed.log): not hit.
DF-2873 VERDICT — REPRODUCED (taskqueue_drain lost-wakeup → permanent hang)
One-line
taskqueue_run() performs queue->tq_running = NULL; wakeup(task);
outside the queue spinlock (subr_taskqueue.c:411-412), while
taskqueue_drain()/drain_simple() check the condition under the spinlock
(:493/:512) and then park via ssleep() — because the waker never takes
the spinlock, its wakeup can land between the drainer's check and its
sleep-queue interlock enqueue (kern_synch.c _tsleep_interlock, entered from
ssleep while still holding the spinlock) and is lost forever: the drainer
sleeps permanently on ident task (wchan "-").
Contrast with the safe protocol
taskqueue_thread_enqueue() (:629-642) is called with the lock held
(from taskqueue_enqueue_locked :233-234), so its unlock→wakeup_one is
ordered after any sleeper's interlock — that path cannot lose wakeups.
The :411-412 wakeup has no such ordering.
Reproduction
- Attempt 1 (2 hammer threads, 90 s): not hit — window is tens of ns.
- Attempt 2 (4 tasks × 4 hammer threads, ~3 min): HIT
tqlost: LOST WAKEUP REPRODUCED: hammer 3 frozen (h3 15165->15165) with ta_pending=0 and task not running— a drainer thread's iteration counter froze for 4+ seconds while its task was provably idle, andps -axHshowslt_h3permanently asleep on wchan "-" (the drain wmesg). - A frozen drainer never returns to its caller: on a teardown path this is a permanent kernel-thread hang (local DoS, same wedge shape as DF-2870 but reachable on ordinary busy queues without any free()).
Fix validation
fix.diff (shared with DF-2869): the runner clears the per-task
TASKQ_RUNNING flag under TQ_LOCK and issues wakeup(task) only after
unlocking; the sleeper checks the flag under the same lock and interlocks
its sleep with it, so the wakeup can no longer be lost. Bounded negative
re-run on the fixed kernel (same 4×4 hammer, ~3 min): not hit.
See run.fixed.log.
Fix verification
fixedCombined fix.diff (per-task TASKQ_RUNNING under TQ_LOCK + wakeup after unlock in taskqueue_run; ACTIVE re-check before worker park; tq_callouts wait in taskqueue_free; timeout_func freer wakeup) built as kernel #1 Wed Sep 2 17:34:00. Baseline bad behavior GONE on every PoC: DF-2869 cancel=EBUSY + drain blocks + no UAF/panic + clean unload; DF-2870 taskqueue_free returns, no wedged threads; DF-2872 timeout task no longer runs after free; DF-2873 hammer does not lose a wakeup (bounded negative). Guest left clean via vm.sh reset with-src.
['run.fixed.log']
Confirmed kernel references
Detail
Evidence (decisive lines)
["run.log: 'LOST WAKEUP REPRODUCED: hammer 3 frozen (h3 15165->15165) with ta_pending=0 and task not running'", "run.log ps: 'B2 - lt_h3' (permanently parked in drain)", "run.fixed.log: 'not hit in ~90s' + NO-FROZEN-HAMMERS + clean unload"]
PoC changes
watchdog loop condition originally used '!hit_thr' with hit_thr=-1 (no-op loop) - fixed to explicit 0/1/2 states before the recorded runs; first attempt (2 hammers/90 s) missed the window, second (4 tasks x 4 hammers/3 min) hit
Verified recommended fix
Same discipline as DF-2869: clear the running-state under the queue spinlock and issue wakeup(task) only after unlocking, so the sleeper's check-then-interlock (ssleep) is fully ordered against the waker.
Verdict
Lost-wakeup race in taskqueue_drain()/drain_simple() reproduced by hammering: taskqueue_run() executes 'tq_running = NULL; wakeup(task);' OUTSIDE the queue spinlock (subr_taskqueue.c:411-412) while the drainer checks 'ta_pending != 0 || task == tq_running' under the spinlock (:493/:512) and then parks via ssleep() - because the waker never takes the spinlock, its wakeup can land between the drainer's check and its sleep-queue interlock enqueue (kern_synch.c _tsleep_interlock called from ssleep at :821) and is lost forever. Guest proof: 4 hammer threads loop enqueue+drain on 4 tasks of a 1-worker queue for ~3 minutes; watchdog reports 'LOST WAKEUP REPRODUCED: hammer 3 frozen (h3 15165->15165) with ta_pending=0 and task not running' and ps -axH shows lt_h3 permanently asleep on wchan '-' (the drain wmesg). A frozen drainer never returns - on a teardown path this is a permanent kernel-thread hang, reachable on ordinary busy queues without any taskqueue_free() (unlike DF-2870). Contrast: taskqueue_thread_enqueue (:629-642) is entered WITH the lock held, so its unlock->wakeup_one is interlock-ordered and that path cannot lose wakeups. Attempt 1 (2 hammers, 90 s) did not hit - window is tens of nanoseconds. fix.diff (runner clears per-task TASKQ_RUNNING under TQ_LOCK, wakeup after unlock; sleeper checks the flag under the same lock) validated by bounded negative re-run (4x4 hammer, 3 min): not hit, no frozen hammers.
No comments yet.