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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3699 Β· 11 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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_testable
baseline no→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-3699

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 include

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.