DF-2873 / verdict.json
{ "finding_id": "DF-2873", "status": "reproduced", "reproduced": 1, "impact": "dos", "confidence": "certain", "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.", "exploit_chain": "", "evidence": [ "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" ], "kernel_refs": [ "sys/kern/subr_taskqueue.c:411", "sys/kern/subr_taskqueue.c:412", "sys/kern/subr_taskqueue.c:493", "sys/kern/subr_taskqueue.c:512", "sys/kern/subr_taskqueue.c:516", "sys/kern/kern_synch.c:821" ], "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", "attempts": 3, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "runtime_sec": 6900, "guest_dirty": 0, "build_cmd": "cd /root/poc/tqlost && make -m /usr/share/mk SYSDIR=/usr/src/sys (see build.sh)", "run_cmd": "kldload /root/poc/tqlost/tqlost.ko; sleep 175; dmesg | grep tqlost; ps -axH -o stat,wchan,comm | grep lt_h (see run.sh)", "code_hash": "16d25065f58053efac9d1bae9716f02fff822de92ad5c6b3145086ad984f7228", "fix_status": "fixed", "fix_kernel_uname": "DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 17:34:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "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.", "fix_evidence": [ "run.fixed.log" ], "notes": "fixed-kernel validation is a bounded negative (race absence cannot be proven, only the interlock ordering argument in fix.diff + failure to hit under the same hammer that hit on stock)", "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." } |