[PATCH sched_ext/for-7.3-fixes] sched_ext: Fix use-after-free of a local reenq node left on an inactive cpu
From: Tejun Heo
Date: Thu Oct 15 2026 - 13:41:01 EST
A local DSQ reenq is queued on the rq through a per-sched node and run from
deferred work. When the reenq is requested from dispatch, the work is a
balance callback queued once dispatch is done. A cpu going down installs
balance push early in its teardown, and queue_balance_callback() refuses new
callbacks from then on. ops.dispatch() keeps running on that cpu until the
rq goes offline after an RCU grace period, so a reenq it requests in that
window may never run. The IMMED handling in dispatch_one() requests one the
same way.
A reenq requested from elsewhere for a cpu that is already offline goes
through irq_work_queue_on(), which warns, and its work doesn't run until the
cpu comes back.
Either way the node stays on the rq's list. When the scheduler is freed,
scx_sched_free_rcu_work() warns and frees the pcpu with the node still
linked, and the list is walked again once the cpu comes back.
An inactive cpu's local DSQ holds only tasks that are bound to it or are
being moved off, so the reenq is pointless. Ignore reenqs of an inactive
cpu's local DSQ.
The test is lockless. The hotplug thread of a cpu going down runs on that
cpu, and it marks the cpu inactive before it installs balance push. A
dispatch on the cpu runs to completion before that thread can run there, so
a dispatch that saw the cpu active has queued its callback by then. Requests
from outside dispatch run from the wakeup path or through irq_work, which
balance push doesn't affect. The grace period after the cpu goes inactive
waits for any requester that saw it active, so that irq_work is queued while
the cpu is online.
Fixes: 238eba8c210d ("sched_ext: Use schedule_deferred_locked() in schedule_dsq_reenq()")
Fixes: 76edc2761ab8 ("sched_ext: Use irq_work_queue_on() in schedule_deferred()")
Cc: stable@xxxxxxxxxxxxxxx # v7.1+
Signed-off-by: Tejun Heo <tj@xxxxxxxxxx>
---
kernel/sched/ext/ext.c | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
--- a/kernel/sched/ext/ext.c
+++ b/kernel/sched/ext/ext.c
@@ -1103,6 +1103,18 @@ void schedule_dsq_reenq(struct scx_sched
rq = container_of(dsq, struct rq, scx.local_dsq);
/*
+ * An inactive cpu's local DSQ holds only tasks that are bound
+ * to it or are being moved off, so a reenq of it is pointless.
+ * Once balance push is installed, queue_balance_callback() also
+ * drops the callback that would run a reenq requested from
+ * dispatch, and irq_work_queue_on() can't reach an offline cpu.
+ * The reenq's node would then stay on this rq's list after
+ * @sch's pcpu is freed.
+ */
+ if (unlikely(!cpu_active(cpu_of(rq))))
+ return;
+
+ /*
* A sub-sched lacking baseline access on the target cid has no
* business triggering IPIs. The lockless test is fine: slipping
* through right after a revoke is harmless and a wrong denial
@@ -10020,7 +10032,8 @@ __bpf_kfunc struct task_struct *scx_bpf_
* - Local DSQs (%SCX_DSQ_LOCAL or %SCX_DSQ_LOCAL_ON | $cpu)
* - User DSQs
*
- * Re-enqueues are performed asynchronously. Can be called from anywhere.
+ * Re-enqueues are performed asynchronously. Can be called from anywhere. A
+ * re-enqueue of an inactive cpu's local DSQ is ignored.
*
* %SCX_DSQ_LOCAL resolves to the local DSQ of the rq the current scheduler
* operation is locked to - e.g. the rq being dispatched for in ops.dispatch() -