DragonFlyBSD Kernel Audit
← triage · dashboard
DF-2873

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2873 · 12 files
FileTypeDescriptionSize
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
README.md
↓ download 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.

VERDICT.md
↓ download raw

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, and ps -axH shows lt_h3 permanently 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Combined 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']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 17:34:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

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.