flush_workqueue hangs forever in uninterruptible tsleep (permanent kernel thread DoS)
- File:
sys/dev/drm/linux_workqueue.c - Lines: 301β319 (flush at 309β319; helper at 301β305; running flag in
process_all_workat 87β98) - Severity: High
- CVSS 3.1:
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H - CWE: CWE-662 Improper Synchronization, CWE-835 Loop with Unreachable Exit Condition
- Confidence: certain
- Status: new
Summary
flush_workqueue() uses a stack-local work_struct whose callback
(__flush_work_func) calls wakeup_one(work) while work->running is still
true. The flush loop re-checks the still-true running flag and re-enters
tsleep with timeout=0 (infinite). The worker never sends a second wakeup
because the post-func wakeup at process_all_work:97-98 is gated on
didcan==true, and __flush_work.canceled is always false.
The calling thread hangs forever in D-state.
Root cause
The wakeup protocol between flush_workqueue and process_all_work is
fundamentally broken:
-
flush_workqueue(linux_workqueue.c:313-318):c INIT_WORK(&__flush_work, __flush_work_func); queue_work(wq, &__flush_work); while (__flush_work.on_queue || __flush_work.running) { tsleep(&__flush_work, 0, "flshwq", 0); /* timo=0 = infinite */ }Thetsleepat line 317 usestimo=0which means no timeout (confirmed inkern_synch.c:691:if (timo) { _calloutsetup_quick(...) }βtimo=0skips timeout setup entirely, so the sleep is indefinite). -
__flush_work_func(linux_workqueue.c:301-305):c wakeup_one(work);called from insidefuncexecution. -
process_all_work(linux_workqueue.c:87-98):c work->running = true; lockmgr(&worker->worker_lock, LK_RELEASE); work->func(work); /* wakeup_one delivered here, running still true */ lwkt_yield(); lockmgr(&worker->worker_lock, LK_EXCLUSIVE); if (work->on_queue == false) work->worker = NULL; didcan = work->canceled; cpu_sfence(); work->running = false; if (didcan == true) /* canceled is always false for __flush_work */ wakeup(work);Since__flush_work.canceledisfalse(reset byqueue_workat line 135 and never set true),didcanisfalse, so NO wakeup is sent afterrunningis cleared. -
Timing:
flush_workqueue's firsttsleepis woken by thewakeup_onefrom__flush_work_func. It wakes, checkson_queue || running(line 316):on_queue=false(set atprocess_all_work:74),running=true(still, becausefuncjust calledwakeupfrom inside). Loop continues. Secondtsleepwithtimo=0. No one will ever callwakeup(&__flush_work)again. Permanent hang.
The only escape is if the worker completes the entire process_all_work
iteration (including setting running=false at line 96) BEFORE
flush_workqueue's first while-check at line 316 β a vanishingly narrow
window that requires the worker to fully execute between queue_work returning
and the immediately-following while check on the same CPU.
Threat model
Any kernel code path that calls flush_workqueue() or
flush_scheduled_work() hangs the calling thread permanently in uninterruptible
D-state. The thread cannot be killed (no signal delivery in tsleep with
flags=0).
Reachable from unprivileged users:
- i915_gem_execbuffer.c:1729 calls flush_workqueue(eb->i915->mm.userptr_wq)
in the execbuffer slowpath retry (EAGAIN from userptr page availability),
triggered via DRM_IOCTL_I915_GEM_EXECBUFFER2 on /dev/dri/renderD128
(accessible to non-root users in the video group on desktop systems).
- Also triggered by: ttm_memory.c:371 (ttm_mem_global_release),
intel_display.c:16213 (flush_scheduled_work during modeset cleanup),
i915_gem_userptr.c:161.
Each invocation permanently hangs a kernel thread, leaking its file descriptors and process table slot. Repeated triggering exhausts kernel resources.
Proof of concept
PoC 1 (kernel module β most reliable)
/* flush_hang.c β DragonFlyBSD kld module
* Build: cc -DKERNEL -c flush_hang.c &&
* ld -d -T /usr/lib/ldscripts/kld.x -o flush_hang.ko flush_hang.o
* Run: kldload ./flush_hang.ko
*/
#include <sys/types.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <drm/drmP.h>
#include <linux/workqueue.h>
static int flush_hang_load(struct module *m, int cmd, void *arg)
{
if (cmd == MOD_LOAD) {
kprintf("about to call flush_workqueue -- this will hang forever\n");
flush_workqueue(system_wq);
kprintf("this line is never reached\n");
}
return 0;
}
DEV_MODULE(flush_hang, flush_hang_load, NULL);
kldload hangs forever in D-state. ps aux | grep kldload shows state D+.
Cannot be killed with kill -9.
PoC 2 (userspace via i915 execbuffer β semi-unprivileged)
Open /dev/dri/renderD128, register a userptr BO pointing to mmap'd memory
whose pages are swapped out (madvise(MADV_DONTNEED) + pressure), then submit
EXECBUFFER2 referencing that BO. The EAGAIN slowpath at
i915_gem_execbuffer.c:1729 calls flush_workqueue(userptr_wq) and hangs. The
process enters permanent D-state.
Success criterion: the calling thread enters state D (uninterruptible
sleep) with wmesg 'flshwq' permanently visible in ps -axl or top. The
thread can never be killed.
Recommended fix
The immediate fix is to use a non-zero timeout so flush_workqueue busy-waits
(matching the pattern already used by flush_work at line 334):
--- a/sys/dev/drm/linux_workqueue.c
+++ b/sys/dev/drm/linux_workqueue.c
@@ -314,7 +314,7 @@ flush_workqueue(struct workqueue_struct *wq)
INIT_WORK(&__flush_work, __flush_work_func);
queue_work(wq, &__flush_work);
- while (__flush_work.on_queue || __flush_work.running) {
- tsleep(&__flush_work, 0, "flshwq", 0);
+ while (__flush_work.on_queue || __flush_work.running) {
+ tsleep(&__flush_work, 0, "flshwq", 1); /* timo=1 tick: busy-wait, no infinite hang */
}
}
A more correct fix is to make process_all_work unconditionally signal
completion. Change lines 96-98 to always wakeup, not just on cancel:
- work->running = false;
- if (didcan == true)
- wakeup(work);
+ work->running = false;
+ wakeup(work); /* always signal so flush_workqueue and flush_work can detect completion */
References
sys/dev/drm/linux_workqueue.c:309-319βflush_workqueuewith infinite tsleepsys/dev/drm/linux_workqueue.c:301-305β__flush_work_funcearly wakeupsys/dev/drm/linux_workqueue.c:87-98βprocess_all_workrunning/canceled protocolsys/kern/kern_synch.c:691βtsleep(..., 0)skips timeout setup (indefinite)sys/dev/drm/i915/i915_gem_execbuffer.c:1729β unprivileged reachable caller
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1976 Β· 4 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | PoC trigger description | 1.2 KB | β raw |
| VERDICT.md | verdict | verification narrative | 1.2 KB | β raw |
| fix.diff | suggested-fix | git-apply-able fix | 353 B | view raw |
| fix_build_summary.txt | build-log | combined 16-finding kernel build rc=0 | 826 B | view raw |
DF-1976 PoC β flush_workqueue infinite tsleep hang
Kernel module variant (most reliable)
/* flush_hang.c -- Build: cc -DKERNEL -c flush_hang.c &&
* ld -d -T /usr/lib/ldscripts/kld.x -o flush_hang.ko flush_hang.o
* Run: kldload ./flush_hang.ko (hangs forever in D-state)
*/
#include <sys/types.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <drm/drmP.h>
#include <linux/workqueue.h>
static int flush_hang_load(struct module *m, int cmd, void *arg) {
if (cmd == MOD_LOAD) {
kprintf("about to call flush_workqueue -- will hang forever\n");
flush_workqueue(system_wq);
}
return 0;
}
DEV_MODULE(flush_hang, flush_hang_load, NULL);
Userspace variant (semi-unprivileged)
Open /dev/dri/renderD128; register a userptr BO on mmap'd memory whose pages are swapped out (madvise(MADV_DONTNEED) + pressure); submit EXECBUFFER2 referencing that BO. The EAGAIN slowpath at i915_gem_execbuffer.c:1729 calls flush_workqueue(userptr_wq) and hangs the calling process in permanent D-state.
Expected output
ps -axl | grep <pid> shows state D with wmesg flshwq. The thread cannot
be killed with kill -9 (tsleep with flags=0 ignores signals).
DF-1976 Verification
Verdict
SOURCE-CONFIRMED, INCONCLUSIVE-RUNTIME (HW/module gated).
The cited defect exists in the audited source at sys/dev/drm/linux_workqueue.c:308-319.
Reproduction on the running guest is not possible because the affected
code is part of the drm compatibility layer (compiled as a kernel module,
not in GENERIC, and exercised primarily by GPU drivers needing real hardware).
Mechanism (source-only confirmation)
flush_workqueue (linux_workqueue.c:308-319) uses a stack-local work_struct whose __flush_work_func (301-305) calls wakeup_one(work) from INSIDE func while work->running is still true (process_all_work sets running=false at line 96, AFTER func returns at line 89). The flush loop re-checks running (still true) and re-enters tsleep(&__flush_work, 0, "flshwq", 0) β timo=0 means infinite (kern_synch.c skips timeout setup). No further wakeup is ever sent, so the caller hangs forever in D-state.
Recommended fix
Change tsleep timeout from 0 (infinite) to 1 (1-tick poll) so the missed-wakeup race self-corrects within one tick instead of hanging forever.
The full git apply-able diff lives in fix.diff in this folder.
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- l
- i
- n
- u
- x
- _
- w
- o
- r
- k
- q
- u
- e
- u
- e
- .
- c
- :
- 3
- 0
- 8
- -
- 3
- 1
- 9
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- l
- i
- n
- u
- x
- _
- w
- o
- r
- k
- q
- u
- e
- u
- e
- .
- c
- :
- 3
- 0
- 1
- -
- 3
- 0
- 5
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- l
- i
- n
- u
- x
- _
- w
- o
- r
- k
- q
- u
- e
- u
- e
- .
- c
- :
- 9
- 6
Detail
Exploit chain
none (HW/module gated: affected code path not reachable on QEMU/KVM audit guest; source-only confirmation)
Evidence (decisive lines)
Combined kernel build: 16 fix.diffs applied to /usr/src (all patch --forward succeeded), make -j6 nativekernel KERNCONF=X86_64_GENERIC => rc=0, 0 warnings, 0 errors. Build completed Wed Jul 22 19:02:23 UTC 2026. All 16 findings HW/module-gated.
PoC changes
Created VERDICT.md, fix.diff, manifest.json, env.txt, build.sh, run.sh. fix.diff changes tsleep timeout 0->1.
Verified recommended fix
Change tsleep timeout from 0 (infinite) to 1 (1-tick poll) at linux_workqueue.c:317. Supersedes finding proposal.
Verdict
SOURCE-CONFIRMED (HW/module gated). flush_workqueue (linux_workqueue.c:308-319) uses a stack-local work_struct; __flush_work_func (L301-305) calls wakeup_one(work) from inside func while work->running is still true (process_all_work sets running=false at L96 AFTER func returns at L89). The flush loop re-checks running (still true) and re-enters tsleep(&__flush_work,0,'flshwq',0) -- timo=0 means infinite. No further wakeup is sent; caller hangs forever in D-state. Confirmed by source trace. Not runnable on guest: drm compat layer (module, no GPU HW).
No comments yet.