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

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_work at 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:

  1. 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 */ } The tsleep at line 317 uses timo=0 which means no timeout (confirmed in kern_synch.c:691: if (timo) { _calloutsetup_quick(...) } β€” timo=0 skips timeout setup entirely, so the sleep is indefinite).

  2. __flush_work_func (linux_workqueue.c:301-305): c wakeup_one(work); called from inside func execution.

  3. 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.canceled is false (reset by queue_work at line 135 and never set true), didcan is false, so NO wakeup is sent after running is cleared.

  4. Timing: flush_workqueue's first tsleep is woken by the wakeup_one from __flush_work_func. It wakes, checks on_queue || running (line 316): on_queue=false (set at process_all_work:74), running=true (still, because func just called wakeup from inside). Loop continues. Second tsleep with timo=0. No one will ever call wakeup(&__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.

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1976 Β· 4 files
FileTypeDescriptionSize
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
README.md readme PoC trigger description
↓ download 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).

VERDICT.md verdict verification narrative
↓ download raw

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.

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

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