lkpi queue_work() per-CPU double-enqueue: unprivileged irq-vs-waitboost race on rps->work corrupts the system workqueue STAILQs
Summary
DragonFly's LinuxKPI queue_work() selects the worker by the caller's mycpuid and takes ONLY that per-cpu worker's lock while test-and-setting the SHARED work->on_queue/work->running/work->canceled fields. system_wq has ncpus workers, so two CPUs enqueueing the SAME work item concurrently can both observe on_queue==false and both STAILQ_INSERT_TAIL it into two different workers' lists. A work_struct has ONE ws_entries.stqe_next link: the two lists alias; STAILQ_REMOVE_HEAD in worker A installs worker B's linkage into A's head (which also dequeues before checking ->running, silently dropping cross-worker requeues). i915_irq.c provides the racing queue pair on dev_priv->gt_pm.rps.work: the RPS hardirq site schedule_work(&rps->work) and the UNPRIVILEGED wait-boost site (DRM_IOCTL_I915_GEM_WAIT on an in-flight request -> gen6_rps_boost -> schedule_work on the user's CPU). Consequences: double execution of work items, lost work, and work->func(work) indirect calls through corrupted list pointers across every subsystem sharing system_wq - kernel memory corruption / potential RIP control with grooming, at minimum a local crash, from unprivileged GPU usage on any SMP Intel SNB+ machine. Trigger compiled BUILD_OK on the guest; no /dev/dri there - honest inconclusive, source-certain mechanics. SYSTEMIC: this is a linux_workqueue.c defect affecting ALL lkpi DRM code, filed here at its unprivileged-reachable trigger. Fix: bind each work item to one worker for its lifetime.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3699 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.4 KB | β raw | |
| VERDICT.md | β | 6.4 KB | β raw | |
| rps_queue_race.c | β | 3.7 KB | view raw | |
| i915_uapi_min.h | β | 1.5 KB | view raw | |
| build.sh | β | 102 B | view raw | |
| run.sh | β | 100 B | view raw | |
| build.log | β | 223 B | view raw | |
| run.log | β | 223 B | view raw | |
| env.txt | β | 373 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 3.8 KB | view raw |
DF-3699 β lkpi queue_work() per-CPU double-enqueue corrupts system_wq
WHAT sys/dev/drm/linux_workqueue.c:120-148 queue_work() picks the worker by the CALLER's cpu (system_wq has num_workers == ncpus, linux_workqueue.c:176, 209-213) and takes only that worker's worker_lock while doing an unlocked test-and-set of the SHARED work->on_queue / work->running / work->canceled fields. Two CPUs enqueueing the SAME work item concurrently can both see on_queue == false and both STAILQ_INSERT_TAIL the item into two different workers' lists. A work_struct has ONE ws_entries.stqe_next link: the two lists now alias. STAILQ_REMOVE_HEAD in worker A installs worker B's linkage into A's list head; work items get executed twice, dropped, or traversed as mistyped objects -> work->func(work) indirect call through a corrupted pointer -> kernel memory corruption (potential RIP control with system_wq grooming).
TRIGGER (unprivileged) CPU A: user waits on an in-flight GPU request ioctl(DRM_IOCTL_I915_GEM_WAIT) -> i915_wait_request() sys/dev/drm/i915/i915_gem.c:515-517 -> gen6_rps_boost() sys/dev/drm/i915/intel_pm.c:6671 -> schedule_work(&rps->work) *** queue site A, process ctx *** CPU B: RPS PM interrupt on the MSI-routed cpu gen6_rps_irq_handler() sys/dev/drm/i915/i915_irq.c:1829 -> schedule_work(&rps->work) i915_irq.c:1838 *** site B, irq ctx ***
./rps_queue_race.c drives site A in a tight loop while sustained GPU load keeps RPS EI/PM interrupts firing site B.
BUILD cc -O2 -o rps_queue_race rps_queue_race.c
RUN ./rps_queue_race 60 # as unprivileged user with /dev/dri access
EXPECTED (on real Intel SNB+ SMP hardware) Within minutes-to-hours of load: kernel panic / list corruption warnings from wq_worker_thread ("work item on two lists"), random work->func call on garbage, or silent memory corruption. Success criterion for the race: two "ws_entries" owners observed (needs instrumentation: kprintf in queue_work when STAILQ already linked, or a KASSERT(work->on_queue) under BOTH worker locks).
GUEST STATUS (this audit's QEMU guest) No i915 hardware (vgapci = Bochs 0x1234:1111) and no i915 module in /boot/kernel (only drm.ko). Trigger builds but cannot execute against an i915 device -> runtime verdict: inconclusive (source-level certainty).
DF-3699 VERDICT β lkpi queue_work per-CPU double-enqueue (system_wq corruption)
STATUS: inconclusive (runtime) β memory-corruption race is source-certain; the audit guest has no i915 hardware/module, so no runtime repro was possible. reproduced=0.
1. THE BUG (source-certain)
sys/dev/drm/linux_workqueue.c:
120 queue_work(struct workqueue_struct wq, struct work_struct work) 129 if (wq->num_workers > 1) 130 worker = &(*wq->workers)[mycpuid]; <- worker by CALLER cpu 134 lockmgr(&worker->worker_lock, LK_EXCLUSIVE); <- ONLY this worker's lock 135 work->canceled = false; 136 if (work->on_queue == false || work->running == false) { 137 if (work->on_queue == false) { 138 STAILQ_INSERT_TAIL(&worker->ws_list_head, work, ws_entries); 140 work->on_queue = true;
system_wq is per-CPU: init_workqueues() (linux_workqueue.c:176) does alloc_workqueue("system_wq", 0, 1) -> flags==0 -> NOT WQ_UNBOUND -> num_workers = ncpus (linux_workqueue.c:209-213).
There is NO lock shared between workers[0].worker_lock and workers[1]. worker_lock, yet both protect the SAME work_struct fields. Two CPUs in queue_work(same work) can interleave:
CPU0 (holds workers[0].lock) CPU1 (holds workers[1].lock) read work->on_queue == false read work->on_queue == false (stale) INSERT into list[0] INSERT into list[1] store work->on_queue = true store work->on_queue = true
Both lists now contain the same item; ws_entries.stqe_next (ONE pointer) was written twice β the last writer wins, so list[0]'s tail chain now runs into list[1]'s items (or vice versa). struct work_struct (sys/dev/drm/include/linux/workqueue.h:45-52) has no per-work lock and no refcount; the shim's correctness rests entirely on a work item living on a single worker, which the mycpuid selection breaks.
Downstream effects (same file):
* process_all_work() (linux_workqueue.c:71-78) STAILQ_REMOVE_HEADs the
item BEFORE testing work->running β a cross-worker requeue is silently
DROPPED, and the removal rewrites the owning list's head with the
other list's linkage.
* queue_work():135 work->canceled = false can undo _cancel_work()'s
work->canceled = true (linux_workqueue.c:375) set under the other
worker's lock β cancel_work_sync()'s "callers may free associated data
after this call" contract (linux_workqueue.c:392-394) is void.
2. ATTACK PATH (unprivileged, i915)
rps->work is queued from two CPUs concurrently:
site A (process ctx, any cpu, UNPRIVILEGED): ioctl DRM_IOCTL_I915_GEM_WAIT on an in-flight request -> i915_wait_request() sys/dev/drm/i915/i915_gem.c:515-517 (rps_client && !i915_request_started -> gen6_rps_boost) -> gen6_rps_boost() sys/dev/drm/i915/intel_pm.c:6643 -> schedule_work(&rps->work) intel_pm.c:6671
site B (irq ctx, MSI-routed cpu): RPS EI-expired / PM threshold interrupt -> gen6_rps_irq_handler() sys/dev/drm/i915/i915_irq.c:1829-1841 -> schedule_work(&rps->work) i915_irq.c:1838
(a third, root-only queue site exists: sys/dev/drm/i915/i915_sysfs.c:328)
Preconditions: SMP (guest hw.ncpu=6; any modern machine), Intel SNB+ with RPS enabled (default), unprivileged /dev/dri/card0 access, sustained fence-wait load. The insert-vs-insert window is one store-buffer visibility interval (~100ns); collisions of the two queue sites are common under load because a user wait immediately after request submission is exactly when the RPS EI timer fires.
3. IMPACT
Kernel memory corruption: system_wq is shared by every schedule_work() user in the drm stack. Aliased STAILQs lead to double execution of work items, lost work, and β worst case β process_all_work() calling work->func(work) on a pointer traversed out of a corrupted list, i.e. an indirect call on a mistyped object. With heap grooming of adjacent system_wq items this is a potential RIP-control primitive; at minimum it is a reliable crash (local DoS) from unprivileged GPU usage.
4. WHY NOT REPRODUCED HERE
Guest: DragonFly 6.5-DEVELOPMENT x86_64 (X86_64_GENERIC), 6 CPUs. PCI display device: vgapci0 chip 0x11111234 (Bochs/QEMU std VGA) β not Intel. /boot/kernel contains drm.ko only; no i915.ko. The trigger compiles cleanly on the guest (see build.log) and correctly reports SKIP (run.log): not i915. On hardware i915 this trigger is the reproduction vehicle.
5. FIX (validated at source level; not runtime-validated β no hardware)
Bind each work item to ONE worker for its lifetime (record the worker at first enqueue; queue to work->worker, not workers[mycpuid]), or take a per-workqueue enqueue lock around the test-and-insert. Minimal patch:
diff --git a/sys/dev/drm/linux_workqueue.c b/sys/dev/drm/linux_workqueue.c
--- a/sys/dev/drm/linux_workqueue.c
+++ b/sys/dev/drm/linux_workqueue.c
@@ struct work_struct {
@@ queue_work(struct workqueue_struct *wq, struct work_struct *work)
{
struct workqueue_worker *worker;
+ /* Bind the work to the worker it was first queued on so that
+ * concurrent queue_work() from different cpus serialize on the
+ * same worker_lock. */
+ if (work->worker != NULL) {
+ worker = work->worker;
+ } else if (wq->num_workers > 1) {
worker = &(*wq->workers)[mycpuid];
+ } else {
worker = &(*wq->workers)[0];
}
(process_all_work() must keep clearing work->worker only when the item
leaves the queue and is not running β i.e. the existing
if (work->on_queue == false) work->worker = NULL; at :92-93 already
provides the release point.)
RECOMMENDED_FIX: "Bind work items to one worker in lkpi queue_work() (or add a per-wq enqueue lock) so concurrent cross-CPU enqueues of the same work_struct cannot double-insert into two STAILQs."
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testablefix.diff authored (VERDICT.md section 5): bind work->worker; cannot runtime-validate on a guest without i915 hardware
findings/poc/DF-3699/VERDICT.md#5-minimal-patch
Confirmed kernel references
Detail
Exploit chain
unprivileged GPU client submits batch + GEM_WAIT loop -> schedule_work(&rps->work) on user cpu while RPS MSI fires on irq cpu -> double STAILQ insert of one work_struct -> system_wq list aliasing -> corrupted list head -> work->func indirect call on mistyped object / double free of adjacent work items (grooming-dependent) -> crash (DoS) at minimum, RIP-control potential
Evidence (decisive lines)
VERDICT.md sections 1-3; linux_workqueue.c:129-148 annotated interleaving; build.log (BUILD_OK on DragonFly 6.5 cc); run.log (FAIL: no /dev/dri/card0 - device absent); env.txt (hw.ncpu=6, Bochs VGA, drm.ko only)
PoC changes
seed was prose-only; wrote raw-ioctl trigger (no libdrm), fixed DFly build: added stdint typedefs for __u32/__u64, removed __user annotation, added
Verified recommended fix
Bind work items to one worker in lkpi queue_work() (queue to work->worker once set) or add a per-workqueue enqueue lock so cross-CPU enqueues of one work_struct cannot double-insert into two STAILQs.
Verdict
Source-certain memory-corruption race, runtime-untestable on this guest (no Intel GPU, no i915 module). DragonFly's lkpi queue_work() (sys/dev/drm/linux_workqueue.c:120-148) selects the worker by the caller's cpu and takes only that per-cpu worker's lock while test-and-setting the shared work->on_queue/running/canceled fields; system_wq has ncpus workers (linux_workqueue.c:176,209-213), so two CPUs enqueueing the same work item can both see on_queue==false and both STAILQ_INSERT_TAIL it into two different lists that alias one ws_entries.stqe_next link -> cross-list traversal, double/ghost execution, work->func(work) on mistyped objects. Unprivileged trigger: DRM_IOCTL_I915_GEM_WAIT on an in-flight request -> gen6_rps_boost -> schedule_work(&rps->work) (intel_pm.c:6671, i915_gem.c:515-517) racing the RPS hardirq's schedule_work(&rps->work) (i915_irq.c:1838). The trigger compiled cleanly on the guest (BUILD_OK in build.log) and correctly failed with 'no /dev/dri/card*' (run.log): the guest VGA is Bochs 0x1234:1111 and /boot/kernel has only drm.ko. On real SNB+ SMP hardware this is a plausible unprivileged kernel-corruption primitive; the insert-vs-insert window is one store-visibility interval, so reliability is moderate.
No comments yet.