* mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented)
2026-10-27 14:12 ` Greg KH
@ 2026-11-03 11:21 ` 徐晗翔
2026-11-04 9:41 ` Qing Wang
2026-11-03 11:30 ` futex/hrtimer: hrtimer_sleeper::task is read unsynchronized in 16 places on v7.3 -- the accessor series that fixes this is not in 7.3; please make sure it reaches the affected trees (backport nudge + stock-kernel KCSAN evidence) 徐晗翔
` (20 subsequent siblings)
21 siblings, 1 reply; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 11:21 UTC (permalink / raw)
To: vbabka, harry, akpm; +Cc: linux-mm, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 4809 bytes --]
flush_all_cpus_locked() calls has_pcs_used() for every CPU to decide
which flush work to skip, reading each remote CPU's pcs->spare /
pcs->rcu_free and dereferencing pcs->main->size -- all locklessly
(mm/slub.c:4041-4051, inlined into flush_all_cpus_locked) -- while
the owning CPU's alloc/free fast paths mutate exactly those fields
under only the per-CPU sheaf lock (pcs->main->size++/--, the
pcs->main pointer swap in __pcs_replace_empty_main, etc.). The
reader runs from kmem_cache_shrink() (sysfs shrink attribute, memory
offline) and is, as far as I can enumerate, the only cross-CPU
consumer of this state outside the sysfs slabinfo walkers (excluded
-- they read other fields under a different protocol). I verified
the race with KCSAN after discovering why nobody has ever seen it
reported: mm/Makefile sets KCSAN_SANITIZE_slub.o := n, so BOTH sides
of this race are structurally invisible to KCSAN on any stock kernel
(verified at the disassembly level: the four accesses in
flush_all_cpus_locked compile to bare cmp/mov with zero __tsan
calls). On a diagnostic build that only flips that Makefile switch
(no source change), a shrink+churn workload on a dedicated cache
produces 436 reader-pair reports covering all five writer families,
including an 8-byte pcs->main pointer swap with an explicit "value
changed" line -- the snapshot prerequisite of the (extremely
unlikely, source-argued) use-after-free chain: a stale pcs->main can
outlive its sheaf's retirement (spare -> rcu_free -> call_rcu ->
kfree) if the reader is preempted between load and dereference. The
practical impact of the race itself is a wrong skip decision in
sfw->skip; the fix is to move the emptiness check into the target
CPU's flush callback, where the state is local and locked.
The detailed description:
* The race (Linux 7.3, mm/slub.c)
Reader (inline has_pcs_used, from flush_all_cpus_locked, called by
__kmem_cache_shrink / kmem_cache_shrink -- sysfs "shrink" and
memory offline, privileged):
reads pcs->spare, pcs->rcu_free, pcs->main, pcs->main->size of
REMOTE cpus, holding no lock on that cpu's sheaf.
Writers (per-CPU fast paths under the sheaf lock of their own cpu):
alloc/free paths' main->size++/-- (kmem_cache_free's free_to_pcs,
bulk variants' size += batch, alloc's size--), and
__pcs_replace_empty_main's pcs->main = full / pcs->spare = old
pointer stores.
Sheaves are enabled by default (no slub_debug boot param needed).
* Why KCSAN has never reported it (part of the finding)
mm/Makefile:15 KCSAN_SANITIZE_slub.o := n excludes the entire file
from instrumentation on both sides. nm/disassembly on a stock 7.3
KCSAN kernel: has_pcs_used is inlined into flush_all_cpus_locked
and the four accesses at +0x166/+0x173 etc. have no __tsan_* calls
around them. So "no KCSAN report" is not evidence of absence for
anything inside slub.o; this race was invisible by construction.
* Evidence (KCSAN, stock-vs-diagnostic A/B; reproducer attached)
- Stock kernel: three runs, zero reports (structurally, as above).
- Diagnostic kernel (ONLY the Makefile switch flipped, source
untouched): dedicated non-mergeable cache, 3 churn threads
(single+bulk8 alloc/free) + 2 shrink threads -> 436 reader-pair
reports, all five writer families:
* size field (4 bytes, same address across CPUs):
read by flush_all_cpus_locked+0x173 (shrink, cpu 3) vs
read-write kmem_cache_free_bulk+0x90a (churn, cpu 1)
* pcs->main pointer (8 bytes): write by
__pcs_replace_empty_main+0x5c1 (pcs->main = full) vs read by
flush_all_cpus_locked+0x166, KCSAN printing
"value changed: ...0300 -> ...78800" -- the reader observed
the sheaf pointer being concurrently replaced, i.e. the
stale-snapshot precondition of the UAF chain, live.
- Control (same diagnostic kernel, churn-only, shrink=0): zero
reader-pair reports -- the reports are caused by the shrink-side
reader, not by the churn load.
- Honest limits: the (c)-layer UAF read (stale pcs->main surviving
the retirement chain during a preempted reader) is source-argued,
not dynamically triggered -- it needs a millisecond-scale preemption
of the reader landing inside a retirement window; KCSAN demonstrates
the racing accesses and the pointer-swap visibility, which is the
right tool's right scope.
Reproducer (attached fxslub.c): registers a private cache with a
ctor (non-mergeable, sheaf-enabled) and hammers
kmem_cache_shrink() from N threads while other threads churn
single+bulk allocations. On a KCSAN kernel with slub.o
instrumented it produces the reports above within minutes; on a
stock kernel it demonstrably cannot (that is the point).
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: fxslub.c --]
[-- Type: text/plain, Size: 6525 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* fxslub.c - KCSAN hammer for Case 1 of the kernel/futex manual audit:
*
* has_pcs_used() (mm/slub.c:4041-4051) reads remote per-CPU sheaf state
* (pcs->spare / pcs->rcu_free / pcs->main->size) with no lock and no
* annotation from flush_all_cpus_locked() during kmem_cache_shrink(),
* racing the owning CPU's local_trylock-protected writers
* (free_to_pcs / alloc_from_pcs / __free_to_pcs_batch /
* __pcs_replace_empty_main / __pcs_replace_full_main /
* __kfree_rcu_sheaf ...).
*
* Method: dedicated unmergeable cache (ctor) + churn kthreads pinned on
* CPUs 0..N-1 driving all sheaf write paths (single fastpath, bulk, and
* kfree_rcu batching) + shrink kthreads looping kmem_cache_shrink() on
* other CPUs. No kernel source modification; EXPORTED APIs only.
*
* Params:
* churn_threads - pinned per-CPU alloc/free/rcu threads (default 6)
* shrink_threads - kmem_cache_shrink() loopers (default 2, cpus 6,7)
* shrink - 1 enable shrink loop (fx1), 0 negative control (fx1ctl)
* secs - self-reported runtime budget hint for /init (default 90)
*/
#define pr_fmt(fmt) "FXSLUB: " fmt
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/slab.h>
#include <linux/delay.h>
#include <linux/rcupdate.h>
#include <linux/cpu.h>
#include <linux/nodemask.h>
#include <linux/mm.h>
static int churn_threads = 6;
static int shrink_threads = 2;
static bool shrink = true;
static int secs = 90;
module_param(churn_threads, int, 0);
module_param(shrink_threads, int, 0);
module_param(shrink, bool, 0);
module_param(secs, int, 0);
struct fxobj {
struct rcu_head rcu;
char pad[512 - sizeof(struct rcu_head)];
};
static struct kmem_cache *fxcache;
static struct task_struct *churn_task[8];
static struct task_struct *shrink_task[4];
static atomic_t stop_flag;
static atomic64_t n_alloc, n_bulk, n_rcu, n_shrink, n_free_single;
/* ctor => unmergeable => dedicated sheaves/barns for this cache */
static void fx_ctor(void *p) { }
static int fx_churn(void *arg)
{
void *bulk[8];
struct fxobj *o;
int n;
while (!kthread_should_stop() && !atomic_read(&stop_flag)) {
/* single fastpath: pcs->main->size ++/-- on this cpu */
o = kmem_cache_alloc(fxcache, GFP_KERNEL);
if (o) {
atomic64_inc(&n_alloc);
kmem_cache_free(fxcache, o);
atomic64_inc(&n_free_single);
}
/* bulk: drives __pcs_replace_empty_main / free_to_pcs_bulk /
* barn traffic (pcs->main/spare pointer stores).
* Bulk size 8 (not 32): smaller batches keep the free-side
* barn_replace_full_sheaf() rate (whose MAX_FULL_SHEAVES check
* is outside barn->lock by design) from stacking unboundedly
* under KCSAN-widened check->lock gaps (attempt4: 5.8GB of
* freed objects parked in barn sheaves -> OOM). */
n = kmem_cache_alloc_bulk(fxcache, GFP_KERNEL, 8, bulk);
if (n > 0) {
atomic64_add(n, &n_bulk);
kmem_cache_free_bulk(fxcache, n, bulk);
}
/* memory watermark throttle: barn parking of freed objects is
* only soft-bounded (see above); do not let the hammer OOM the VM */
if (si_mem_available() < (512UL << 20 >> PAGE_SHIFT))
msleep(100);
/*
* No kfree_rcu phase: for a non-kmalloc cache, kfree_rcu() takes
* the per-object call_rcu(kvfree_rcu_cb) path (NOT the sheaf
* batching), so it contributes nothing to the sheaf-state writers
* under test while its RCU backlog grows without bound under
* KCSAN taxation (attempt3: 5.8GB unreclaimable, OOM panic).
* All target writers (free_to_pcs / alloc_from_pcs /
* __pcs_replace_*) are driven by the single+bulk churn above.
*/
if (!(atomic64_read(&n_alloc) & 0xfff))
cond_resched();
}
return 0;
}
static int fx_shrink(void *arg)
{
while (!kthread_should_stop() && !atomic_read(&stop_flag)) {
kmem_cache_shrink(fxcache);
atomic64_inc(&n_shrink);
cond_resched();
}
return 0;
}
static int __init fxslub_init(void)
{
long i;
int ret;
fxcache = kmem_cache_create("fxsheaf", sizeof(struct fxobj), 0,
SLAB_PANIC, fx_ctor);
if (!fxcache)
return -ENOMEM;
if (churn_threads > 8)
churn_threads = 8;
if (shrink_threads > 4)
shrink_threads = 4;
pr_info("cache fxsheaf created, churn=%d shrink=%d(%s) secs=%d\n",
churn_threads, shrink_threads,
shrink ? "on" : "OFF (negative control)", secs);
for (i = 0; i < churn_threads; i++) {
struct task_struct *t = kthread_create(fx_churn, (void *)i,
"fxchurn/%ld", i);
if (IS_ERR(t)) { ret = PTR_ERR(t); goto err; }
kthread_bind(t, i % nr_cpu_ids);
wake_up_process(t);
churn_task[i] = t;
}
if (shrink) {
for (i = 0; i < shrink_threads; i++) {
struct task_struct *t = kthread_create(fx_shrink, (void *)i,
"fxshrink/%ld", i);
if (IS_ERR(t)) { ret = PTR_ERR(t); goto err; }
kthread_bind(t, (churn_threads + i) % nr_cpu_ids);
wake_up_process(t);
shrink_task[i] = t;
}
}
pr_info("threads running\n");
return 0;
err:
atomic_set(&stop_flag, 1);
for (i = 0; i < 8; i++)
if (churn_task[i])
kthread_stop(churn_task[i]);
kmem_cache_destroy(fxcache);
return ret;
}
static void __exit fxslub_exit(void)
{
int i;
atomic_set(&stop_flag, 1);
for (i = 0; i < 8; i++)
if (churn_task[i])
kthread_stop(churn_task[i]);
for (i = 0; i < 4; i++)
if (shrink_task[i])
kthread_stop(shrink_task[i]);
pr_info("totals: shrink_calls=%lld allocs=%lld bulk=%lld rcu=%lld single_free=%lld\n",
atomic64_read(&n_shrink), atomic64_read(&n_alloc),
atomic64_read(&n_bulk), atomic64_read(&n_rcu),
atomic64_read(&n_free_single));
/* see comment below before any future re-addition of destroy */
/*
* The cache is deliberately NOT destroyed: kfree_rcu() from a non-
* kmalloc cache queues one call_rcu(kvfree_rcu_cb) per object
* (kvfree_call_rcu(), mm/slab_common.c), and those callbacks free back
* into per-CPU sheaves; draining ~200k of them under KCSAN taxation
* takes minutes and then floods "Objects remaining on
* __kmem_cache_shutdown()" warnings (observed: 1441/1604 warnings in
* the first two control attempts). That noise is a module-contract
* artifact, irrelevant to the race under test; this VM is throwaway,
* so we leak the idle cache and keep the log clean.
*/
pr_info("leaking idle cache fxsheaf (teardown noise avoided)\n");
pr_info("cache destroyed, module out\n");
}
module_init(fxslub_init);
module_exit(fxslub_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("KCSAN hammer: has_pcs_used() vs per-CPU sheaf writers (audit Case 1)");
^ permalink raw reply [flat|nested] 32+ messages in thread* Re: mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented)
2026-11-03 11:21 ` mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented) 徐晗翔
@ 2026-11-04 9:41 ` Qing Wang
2026-11-04 10:07 ` Qing Wang
0 siblings, 1 reply; 32+ messages in thread
From: Qing Wang @ 2026-11-04 9:41 UTC (permalink / raw)
To: xuhx, Vlastimil Babka, Harry Yoo, Andrew Morton, Hao Li,
Christoph Lameter, David Rientjes, Roman Gushchin
Cc: linux-mm, linux-kernel
On Tue, 03 Nov 2026 at 19:21, 徐晗翔 <xuhx@hust.edu.cn> wrote:
> flush_all_cpus_locked() calls has_pcs_used() for every CPU to decide
> which flush work to skip, reading each remote CPU's pcs->spare /
> pcs->rcu_free and dereferencing pcs->main->size -- all locklessly
> (mm/slub.c:4041-4051, inlined into flush_all_cpus_locked) -- while
> the owning CPU's alloc/free fast paths mutate exactly those fields
> under only the per-CPU sheaf lock (pcs->main->size++/--, the
> pcs->main pointer swap in __pcs_replace_empty_main, etc.). The
> reader runs from kmem_cache_shrink() (sysfs shrink attribute, memory
> offline) and is, as far as I can enumerate, the only cross-CPU
> consumer of this state outside the sysfs slabinfo walkers (excluded
> -- they read other fields under a different protocol). I verified
> the race with KCSAN after discovering why nobody has ever seen it
> reported: mm/Makefile sets KCSAN_SANITIZE_slub.o := n, so BOTH sides
> of this race are structurally invisible to KCSAN on any stock kernel
> (verified at the disassembly level: the four accesses in
> flush_all_cpus_locked compile to bare cmp/mov with zero __tsan
> calls). On a diagnostic build that only flips that Makefile switch
> (no source change), a shrink+churn workload on a dedicated cache
> produces 436 reader-pair reports covering all five writer families,
> including an 8-byte pcs->main pointer swap with an explicit "value
> changed" line -- the snapshot prerequisite of the (extremely
> unlikely, source-argued) use-after-free chain: a stale pcs->main can
> outlive its sheaf's retirement (spare -> rcu_free -> call_rcu ->
> kfree) if the reader is preempted between load and dereference. The
> practical impact of the race itself is a wrong skip decision in
> sfw->skip; the fix is to move the emptiness check into the target
> CPU's flush callback, where the state is local and locked.
You're right, there is a data race in has_pcs_used() reading the pcs
fields. The possible consequences:
1. has_pcs_used() observes an empty pcs and sets sfw->skip, so the
flush is skipped and the memory is not freed in time.
2. A very unlikely UAF: the reader loads pcs->main, and the old sheaf
is freed before ->size is dereferenced.
For the fix: could we simply drop the has_pcs_used() check? The cost
is some extra workqueue overhead — flush work would be queued even on
CPUs whose sheaves are empty.
--
Best regards,
Qing
^ permalink raw reply [flat|nested] 32+ messages in thread
* Re: mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented)
2026-11-04 9:41 ` Qing Wang
@ 2026-11-04 10:07 ` Qing Wang
0 siblings, 0 replies; 32+ messages in thread
From: Qing Wang @ 2026-11-04 10:07 UTC (permalink / raw)
To: xuhx, Vlastimil Babka, Harry Yoo, Andrew Morton, Hao Li,
Christoph Lameter, David Rientjes, Roman Gushchin
Cc: linux-mm, linux-kernel
On Wed, 04 Nov 2026 at 17:41, Qing Wang <wangqing7171@gmail.com> wrote:
> You're right, there is a data race in has_pcs_used() reading the pcs
> fields. The possible consequences:
>
> 1. has_pcs_used() observes an empty pcs and sets sfw->skip, so the
> flush is skipped and the memory is not freed in time.
Sorry, I didn't state point 1 correctly. It should be:
has_pcs_used() reads main->size as 0 before the concurrent free
increments it, so sfw->skip is wrongly set and the memory is not
reclaimed in time.
--
Best regards,
Qing
^ permalink raw reply [flat|nested] 32+ messages in thread
* futex/hrtimer: hrtimer_sleeper::task is read unsynchronized in 16 places on v7.3 -- the accessor series that fixes this is not in 7.3; please make sure it reaches the affected trees (backport nudge + stock-kernel KCSAN evidence)
2026-10-27 14:12 ` Greg KH
2026-11-03 11:21 ` mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented) 徐晗翔
@ 2026-11-03 11:30 ` 徐晗翔
2026-11-03 11:48 ` cgroup: fork vs cgroup.procs write race in favordynmods mode -- v7.3 is affected; please make sure bf6fbf46b5ff reaches the stable trees (repro incl. uninstrumented natural hit) 徐晗翔
` (19 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 11:30 UTC (permalink / raw)
To: tglx, mingo, anna-maria, frederic; +Cc: linux-kernel
[-- Attachment #1: Type: text/plain, Size: 3509 bytes --]
On v7.3, struct hrtimer_sleeper::task is written by the hrtimer
callback in hard interrupt context (hrtimer_wakeup(): t->task = NULL,
plus the start path's assignment) and read without any synchronization
in sixteen places across nine files -- the futex wait/requeue/PI
paths (kernel/futex/waitwake.c:386/542/593/728, requeue.c:747,
rtmutex.c:1647), the nanosleep core (kernel/time/hrtimer.c:2435/2441/
2445, sleep_timeout.c:215/223), wait.h:559, aio, io_uring/rw.c and
pktgen. The impact is bounded -- misclassification of a return code
(-ERESTARTSYS seen where -ETIMEDOUT is due, or an extra restart) with
no UAF and no lost wakeup -- which matches the analysis in the
upstream series that already fixes this (P. McKenney, 2026-07-31:
mark the field __private and route every access through
hrtimer_sleeper_task_get/set() accessors; nine patches). That series
is not in v7.3. I am writing to (a) confirm the analysis with
stock-kernel KCSAN evidence on 7.3 -- 48+ reports within ~20 s of
targeted microsecond-timeout load, exactly at the audited pairs
(__futex_wait vs hrtimer_wakeup, do_nanosleep vs hrtimer_wakeup),
and (b) ask that the series be applied/backported to the trees that
still carry the raw accesses, since every one of them keeps being a
KCSAN report factory (and a correctness smell) until then.
The detailed description:
* The unsynchronized pattern (v7.3)
Writers (2): hrtimer_sleeper_start_expires()'s task assignment and,
in hard IRQ context, hrtimer_wakeup() setting t->task = NULL
(kernel/time/hrtimer.c:2323-2333) when the timer expires.
Readers (16): each checks "did the timer already fire?" by reading
sleeper->task -- e.g. __futex_wait's return-code selection
(waitwake.c:728), futex_wait_requeue_pi paths (:542/:593/:386),
requeue.c:747, rt_mutex_waiter (rtmutex.c:1647), do_nanosleep's
restart decision (hrtimer.c:2435/2441/2445), etc. None holds a
lock or uses READ_ONCE against the IRQ writer.
* Evidence (KCSAN on a STOCK 7.3 kernel -- unlike slub, these files
are instrumented; no diagnostic build needed)
Targeted microsecond-timeout workloads (userspace static binary
plus a small module for the in-kernel waiters): futex with tiny
timeouts (146k/141k calls), FUTEX_WAIT_REQUEUE_PI (122k), nanosleep
(363k), epoll (2.89M), aio (531k), usleep_range /
wait_event_hrtimeout (581k each). Within ~20 seconds of load
start:
BUG: KCSAN: data-race in __futex_wait / hrtimer_wakeup
read-write to 0x... of 8 bytes by interrupt on cpu 1:
hrtimer_wakeup+0x17 <- t->task = NULL
__hrtimer_run_queues <- hrtimer_interrupt <- apic timer
read to 0x... of 8 bytes by task on cpu 3:
__futex_wait+0xc9 <- the waitwake.c:728
read, held in KCSAN's
watchpoint delay
futex_wait -> do_futex -> __x64_sys_futex
and the same shape for do_nanosleep vs hrtimer_wakeup
(do_nanosleep+0xbe = hrtimer.c:2435/2441). 48+ targeted reports
across the two representative read sites; the other audited read
sites share the identical mechanism (sampling/limiting is why they
were not individually collected).
Reproducers attached: fxfut.c (userspace, six thread classes) and
fxhrt.c (module driving usleep_range/wait_event_hrtimeout hammer).
Run on any KCSAN kernel with skip_watch/udelay tuned; first hits
arrive within tens of seconds.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: fxhrt.c --]
[-- Type: text/plain, Size: 2619 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* fxhrt.c - KCSAN hammer for Case 2 of the kernel/futex manual audit,
* kernel-context side:
*
* hrtimer_sleeper::task plain reads racing the hardirq writer
* hrtimer_wakeup() (kernel/time/hrtimer.c:2323-2333 t->task = NULL).
*
* Sites exercised here:
* R10/R11 schedule_hrtimeout_range_clock() kernel/time/sleep_timeout.c:215/223
* R12 __wait_event_hrtimeout include/linux/wait.h:559
* (user-space side fxfut covers futex R1-R5, do_nanosleep R7-R9, epoll/aio)
*
* Method: kthreads looping microsecond sleeps so the timer expires within
* the KCSAN watch window of the immediately-following ->task read.
* usleep_range() reaches schedule_hrtimeout_range_clock() via
* do_usleep_range(); wait_event_hrtimeout() expands the wait.h macro.
*/
#define pr_fmt(fmt) "FXHRT: " fmt
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/wait.h>
#include <linux/slab.h>
static int threads = 4;
static int secs = 90;
module_param(threads, int, 0);
module_param(secs, int, 0);
static DECLARE_WAIT_QUEUE_HEAD(fxwq);
static struct task_struct *htask[8];
static atomic_t stop_flag;
static atomic64_t n_usleep, n_wevt;
static int fx_hrt(void *arg)
{
unsigned long t;
while (!kthread_should_stop() && !atomic_read(&stop_flag)) {
/* R10/R11: schedule_hrtimeout_range_clock() t.task reads */
usleep_range(1, 2);
atomic64_inc(&n_usleep);
/* R12: __wait_event_hrtimeout __t.task read */
t = atomic64_inc_return(&n_wevt);
wait_event_hrtimeout(fxwq, (t & 1) == 2, 2);
}
return 0;
}
static int __init fxhrt_init(void)
{
long i;
int ret;
if (threads > 8)
threads = 8;
for (i = 0; i < threads; i++) {
struct task_struct *t = kthread_create(fx_hrt, (void *)i,
"fxhrt/%ld", i);
if (IS_ERR(t)) { ret = PTR_ERR(t); goto err; }
kthread_bind(t, (i + 4) % nr_cpu_ids);
wake_up_process(t);
htask[i] = t;
}
pr_info("%d hrt sleeper threads running (usleep_range 1-2us + wait_event_hrtimeout 2us)\n",
threads);
return 0;
err:
atomic_set(&stop_flag, 1);
for (i = 0; i < 8; i++)
if (htask[i])
kthread_stop(htask[i]);
return ret;
}
static void __exit fxhrt_exit(void)
{
int i;
atomic_set(&stop_flag, 1);
for (i = 0; i < 8; i++)
if (htask[i])
kthread_stop(htask[i]);
pr_info("totals: usleep_range=%lld wait_event_hrtimeout=%lld\n",
atomic64_read(&n_usleep), atomic64_read(&n_wevt));
}
module_init(fxhrt_init);
module_exit(fxhrt_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("KCSAN hammer: hrtimer_sleeper::task readers vs hrtimer_wakeup (audit Case 2)");
[-- Attachment #3: fxfut.c --]
[-- Type: text/plain, Size: 3505 bytes --]
// SPDX-License-Identifier: GPL-2.0
/* fxfut.c - KCSAN hammer for Case 2, user-space side.
*
* Threads hammering microsecond-timeout waits so the armed hrtimer
* expires inside the KCSAN watch window of the following ->task read:
*
* R1/R4 futex(FUTEX_WAIT_PRIVATE, .., 1-2us) -> futex_do_wait/__futex_wait
* R1 futex(FUTEX_WAIT_BITSET_PRIVATE, ...) -> futex_do_wait
* R5 futex(FUTEX_WAIT_REQUEUE_PI_PRIVATE..) -> handle_early_requeue_pi_wakeup
* R7-R9 nanosleep(1-2us) -> do_nanosleep
* R10 epoll_wait(1ms) -> schedule_hrtimeout_range_clock
* R13 io_getevents(1-2us timeout) -> aio_read_events_ring
*
* usage: fxfut <threads-per-kind> <seconds>
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/syscall.h>
#include <sys/epoll.h>
#include <time.h>
#ifndef SYS_futex
#define SYS_futex 202
#endif
#define FUTEX_WAIT 0
#define FUTEX_WAIT_BITSET 9
#define FUTEX_WAIT_REQUEUE_PI 11
#define FUTEX_PRIVATE_FLAG 128
#ifndef SYS_io_setup
#define SYS_io_setup 206
#define SYS_io_getevents 208
#endif
static volatile int running = 1;
static long cnt_futex, cnt_futexb, cnt_rqi, cnt_nano, cnt_epoll, cnt_aio;
static int n_threads = 3, secs = 90;
#define TS_US(nsec) ({ static struct timespec _ts; _ts.tv_sec = 0; _ts.tv_nsec = (nsec); &_ts; })
static void *tf_futex(void *a)
{
unsigned int word = 0;
while (running) {
syscall(SYS_futex, &word, FUTEX_WAIT | FUTEX_PRIVATE_FLAG,
word, TS_US(1000), NULL, 0);
cnt_futex++;
}
return NULL;
}
static void *tf_futexb(void *a)
{
unsigned int word = 0;
while (running) {
syscall(SYS_futex, &word, FUTEX_WAIT_BITSET | FUTEX_PRIVATE_FLAG,
word, TS_US(2000), NULL, 0xffffffff);
cnt_futexb++;
}
return NULL;
}
static void *tf_rqi(void *a)
{
unsigned int u1 = 0, u2 = 0;
while (running) {
syscall(SYS_futex, &u1, FUTEX_WAIT_REQUEUE_PI | FUTEX_PRIVATE_FLAG,
0, TS_US(2000), &u2, 0);
cnt_rqi++;
}
return NULL;
}
static void *tf_nano(void *a)
{
while (running) {
nanosleep(TS_US(1000), NULL);
cnt_nano++;
}
return NULL;
}
static void *tf_epoll(void *a)
{
int fd = epoll_create1(0);
struct epoll_event ev;
while (running && fd >= 0) {
epoll_wait(fd, &ev, 0, 1);
cnt_epoll++;
}
return NULL;
}
static void *tf_aio(void *a)
{
unsigned long ctx = 0;
if (syscall(SYS_io_setup, 4, &ctx) < 0)
return NULL;
while (running) {
syscall(SYS_io_getevents, ctx, 0, 0, NULL, TS_US(2000));
cnt_aio++;
}
return NULL;
}
int main(int argc, char **argv)
{
pthread_t th[64];
int n = 0, i;
if (argc > 1) n_threads = atoi(argv[1]);
if (argc > 2) secs = atoi(argv[2]);
if (n_threads < 1) n_threads = 1;
if (n_threads > 8) n_threads = 8;
for (i = 0; i < n_threads; i++) {
pthread_create(&th[n++], NULL, tf_futex, NULL);
pthread_create(&th[n++], NULL, tf_futexb, NULL);
pthread_create(&th[n++], NULL, tf_rqi, NULL);
pthread_create(&th[n++], NULL, tf_nano, NULL);
pthread_create(&th[n++], NULL, tf_epoll, NULL);
pthread_create(&th[n++], NULL, tf_aio, NULL);
}
printf("FXFUT: %d threads x 6 kinds, %ds\n", n_threads, secs);
fflush(stdout);
sleep(secs);
running = 0;
for (i = 0; i < n; i++)
pthread_join(th[i], NULL);
printf("FXFUT: totals futex=%ld futexb=%ld rqi=%ld nano=%ld epoll=%ld aio=%ld\n",
cnt_futex, cnt_futexb, cnt_rqi, cnt_nano, cnt_epoll, cnt_aio);
fflush(stdout);
return 0;
}
^ permalink raw reply [flat|nested] 32+ messages in thread* cgroup: fork vs cgroup.procs write race in favordynmods mode -- v7.3 is affected; please make sure bf6fbf46b5ff reaches the stable trees (repro incl. uninstrumented natural hit)
2026-10-27 14:12 ` Greg KH
2026-11-03 11:21 ` mm/slub: has_pcs_used() reads remote per-CPU sheaf state from flush_all_cpus_locked() without synchronization (data race; KCSAN blind spot: slub.o is uninstrumented) 徐晗翔
2026-11-03 11:30 ` futex/hrtimer: hrtimer_sleeper::task is read unsynchronized in 16 places on v7.3 -- the accessor series that fixes this is not in 7.3; please make sure it reaches the affected trees (backport nudge + stock-kernel KCSAN evidence) 徐晗翔
@ 2026-11-03 11:48 ` 徐晗翔
2026-11-03 14:51 ` Sasha Levin
2026-11-03 11:57 ` net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3 徐晗翔
` (18 subsequent siblings)
21 siblings, 1 reply; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 11:48 UTC (permalink / raw)
To: tj, hannes, mkoutny; +Cc: cgroups, linux-kernel, stable
[-- Attachment #1: Type: text/plain, Size: 4111 bytes --]
In the per-threadgroup-rwsem mode (CONFIG_CGROUP_FAVOR_DYNMODS or the
cgroup2 mount option favordynmods), v7.3 publishes a forked child's
PID before cgroup_post_fork() links it into its css_set
(fork.c:2572 vs cgroup.c:7085->928-929); a concurrent writer to the
destination cgroup's cgroup.procs can migrate the child inside that
window, double-linking task_struct::cg_list and corrupting css_set
accounting -- after the child exits, css_task_iter_advance() walks a
dangling pointer (use-after-free on the child's task_struct; after
SLUB reuse this is a write primitive in principle). This is fixed
upstream by bf6fbf46b5ff ("cgroup: serialize migrations of newly
forked processes", merged 2026-10-20, already tagged Cc: stable #
6.18+), which v7.3 predates. I am writing to support that backport
with reproduction evidence on a pristine 7.3 KASAN kernel: a
controlled window hits all three of the kernel's own WARN tripwires
(cgroup.c:2601 in cgroup_migrate_add_task, :7083 in
cgroup_post_fork, :915 in css_set_move_task) and produces 648
slab-use-after-free reports in css_task_iter_advance/_next (victim =
the child's 5896-byte task_struct, alloc copy_process, free
release_task); more importantly, a completely uninstrumented
userspace-only hammer (4 forker threads + 2 cgroup.procs writers,
preempt=full) hits the same three tripwires with identical offsets
within ~40 seconds and then takes a secondary NULL-deref in
css_set_move_task -- the race is naturally reachable by ordinary
userspace, with no KASAN required to notice (the WARNs fire on
stock kernels too). The migration succeeding is what corrupts;
even a later failed/rolled-back migration leaves the damage.
The detailed description:
* The window (v7.3)
fork(): the child's PID becomes visible (fork.c:2572 area) before
cgroup_post_fork() runs and does css_set_move_task/link into
cg_list (cgroup.c:7085 -> 928-929). In favordynmods mode nothing
serializes a cgroup.procs writer against this window (that is what
bf6fbf46b5ff adds). The writer's cgroup_migrate_add_task finds
the "published but unlinked" child (list_empty(cg_list) true --
WARN at :2601), links it to the destination; cgroup_post_fork then
links it again (WARN at :7083), and css_set_move_task takes its
from_cset==NULL branch (WARN at :915): cg_list is on two lists,
nr_tasks is double-counted. After the child exits and is reaped,
the destination's task list retains a dangling pointer that
css_task_iter walks (the UAF), and unrelated later migrations can
crash on the corrupted state (observed: NULL-deref at
css_set_move_task+0x8a).
* Evidence (pristine 7.3 KASAN; reproducer attached)
Controlled (r1stall.ko parks the forker at cgroup_post_fork's
entry -- the window's end boundary -- and hands the child PID to
userspace; r1poc writes it to dst/cgroup.procs inside the window):
WARNING: cgroup.c:2601 at cgroup_migrate_add_task (writer side)
... write ret=3, dst membership: YES (migration
completed inside the window)
WARNING: cgroup.c:7083 at cgroup_post_fork (forker side)
WARNING: cgroup.c:915 at css_set_move_task (double link)
... child exits ...
BUG: KASAN: slab-use-after-free in css_task_iter_advance+0xb8
(x648, css_task_iter_advance/_next family; victim task_struct,
alloc copy_process, free release_task)
Natural (NO module loaded, preempt=full, pure userspace): same
three WARNs with identical function+offset signatures at ~40 s
into the hammer (writer thread hits :2601, forker thread hits
:7083/:915 -- the interleaving itself captured), followed by
BUG: KASAN: null-ptr-deref in css_set_move_task+0x8a as a later
legitimate migration meets the corrupted state; the VM eventually
became unstable. Negative control (favordynmods active, normal
migration of a fully-linked child): zero warnings, zero KASAN.
Reproducers attached: r1stall.c (kprobe window module) and r1poc.c
(static userspace orchestrator with ctl/base/nat modes; the nat
mode needs no module at all).
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: r1stall.c --]
[-- Type: text/plain, Size: 6027 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* r1stall.c - controlled-window instrument for R1 (fork vs cgroup.procs
* migration race under the favordynmods per-threadgroup rwsem).
*
* Audited bug (R1, v7.3, fixed upstream by bf6fbf46b5ff):
* copy_process() publishes the child PID (attach_pid + tasklist_unlock,
* fork.c) BEFORE cgroup_post_fork() links the child into its css_set
* (css_set_move_task, cgroup.c:7085). With cgroup_enable_per_threadgroup_
* rwsem active, a cgroup.procs writer only takes the child's own fresh,
* uncontended per-threadgroup rwsem (cgroup_attach_lock PER_THREADGROUP),
* while the forker holds only the global read lock + the PARENT's per-tg
* read lock -- the two lock sets are disjoint, so the migration can run
* inside that window: cgroup_migrate_add_task() list_move_tail()s the
* still-orphan child->cg_list onto the source css_set's mg_tasks list,
* and cgroup_migrate_execute() relinks it into the destination css_set
* and rewrites child->cgroups. When the forker then runs
* cgroup_post_fork(), css_set_move_task(child, NULL, cset, false) takes
* the from_cset==NULL path, which SKIPS list_del_init and directly
* list_add_tail()s the already-linked node onto cset->tasks:
* double linkage of one cg_list node in two css_set lists.
*
* This module stalls the forking thread exactly inside that window by
* busy-waiting in a kprobe pre_handler at the ENTRY of cgroup_post_fork
* (the window's end boundary). This enlarges the natural window to an
* arbitrary controlled duration, following the established controlled-
* window methodology of this environment (see r4_stall.c precedent).
* It does not by itself prove natural hit rates; those are exercised by
* the r1poc natural-hammer mode.
*
* Hand-off protocol with userspace (r1poc):
* 1. userspace writes 1 to /sys/module/r1stall/parameters/arm
* 2. the NEXT user fork hits the probe: pre_handler claims the arm
* (atomic cmpxchg), records the child's pid where userspace can read
* it (child_pid), and spins (cpu_relax, IRQs on, preempt off) until
* userspace writes 1 to .../release or stall_ms elapses
* 3. userspace (a helper process, on another CPU) reads child_pid,
* performs the cgroup.procs migration of that pid, then releases
* 4. cgroup_post_fork() executes on the corrupted state
*
* Kernel-thread forks are passed through untouched (PF_KTHREAD check
* BEFORE claiming the arm) so stray kworkers cannot consume the stall.
*/
#define pr_fmt(fmt) "R1STALL: " fmt
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kprobes.h>
#include <linux/sched.h>
#include <linux/jiffies.h>
#include <linux/atomic.h>
static int child_pid;
static int stall_ms = 4000;
module_param(stall_ms, int, 0600);
MODULE_PARM_DESC(stall_ms, "safety cap on the stall duration (ms)");
static atomic_t armed = ATOMIC_INIT(0);
static atomic_t release = ATOMIC_INIT(0);
static atomic_t taken = ATOMIC_INIT(0);
static int arm_set(const char *val, const struct kernel_param *kp)
{
int v, ret;
ret = kstrtoint(val, 0, &v);
if (ret)
return ret;
if (v) {
/* fresh arming: clear leftover release and pid */
atomic_set(&release, 0);
WRITE_ONCE(child_pid, 0);
atomic_set(&armed, 1);
} else {
atomic_set(&armed, 0);
}
return 0;
}
static int arm_get(char *buffer, const struct kernel_param *kp)
{
return sprintf(buffer, "%d\n", atomic_read(&armed));
}
static const struct kernel_param_ops arm_ops = {
.set = arm_set,
.get = arm_get,
};
module_param_cb(arm, &arm_ops, NULL, 0600);
MODULE_PARM_DESC(arm, "write 1 to arm the stall for the next user fork");
static int release_set(const char *val, const struct kernel_param *kp)
{
int v, ret;
ret = kstrtoint(val, 0, &v);
if (ret)
return ret;
if (v)
atomic_set(&release, 1);
return 0;
}
static int release_get(char *buffer, const struct kernel_param *kp)
{
return sprintf(buffer, "%d\n", atomic_read(&release));
}
static const struct kernel_param_ops release_ops = {
.set = release_set,
.get = release_get,
};
module_param_cb(release, &release_ops, NULL, 0600);
MODULE_PARM_DESC(release, "write 1 to release a stalled forker");
module_param(child_pid, int, 0400);
MODULE_PARM_DESC(child_pid, "pid of the child belonging to the stalled fork (read by userspace)");
static int stall_pre(struct kprobe *p, struct pt_regs *regs)
{
struct task_struct *child = (struct task_struct *)regs->di;
unsigned long deadline;
int h;
/* pass through when idle */
if (!atomic_read(&armed))
return 0;
/* never stall kernel-thread forks (they are not migratable anyway) */
if (!child || (child->flags & PF_KTHREAD))
return 0;
/* single taker */
if (atomic_cmpxchg(&armed, 1, 0) != 1)
return 0;
h = atomic_inc_return(&taken);
WRITE_ONCE(child_pid, child->pid);
pr_warn("stall #%d: forker pid=%d comm=%s stalled at cgroup_post_fork entry, child=%d; waiting for release (cap %d ms)\n",
h, current->pid, current->comm, child->pid, stall_ms);
deadline = jiffies + msecs_to_jiffies(stall_ms);
while (!atomic_read_acquire(&release) && time_before(jiffies, deadline))
cpu_relax();
pr_warn("stall #%d released (child=%d, %s)\n", h, child->pid,
atomic_read(&release) ? "by user" : "by TIMEOUT");
atomic_set(&release, 0);
return 0;
}
static struct kprobe kp = {
.symbol_name = "cgroup_post_fork",
.pre_handler = stall_pre,
};
static int __init r1stall_init(void)
{
int ret;
ret = register_kprobe(&kp);
if (ret) {
pr_err("cannot register kprobe on cgroup_post_fork: %d\n", ret);
return ret;
}
pr_info("armed kprobe on cgroup_post_fork (%px); stall_ms=%d\n",
kp.addr, stall_ms);
return 0;
}
static void __exit r1stall_exit(void)
{
unregister_kprobe(&kp);
pr_info("removed, stalls taken=%d\n", atomic_read(&taken));
}
module_init(r1stall_init);
module_exit(r1stall_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("controlled-window kprobe stall for R1 (fork vs cgroup.procs favordynmods race) verification");
[-- Attachment #3: r1poc.c --]
[-- Type: text/plain, Size: 10564 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* r1poc.c - userspace choreography for the R1 (fork vs cgroup.procs
* favordynmods per-threadgroup rwsem race) verification.
*
* Modes:
* r1poc ctl <dst_cgroup_procs>
* Controlled-window run (requires /r1stall.ko loaded).
* - forks a helper BEFORE arming; helper spins on the module's
* child_pid parameter, migrates the stalled child by writing its
* pid to <dst_cgroup_procs>, verifies dst membership, releases
* - main arms the stall and forks the victim child; the fork is
* stalled inside cgroup_post_fork() while the helper migrates
* - afterwards the child exits and main hammers reads of the dst
* cgroup.procs / cgroup.tasks (css_task_iter walks the corrupted
* list -> KASAN use-after-free if the freed task is hit)
*
* r1poc nat <dst_cgroup_procs> <secs> <n_forkers> <n_writers>
* Natural hammer, no instrumentation: forkers fork+reap in a tight
* loop; writers predict the next pid via /proc/sys/kernel/ns_last_pid
* and hammer writes into <dst_cgroup_procs>. Races the natural window
* between attach_pid() publication and cgroup_post_fork() linking
* (widened by proc connector broadcast, CONFIG_PROC_EVENTS=y).
*
* r1poc base <dst_cgroup_procs>
* Negative baseline: migrate a fully-forked (properly linked) child
* and read the dst lists afterwards. Validates the plumbing; must
* NOT produce the cgroup WARNs or any KASAN report.
*
* All output is prefixed "R1POC:" for log harvesting.
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <time.h>
#include <pthread.h>
#define P "/sys/module/r1stall/parameters/"
#define ARM_PATH P "arm"
#define RELEASE_PATH P "release"
#define CHILD_PID_PATH P "child_pid"
static void say(const char *fmt, ...)
{
va_list ap;
fputs("R1POC: ", stdout);
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
fputc('\n', stdout);
fflush(stdout);
}
static int write_file(const char *path, const char *buf)
{
int fd = open(path, O_WRONLY);
int n = -1;
if (fd < 0)
return -1;
n = write(fd, buf, strlen(buf));
close(fd);
return n;
}
static int read_file(const char *path, char *buf, int len)
{
int fd = open(path, O_RDONLY);
int n = -1;
if (fd < 0)
return -1;
n = read(fd, buf, len - 1);
if (n >= 0)
buf[n] = '\0';
close(fd);
return n;
}
static int write_int(const char *path, int v)
{
char buf[32];
snprintf(buf, sizeof(buf), "%d", v);
return write_file(path, buf);
}
static int read_int(const char *path, int *out)
{
char buf[64];
int n = read_file(path, buf, sizeof(buf));
if (n <= 0)
return -1;
*out = atoi(buf);
return 0;
}
static void dump_first_line(const char *tag, const char *path)
{
char buf[4096];
int n = read_file(path, buf, sizeof(buf));
if (n <= 0) {
say("%s read(%s) failed errno=%d", tag, path, errno);
return;
}
buf[200] = '\0';
for (char *p = buf; *p; p++)
if (*p == '\n') { *p = '|'; }
say("%s %s -> %.200s", tag, path, buf);
}
/* ------------------------------------------------------------------ */
/* controlled mode */
/* ------------------------------------------------------------------ */
static void ctl_helper(const char *dst, int mypid)
{
char buf[64];
int pid = 0, i, n, fd;
char rb[4096];
for (i = 0; i < 800; i++) { /* up to ~8s */
if (read_int(CHILD_PID_PATH, &pid) == 0 && pid > 0)
break;
usleep(10000);
}
if (pid <= 0) {
say("helper(pid=%d): TIMEOUT waiting for child_pid, releasing", mypid);
write_int(RELEASE_PATH, 1);
_exit(9);
}
say("helper(pid=%d): saw child_pid=%d, migrating it now", mypid, pid);
n = snprintf(buf, sizeof(buf), "%d", pid);
fd = open(dst, O_WRONLY);
if (fd < 0) {
say("helper: open(%s) FAILED errno=%d", dst, errno);
write_int(RELEASE_PATH, 1);
_exit(10);
}
errno = 0;
n = write(fd, buf, n);
close(fd);
say("helper: write(pid=%d) to %s -> ret=%d errno=%d", pid, dst, n, errno);
/* migration effect readback: the stalled child must now be listed */
n = read_file(dst, rb, sizeof(rb));
if (n > 0) {
char want[32];
int found = 0;
snprintf(want, sizeof(want), "%d", pid);
/* crude token scan */
for (char *p = strtok(rb, " \t\n"); p; p = strtok(NULL, " \t\n"))
if (!strcmp(p, want))
found = 1;
say("helper: dst membership of pid=%d after migration: %s",
pid, found ? "YES (child now belongs to dst)" : "NO");
} else {
say("helper: dst readback failed errno=%d", errno);
}
say("helper: releasing stalled forker");
write_int(RELEASE_PATH, 1);
_exit(0);
}
static int ctl_mode(const char *dst)
{
pid_t helper, child;
int i, st;
say("CTL-MODE-START dst=%s", dst);
helper = fork();
if (helper == 0) {
ctl_helper(dst, getpid());
_exit(0); /* not reached */
}
usleep(200000); /* helper is up and spinning */
say("main: arming stall, then forking victim child");
if (write_int(ARM_PATH, 1) < 0) {
say("main: arm write FAILED errno=%d (module loaded?)", errno);
return 1;
}
child = fork(); /* stalls inside cgroup_post_fork */
if (child == 0) {
say("child(pid=%d): alive after cgroup_post_fork, exiting now", getpid());
fflush(stdout);
_exit(42);
}
say("main: fork returned child=%d (stall released)", child);
if (waitpid(child, &st, 0) < 0)
say("main: waitpid errno=%d", errno);
else
say("main: child reaped, exit status=%d", WEXITSTATUS(st));
kill(helper, SIGKILL);
waitpid(helper, &st, 0);
say("main: sleeping 3s (RCU GP + KASAN quarantine) then hammering dst list reads");
sleep(3);
for (i = 0; i < 100; i++) {
dump_first_line("walk", dst);
usleep(20000);
}
dump_first_line("walk", "/sys/fs/cgroup/cgroup.procs");
say("CTL-MODE-DONE");
return 0;
}
/* ------------------------------------------------------------------ */
/* baseline (negative control) */
/* ------------------------------------------------------------------ */
static int base_mode(const char *dst)
{
char buf[64], rb[4096];
pid_t child;
int n, fd, st, i, found;
char want[32];
say("BASE-MODE-START dst=%s (no arming, plain post-fork migration)", dst);
child = fork();
if (child == 0) {
sleep(1);
_exit(7);
}
usleep(100000); /* child fully forked & linked */
n = snprintf(buf, sizeof(buf), "%d", child);
fd = open(dst, O_WRONLY);
if (fd < 0) {
say("base: open(%s) FAILED errno=%d", dst, errno);
return 1;
}
errno = 0;
n = write(fd, buf, n);
close(fd);
say("base: write(pid=%d) ret=%d errno=%d", child, n, errno);
n = read_file(dst, rb, sizeof(rb));
found = 0;
snprintf(want, sizeof(want), "%d", child);
if (n > 0)
for (char *p = strtok(rb, " \t\n"); p; p = strtok(NULL, " \t\n"))
if (!strcmp(p, want))
found = 1;
say("base: dst membership of pid=%d: %s", child, found ? "YES" : "NO");
waitpid(child, &st, 0);
say("base: child reaped (%d), sleeping 2s then walking dst lists", WEXITSTATUS(st));
sleep(2);
for (i = 0; i < 20; i++) {
dump_first_line("walk", dst);
usleep(50000);
}
say("BASE-MODE-DONE");
return 0;
}
/* ------------------------------------------------------------------ */
/* natural hammer */
/* */
/* NOTE: /proc/sys/kernel/ns_last_pid needs CONFIG_CHECKPOINT_RESTORE */
/* which this kernel does not set; prediction is done cooperatively */
/* instead: forker threads publish the pid of the fork that just */
/* returned, writer threads hammer last_pid+1 / +2 (pids are */
/* sequential in this quiet VM, so the next fork's child is almost */
/* certainly one of those two numbers). */
/* ------------------------------------------------------------------ */
static volatile sig_atomic_t stop_flag;
static volatile long last_forked_pid;
static void onTerm(int sig) { stop_flag = 1; }
static void *nat_forker(void *arg)
{
(void)arg;
while (!stop_flag) {
pid_t c = fork();
if (c == 0)
_exit(0); /* immediate exit: highest pid churn */
if (c > 0) {
last_forked_pid = c;
waitpid(c, NULL, 0);
}
}
return NULL;
}
static void *nat_writer(void *arg)
{
char *dst = arg;
char b1[16], b2[16];
int n1, n2, fd;
fd = open(dst, O_WRONLY);
if (fd < 0)
return NULL;
while (!stop_flag) {
long t = last_forked_pid + 1;
/* hammer the two most likely next child pids; attempts
* before the child's attach_pid() fail fast with ESRCH,
* a success landing between attach_pid() publication and
* cgroup_post_fork() linking wins the race */
n1 = snprintf(b1, sizeof(b1), "%ld", t);
n2 = snprintf(b2, sizeof(b2), "%ld", t + 1);
write(fd, b1, n1);
write(fd, b2, n2);
}
close(fd);
return NULL;
}
static int nat_mode(const char *dst, int secs, int nf, int nw)
{
pthread_t ft[16], wt[16];
int i, r, w;
if (nf > 16) nf = 16;
if (nw > 16) nw = 16;
say("NAT-MODE-START dst=%s secs=%d fork_threads=%d writer_threads=%d",
dst, secs, nf, nw);
signal(SIGTERM, onTerm);
for (i = 0; i < nf; i++)
if ((r = pthread_create(&ft[i], NULL, nat_forker, NULL)) != 0)
say("NAT: forker thread %d create failed: %d", i, r);
for (i = 0; i < nw; i++)
if ((r = pthread_create(&wt[i], NULL, nat_writer, (void *)dst)) != 0)
say("NAT: writer thread %d create failed: %d", i, r);
for (i = 0; i < secs && !stop_flag; i++)
sleep(1);
say("NAT: hammer window over (last_forked_pid=%ld), joining", last_forked_pid);
stop_flag = 1;
for (i = 0; i < nf; i++)
pthread_join(ft[i], NULL);
for (i = 0; i < nw; i++)
pthread_join(wt[i], NULL);
w = 0;
say("NAT: sleeping 2s then walking dst lists");
sleep(2);
for (i = 0; i < 40 && !stop_flag; i++) {
dump_first_line("walk", dst);
usleep(50000);
}
say("NAT-MODE-DONE");
return w;
}
int main(int argc, char **argv)
{
if (argc < 3) {
say("usage: %s ctl|base|nat <dst_cgroup_procs> [secs nf nw]", argv[0]);
return 2;
}
setvbuf(stdout, NULL, _IONBF, 0);
if (!strcmp(argv[1], "ctl"))
return ctl_mode(argv[2]);
if (!strcmp(argv[1], "base"))
return base_mode(argv[2]);
if (!strcmp(argv[1], "nat")) {
int secs = argc > 3 ? atoi(argv[3]) : 30;
int nf = argc > 4 ? atoi(argv[4]) : 4;
int nw = argc > 5 ? atoi(argv[5]) : 4;
return nat_mode(argv[2], secs, nf, nw);
}
say("unknown mode %s", argv[1]);
return 2;
}
^ permalink raw reply [flat|nested] 32+ messages in thread* Re: cgroup: fork vs cgroup.procs write race in favordynmods mode -- v7.3 is affected; please make sure bf6fbf46b5ff reaches the stable trees (repro incl. uninstrumented natural hit)
2026-11-03 11:48 ` cgroup: fork vs cgroup.procs write race in favordynmods mode -- v7.3 is affected; please make sure bf6fbf46b5ff reaches the stable trees (repro incl. uninstrumented natural hit) 徐晗翔
@ 2026-11-03 14:51 ` Sasha Levin
0 siblings, 0 replies; 32+ messages in thread
From: Sasha Levin @ 2026-11-03 14:51 UTC (permalink / raw)
To: tj, hannes, mkoutny
Cc: Sasha Levin, cgroups, linux-kernel, stable, 徐晗翔
> This is fixed upstream by bf6fbf46b5ff ("cgroup: serialize migrations of newly
> forked processes", merged 2026-10-20, already tagged Cc: stable #
> 6.18+), which v7.3 predates.
Queued for 7.3 and 6.18, thanks.
--
Thanks,
Sasha
^ permalink raw reply [flat|nested] 32+ messages in thread
* net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3
2026-10-27 14:12 ` Greg KH
` (2 preceding siblings ...)
2026-11-03 11:48 ` cgroup: fork vs cgroup.procs write race in favordynmods mode -- v7.3 is affected; please make sure bf6fbf46b5ff reaches the stable trees (repro incl. uninstrumented natural hit) 徐晗翔
@ 2026-11-03 11:57 ` 徐晗翔
2026-11-03 15:49 ` Eric Dumazet
2026-11-04 15:03 ` Sasha Levin
2026-11-03 12:07 ` wifi: wilc1000: SDIO/SPI remove destroys the srcu_struct before wiphy_unregister() -- late cfg80211 callbacks write through dangling srcu state (unfixed upstream; controlled + natural Oops) 徐晗翔
` (17 subsequent siblings)
21 siblings, 2 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 11:57 UTC (permalink / raw)
To: davem, edumazet, kuba, pabeni; +Cc: netdev, linux-kernel, stable
[-- Attachment #1: Type: text/plain, Size: 4303 bytes --]
On v7.3, rtnl_link_unregister() removes the rtnl_link_ops from the
global list and immediately destroys the embedded srcu_struct and
frees the ops memory -- a concurrent RTM_GETLINK dump that already
found the ops (rtnl_link_ops_get(), rtnetlink.c:599 hand-off inside
an RCU read critical section at :595) but has not yet taken
ops->srcu will, after the free, execute __srcu_read_lock() /
__srcu_read_unlock() on freed memory: two reads of the destroyed
object (ssp->srcu_ctrp, ssp->sda) whose subsequent percpu counter
writes land wherever the freed percpu pointers point -- for a module
rtnl_link_ops, the ops (and its srcu) live in module memory, so
module_memfree() is the freeing side. Upstream already fixed this
with a synchronize_rcu() in rtnl_link_unregister() (the patch with
Fixes: 6b57ff21a310; its own comment: "Wait for rtnl_link_ops_get()
readers that found ops but have not yet taken ops->srcu"). v7.3
does not contain it. I verified both sides on a stock 7.3 KASAN
kernel: parking a real dump reader (driven by an ordinary
RTM_GETLINK + IFLA_LINKINFO dump request) exactly at the :599
hand-off and unregistering+freeing underneath produces three KASAN
slab-use-after-free reports (__srcu_read_lock+0xe, +0x25,
__srcu_read_unlock+0x1a; bad address = ops+0x10 = ops->srcu); and
rebuilding the SAME kernel with ONLY the upstream one-line fix
flips the outcome to zero reports, with the kfree demonstrably
delayed until the parked reader left its RCU section. Natural
hammering did not hit (the hand-off window is ~100 ns against a
~4 ms unregister -- it needs a preempted/preemptible-RCU reader),
which matches the fix's own reasoning; the controlled run models
exactly that preemption. The purpose of this mail is to support
the fix's propagation: trees still carrying the unfixed
rtnl_link_unregister() keep a cross-CPU UAF (write) plus potential
freed-module execution reachable by unprivileged netlink dumps
racing a module unload.
The detailed description:
* The gap (v7.3, net/core/rtnetlink.c)
rtnl_dump_ifinfo() runs lockless for dumps
(RTNL_FLAG_DUMP_UNLOCKED), takes rcu_read_lock() at :595, calls
linkinfo_to_kind_ops() -> rtnl_link_ops_get() which returns the
ops pointer at :599, and only then takes ops->srcu
(srcu_read_lock) to call ops->dumpit. rtnl_link_unregister()
holds rtnl_mutex, removes the ops from the list, calls
cleanup_srcu_struct(&ops->srcu) and returns to the module, whose
exit then frees the module memory. Nothing between the :599
hand-off and the srcu_read_lock is waited for on the unregister
side in 7.3 -- hence the fix's synchronize_rcu().
* Evidence (stock 7.3 KASAN, QEMU; A/B on the one-line fix;
reproducers attached)
rtnlrace.c registers a kzalloc'd rtnl_link_ops (kind "rtnlrace"),
parks a dump reader by kprobe at the exact hand-off (return
address rtnl_dump_ifinfo+0x374, ENDBR-adjusted), and unregisters
+ kfrees from another thread (modeling module_exit ->
module_memfree). nlr (userspace) sends the IFLA_LINKINFO dump
request that drives the reader path.
Defect kernel (stock 7.3):
park t=127041 (1004 ms) -> unregister+kfree at t=127249 (park
+208 ms, reader still parked 792 ms) -> park ends t=128045:
BUG: KASAN: slab-use-after-free in __srcu_read_lock+0xe
BUG: KASAN: slab-use-after-free in __srcu_read_lock+0x25
BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a
read stack: __srcu_read_lock <- rtnl_dump_ifinfo+0x374 <-
rtnl_dumpit <- netlink_dump <- rtnetlink_rcv_msg (task nlr)
bad address = object+0x10 == ops->srcu (matches the
disassembly lea 0x10(%rbx))
alloc: insmod kzalloc; free: unregister thread kfree
Fixed kernel (same sources, ONLY the upstream synchronize_rcu()
added to rtnl_link_unregister()):
park t=191305 -> park ENDS t=192306 -> unregister+kfree
t=192307 -- the order literally inverts -- zero KASAN reports,
dump completes with zero errors. Same module, same parking.
Natural runs (three samplings, up to ~267k dumps/reader plus
6-CPU preemption spinners): zero hits; the window arithmetic
(~100 ns hand-off vs ~4 ms unregister) explains it, and the
controlled park models precisely the preemption the upstream
fix's comment describes.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: nlr.c --]
[-- Type: text/plain, Size: 3485 bytes --]
// SPDX-License-Identifier: GPL-2.0+
/*
* nlr.c - userspace RTM_GETLINK dump hammer for the rtnl_link_ops_get()
* verification (audit-report Case 1).
*
* Sends NLM_F_DUMP RTM_GETLINK requests carrying a nested
* IFLA_LINKINFO{ IFLA_INFO_KIND=<kind> } filter, which drives exactly:
*
* rtnl_dump_ifinfo -> linkinfo_to_kind_ops -> rtnl_link_ops_get(kind)
* -> (inlined) list_for_each_entry_rcu + strcmp + srcu_read_lock(&ops->srcu)
*
* i.e. the audited reader path. The dump walk itself runs without RTNL
* (RTM_GETLINK is registered RTNL_FLAG_DUMP_UNLOCKED), so it is fully
* concurrent with rtnl_link_unregister().
*
* Usage: nlr <kind> [seconds]
*/
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#ifndef RTNL_FAMILY_MAX
#define RTNL_FAMILY_MAX 33
#endif
#define NLR_BUF 65536
static int mk_dump_req(char *b, int blen, const char *kind, uint32_t seq)
{
struct nlmsghdr *nh = (struct nlmsghdr *)b;
struct ifinfomsg *ifi;
struct rtattr *linfo, *ikind;
int klen = strlen(kind) + 1;
memset(b, 0, blen);
nh->nlmsg_len = NLMSG_LENGTH(sizeof(*ifi));
nh->nlmsg_type = RTM_GETLINK;
nh->nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
nh->nlmsg_seq = seq;
nh->nlmsg_pid = 0;
ifi = NLMSG_DATA(nh);
ifi->ifi_family = AF_UNSPEC;
/* nested IFLA_LINKINFO { IFLA_INFO_KIND = kind } */
linfo = (struct rtattr *)((char *)b + NLMSG_ALIGN(nh->nlmsg_len));
linfo->rta_type = IFLA_LINKINFO;
linfo->rta_len = RTA_LENGTH(0);
ikind = (struct rtattr *)((char *)linfo + RTA_ALIGN(linfo->rta_len));
ikind->rta_type = IFLA_INFO_KIND;
ikind->rta_len = RTA_LENGTH(klen);
memcpy(RTA_DATA(ikind), kind, klen);
linfo->rta_len = (char *)ikind + RTA_ALIGN(ikind->rta_len) - (char *)linfo;
nh->nlmsg_len = (char *)ikind + RTA_ALIGN(ikind->rta_len) - b;
return nh->nlmsg_len;
}
int main(int argc, char **argv)
{
const char *kind = argc > 1 ? argv[1] : "rtnlrace";
int secs = argc > 2 ? atoi(argv[2]) : 30;
int fd, iters = 0, errs = 0;
uint32_t seq = 1;
char req[NLR_BUF], buf[NLR_BUF];
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
time_t t0 = time(NULL);
fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (fd < 0) {
perror("socket");
return 1;
}
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
fprintf(stderr, "nlr: hammering RTM_GETLINK dumps with kind=%s for %ds\n",
kind, secs);
while (time(NULL) - t0 < secs) {
struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
struct iovec iov = { buf, sizeof(buf) };
struct msghdr msg = {
.msg_name = &sa, .msg_namelen = sizeof(sa),
.msg_iov = &iov, .msg_iovlen = 1,
};
int len = mk_dump_req(req, sizeof(req), kind, seq++);
ssize_t n;
if (sendto(fd, req, len, 0, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
errs++;
continue;
}
/* drain until DONE or timeout/error */
for (;;) {
n = recvmsg(fd, &msg, 0);
if (n <= 0) {
errs++;
break;
}
{
struct nlmsghdr *nh = (struct nlmsghdr *)buf;
for (; NLMSG_OK(nh, (unsigned)n);
nh = NLMSG_NEXT(nh, n)) {
if (nh->nlmsg_type == NLMSG_DONE ||
nh->nlmsg_type == NLMSG_ERROR)
goto next_iter;
}
}
}
next_iter:
iters++;
if ((iters & 0x3ff) == 0)
fprintf(stderr, "nlr: %d iters (%d errs)\n", iters, errs);
}
fprintf(stderr, "nlr: done: %d iters, %d errs\n", iters, errs);
return 0;
}
[-- Attachment #3: rtnlrace.c --]
[-- Type: text/plain, Size: 7729 bytes --]
// SPDX-License-Identifier: GPL-2.0+
/*
* rtnlrace.c - dynamic verification for the audit-report Case 1:
*
* rtnl_link_unregister() (net/core/rtnetlink.c:701-729) is missing the
* synchronize_rcu() that upstream "rtnetlink: wait for RCU readers in
* rtnl_link_unregister()" (Fixes: 6b57ff21a310) added.
*
* Reader window under test (rtnetlink.c:595-606, rtnl_link_ops_get inlined):
* rcu_read_lock(); // :595
* list_for_each_entry_rcu(...) // :597 found ops via RCU
* strcmp(ops->kind, kind) // :598 matched
* *srcu_index = srcu_read_lock(&ops->srcu); // :599 <-- handoff
* rcu_read_unlock(); // :606
*
* Controlled mode (stall_us > 0):
* - registers a kzalloc()'d rtnl_link_ops with kind "rtnlrace"
* (rtnl_link_register is EXPORT_SYMBOL_GPL), so a userspace RTM_GETLINK
* dump with IFLA_INFO_KIND=rtnlrace walks exactly the audited path.
* - a kprobe on __srcu_read_lock with return-address filter parks the dump
* reader exactly at the :599 handoff (inside the RCU read-side critical
* section, before any SRCU state is touched): the audited window, enlarged
* for deterministic observation (same technique as kmod/r4_stall.c).
* - the unregister kthread then performs what module_exit does:
* rtnl_link_unregister(ops) -> list_del_rcu + synchronize_srcu (returns
* immediately: the parked reader has NOT entered the SRCU region) +
* cleanup_srcu_struct (frees srcu_usage and the percpu sda, sets
* ssp->srcu_sup/sda to NULL, leaves srcu_ctrp dangling), then kfree(ops)
* (models module_memfree of the unloaded module's static storage; KASAN
* slab poisoning makes the post-free read observable).
* - the parked reader resumes: READ_ONCE(ssp->srcu_ctrp) on freed memory
* => KASAN use-after-free Read in __srcu_read_lock.
*
* Natural mode (stall_us == 0): no kprobe, no init registration. A kthread
* hammers kzalloc/rtnl_link_register/rtnl_link_unregister/kfree cycles of a
* kind-"rtnlrace" ops while userspace hammers dumps; the natural
* strcmp->srcu_read_lock window is the only interleaving (rmmod shape).
*
* Offsets for build-kasan-7.3/vmlinux (rtnl_link_ops_get fully inlined):
* rtnl_dump_ifinfo @ 0xffffffff826e8030 (size 0x925)
* call __srcu_read_lock @ +0x36f -> return address +0x374 (kprobe base skips ENDBR, live filter +0x370)
*/
#define pr_fmt(fmt) "RTNLRACE: " fmt
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kprobes.h>
#include <linux/delay.h>
#include <linux/slab.h>
#include <linux/srcu.h>
#include <net/rtnetlink.h>
#include <linux/kthread.h>
#include <linux/atomic.h>
static int stall_ms = 1000; /* 0 = natural mode */
module_param(stall_ms, int, 0600);
MODULE_PARM_DESC(stall_ms, "park the dump reader this long at the handoff (0=natural); implemented as udelay(10000) x N to dodge x86 __bad_udelay truncation for >20ms values");
static int unreg_delay_ms = 120;
module_param(unreg_delay_ms, int, 0600);
static int natural_secs = 90;
module_param(natural_secs, int, 0600);
static ulong ret_off = 0x370;
module_param(ret_off, ulong, 0600);
MODULE_PARM_DESC(ret_off, "return-address offset of the __srcu_read_lock call inside rtnl_dump_ifinfo");
static struct rtnl_link_ops *rops; /* controlled mode only */
static atomic_t hits = ATOMIC_INIT(0);
static atomic_t parked = ATOMIC_INIT(0);
static atomic_t freed = ATOMIC_INIT(0);
static atomic_t rounds = ATOMIC_INIT(0);
static unsigned long base_addr;
static struct kprobe kp;
static int probe_live;
static struct task_struct *uthr;
static int stall_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long ra = *(unsigned long *)regs->sp;
if (ra == base_addr + ret_off) {
int h = atomic_inc_return(&hits);
u64 t0 = ktime_to_ms(ktime_get());
int i;
atomic_set(&parked, 1);
if (h <= 3)
pr_warn("stall #%d: parking dump reader %d ms at handoff (ra=%lx == rtnl_dump_ifinfo+0x%lx) t=%llu\n",
h, stall_ms, ra, ret_off, t0);
for (i = 0; i < stall_ms / 10; i++)
udelay(10000);
if (h <= 3)
pr_warn("stall #%d: park END t=%llu (held %llu ms)\n",
h, ktime_to_ms(ktime_get()),
ktime_to_ms(ktime_get()) - t0);
} else if (ra > base_addr && ra < base_addr + 0x2000) {
static atomic_t dbg;
int d = atomic_inc_return(&dbg);
if (d <= 8)
pr_warn("near-caller #%d ra-off=0x%lx\n", d, ra - base_addr);
}
return 0;
}
static unsigned long sym_addr(const char *name)
{
struct kprobe probe = { .symbol_name = name, };
unsigned long addr = 0;
if (register_kprobe(&probe) == 0) {
addr = (unsigned long)probe.addr;
unregister_kprobe(&probe);
}
return addr;
}
static int unreg_thr(void *unused)
{
if (stall_ms > 0) {
/* controlled: wait until the kprobe parked the dump reader */
while (!atomic_read(&parked) && !kthread_should_stop())
msleep(5);
if (kthread_should_stop())
return 0;
msleep(unreg_delay_ms);
rtnl_link_unregister(rops);
/* protocol evidence: what did cleanup_srcu_struct leave behind?
* (rops itself is still allocated here, so these reads are safe) */
pr_warn("post-unregister t=%llu: ops=%px srcu.srcu_sup=%px srcu.sda=%px srcu.srcu_ctrp=%px\n",
ktime_to_ms(ktime_get()), rops, rops->srcu.srcu_sup, rops->srcu.sda,
rops->srcu.srcu_ctrp);
kfree(rops);
atomic_set(&freed, 1);
pr_warn("ops kfree()d (models module_memfree of the unloaded module); parked reader will resume on freed memory\n");
} else {
/* natural: hammer kzalloc/register/unregister/kfree (rmmod shape) */
u64 t0 = ktime_to_ms(ktime_get());
while (!kthread_should_stop() &&
ktime_to_ms(ktime_get()) - t0 < (u64)natural_secs * 1000) {
struct rtnl_link_ops *o = kzalloc(sizeof(*o), GFP_KERNEL);
if (!o)
break;
o->kind = "rtnlrace";
if (rtnl_link_register(o) == 0) {
rtnl_link_unregister(o);
kfree(o);
} else {
kfree(o);
}
atomic_inc(&rounds);
if ((atomic_read(&rounds) & 0x3ff) == 0)
pr_warn("natural round %d\n", atomic_read(&rounds));
}
pr_warn("natural done: %d register/unregister/kfree rounds\n",
atomic_read(&rounds));
}
return 0;
}
static int __init rtnlrace_init(void)
{
int ret;
if (stall_ms > 0) {
rops = kzalloc(sizeof(*rops), GFP_KERNEL);
if (!rops)
return -ENOMEM;
rops->kind = "rtnlrace";
ret = rtnl_link_register(rops);
if (ret) {
pr_err("rtnl_link_register failed: %d\n", ret);
kfree(rops);
rops = NULL;
return ret;
}
pr_warn("registered rtnl_link_ops kind=rtnlrace (%px); start: /nlr rtnlrace\n", rops);
base_addr = sym_addr("rtnl_dump_ifinfo");
if (!base_addr) {
pr_err("cannot resolve rtnl_dump_ifinfo\n");
return -EINVAL;
}
kp.symbol_name = "__srcu_read_lock";
kp.pre_handler = stall_pre;
ret = register_kprobe(&kp);
if (ret) {
pr_err("cannot register kprobe: %d\n", ret);
return ret;
}
probe_live = 1;
pr_warn("kprobe on __srcu_read_lock, park ra=rtnl_dump_ifinfo+0x%lx (%px)\n",
ret_off, (void *)(base_addr + ret_off));
}
uthr = kthread_run(unreg_thr, NULL, "rtnlrace_unreg");
if (IS_ERR(uthr))
return PTR_ERR(uthr);
pr_warn("armed: stall_ms=%d unreg_delay_ms=%d mode=%s\n", stall_ms,
unreg_delay_ms, stall_ms > 0 ? "controlled" : "natural");
return 0;
}
static void __exit rtnlrace_exit(void)
{
if (uthr)
kthread_stop(uthr);
if (probe_live)
unregister_kprobe(&kp);
if (stall_ms > 0 && atomic_read(&freed) == 0 && rops) {
rtnl_link_unregister(rops);
kfree(rops);
}
pr_warn("module exit (hits=%d rounds=%d)\n", atomic_read(&hits),
atomic_read(&rounds));
}
module_init(rtnlrace_init);
module_exit(rtnlrace_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* Re: net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3
2026-11-03 11:57 ` net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3 徐晗翔
@ 2026-11-03 15:49 ` Eric Dumazet
2026-11-04 15:03 ` Sasha Levin
1 sibling, 0 replies; 32+ messages in thread
From: Eric Dumazet @ 2026-11-03 15:49 UTC (permalink / raw)
To: 徐晗翔
Cc: davem, kuba, pabeni, netdev, linux-kernel, stable
Le mar. 3 nov. 2026 à 12:57, 徐晗翔 <xuhx@hust.edu.cn> a écrit :
>
> On v7.3, rtnl_link_unregister() removes the rtnl_link_ops from the
> global list and immediately destroys the embedded srcu_struct and
> frees the ops memory -- a concurrent RTM_GETLINK dump that already
> found the ops (rtnl_link_ops_get(), rtnetlink.c:599 hand-off inside
> an RCU read critical section at :595) but has not yet taken
> ops->srcu will, after the free, execute __srcu_read_lock() /
> __srcu_read_unlock() on freed memory: two reads of the destroyed
> object (ssp->srcu_ctrp, ssp->sda) whose subsequent percpu counter
> writes land wherever the freed percpu pointers point -- for a module
> rtnl_link_ops, the ops (and its srcu) live in module memory, so
> module_memfree() is the freeing side. Upstream already fixed this
> with a synchronize_rcu() in rtnl_link_unregister() (the patch with
> Fixes: 6b57ff21a310; its own comment: "Wait for rtnl_link_ops_get()
> readers that found ops but have not yet taken ops->srcu"). v7.3
> does not contain it. I verified both sides on a stock 7.3 KASAN
> kernel: parking a real dump reader (driven by an ordinary
> RTM_GETLINK + IFLA_LINKINFO dump request) exactly at the :599
> hand-off and unregistering+freeing underneath produces three KASAN
> slab-use-after-free reports (__srcu_read_lock+0xe, +0x25,
> __srcu_read_unlock+0x1a; bad address = ops+0x10 = ops->srcu); and
> rebuilding the SAME kernel with ONLY the upstream one-line fix
> flips the outcome to zero reports, with the kfree demonstrably
> delayed until the parked reader left its RCU section. Natural
> hammering did not hit (the hand-off window is ~100 ns against a
> ~4 ms unregister -- it needs a preempted/preemptible-RCU reader),
> which matches the fix's own reasoning; the controlled run models
> exactly that preemption. The purpose of this mail is to support
> the fix's propagation: trees still carrying the unfixed
> rtnl_link_unregister() keep a cross-CPU UAF (write) plus potential
> freed-module execution reachable by unprivileged netlink dumps
> racing a module unload.
>
>
> The detailed description:
>
> * The gap (v7.3, net/core/rtnetlink.c)
> rtnl_dump_ifinfo() runs lockless for dumps
> (RTNL_FLAG_DUMP_UNLOCKED), takes rcu_read_lock() at :595, calls
> linkinfo_to_kind_ops() -> rtnl_link_ops_get() which returns the
> ops pointer at :599, and only then takes ops->srcu
> (srcu_read_lock) to call ops->dumpit. rtnl_link_unregister()
> holds rtnl_mutex, removes the ops from the list, calls
> cleanup_srcu_struct(&ops->srcu) and returns to the module, whose
> exit then frees the module memory. Nothing between the :599
> hand-off and the srcu_read_lock is waited for on the unregister
> side in 7.3 -- hence the fix's synchronize_rcu().
>
> * Evidence (stock 7.3 KASAN, QEMU; A/B on the one-line fix;
> reproducers attached)
> rtnlrace.c registers a kzalloc'd rtnl_link_ops (kind "rtnlrace"),
> parks a dump reader by kprobe at the exact hand-off (return
> address rtnl_dump_ifinfo+0x374, ENDBR-adjusted), and unregisters
> + kfrees from another thread (modeling module_exit ->
> module_memfree). nlr (userspace) sends the IFLA_LINKINFO dump
> request that drives the reader path.
> Defect kernel (stock 7.3):
> park t=127041 (1004 ms) -> unregister+kfree at t=127249 (park
> +208 ms, reader still parked 792 ms) -> park ends t=128045:
> BUG: KASAN: slab-use-after-free in __srcu_read_lock+0xe
> BUG: KASAN: slab-use-after-free in __srcu_read_lock+0x25
> BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a
> read stack: __srcu_read_lock <- rtnl_dump_ifinfo+0x374 <-
> rtnl_dumpit <- netlink_dump <- rtnetlink_rcv_msg (task nlr)
> bad address = object+0x10 == ops->srcu (matches the
> disassembly lea 0x10(%rbx))
> alloc: insmod kzalloc; free: unregister thread kfree
> Fixed kernel (same sources, ONLY the upstream synchronize_rcu()
> added to rtnl_link_unregister()):
> park t=191305 -> park ENDS t=192306 -> unregister+kfree
> t=192307 -- the order literally inverts -- zero KASAN reports,
> dump completes with zero errors. Same module, same parking.
> Natural runs (three samplings, up to ~267k dumps/reader plus
> 6-CPU preemption spinners): zero hits; the window arithmetic
> (~100 ns hand-off vs ~4 ms unregister) explains it, and the
> controlled park models precisely the preemption the upstream
> fix's comment describes.
>
What is the point of this email ? This looks like LLM slope to me.
I think we have far more serious bugs to fix.
3b70edea393d was tagged stable+noautosel on purpose: the race needs
a module unload (CAP_SYS_MODULE in init_user_ns) to line up with a
~100ns window, which your report also shows you could not hit
without a kprobe. It is not reachable by an unprivileged user alone.
^ permalink raw reply [flat|nested] 32+ messages in thread
* Re: net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3
2026-11-03 11:57 ` net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3 徐晗翔
2026-11-03 15:49 ` Eric Dumazet
@ 2026-11-04 15:03 ` Sasha Levin
1 sibling, 0 replies; 32+ messages in thread
From: Sasha Levin @ 2026-11-04 15:03 UTC (permalink / raw)
To: davem, edumazet, kuba, pabeni
Cc: Sasha Levin, netdev, linux-kernel, stable, 徐晗翔
On 2026-11-03, 徐晗翔 <xuhx@hust.edu.cn> wrote:
> Upstream already fixed this with a synchronize_rcu() in rtnl_link_unregister()
Queued for 7.3 and 6.18, thanks.
--
Thanks,
Sasha
^ permalink raw reply [flat|nested] 32+ messages in thread
* wifi: wilc1000: SDIO/SPI remove destroys the srcu_struct before wiphy_unregister() -- late cfg80211 callbacks write through dangling srcu state (unfixed upstream; controlled + natural Oops)
2026-10-27 14:12 ` Greg KH
` (3 preceding siblings ...)
2026-11-03 11:57 ` net: rtnetlink: rtnl_link_unregister() vs in-flight RTM_GETLINK dump readers -- v7.3 lacks the synchronize_rcu() fix (Fixes: 6b57ff21a310 family); A/B reproduction on stock 7.3 徐晗翔
@ 2026-11-03 12:07 ` 徐晗翔
2026-11-03 12:10 ` gpio: gpiolib: final put of the gpio_device happens inside the SRCU read critical section on the setup-failure path -- deterministic use-after-free read at guard release (unfixed upstream) 徐晗翔
` (16 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 12:07 UTC (permalink / raw)
To: ajay.kathat, claudiu.beznea; +Cc: linux-wireless, linux-kernel, netdev
[-- Attachment #1: Type: text/plain, Size: 4022 bytes --]
In wilc1000's SDIO/SPI remove path, the driver destroys its
srcu_struct (cleanup_srcu_struct at sdio.c:228-229, via
wlan_deinit_locks) BEFORE wiphy_unregister() tears down the wiphy --
so any cfg80211 callback that arrives late and holds only the wiphy
lock (which nl80211 releases before dispatching, nl80211.c:4303)
will execute srcu_read_lock()/srcu_read_unlock() on an already
destroyed srcu_struct whose backing percpu memory is freed: the
counter writes in __srcu_read_lock/unlock land on
__per_cpu_offset[cpu]+{0,16} -- the first bytes of the current
CPU's percpu area, corrupting whatever lives there. I verified the
mechanism on a stock 7.3 KASAN kernel with a field/lock-faithful
simulator of the two sides: the controlled run pins the reader
inside the callback (holding only the simulated wiphy_lock), runs
the real cleanup_srcu_struct() underneath, then deliberately
re-allocates percpu memory at the freed sda address -- the reader's
subsequent srcu_read_lock increments a counter inside that
replacement object (read back as +1: cross-object percpu corruption
demonstrated); the natural run, with no instrumentation at all,
crashes the kernel in under one second of reader/init/cleanup churn
with a page-fault write Oops in __srcu_read_unlock+0x25 whose
disassembly state matches the dangling-state chain instruction for
instruction (RAX=0 -> sda==NULL, RSI=0x10, incq %gs:0x8(%rax,%rsi)
-> a write into the first 32 bytes of the CPU's percpu area). The
fix is the ordering: wiphy_unregister() (which waits for the wiphy
srcu readers) must happen before cleanup_srcu_struct(). As far as I
can tell this is unfixed in mainline.
The detailed description:
* The ordering bug (Linux 7.3)
drivers/net/wireless/microchip/wilc1000/sdio.c remove path (SPI
variant identical): ... -> wilc_netdev_cleanup ->
wlan_deinit_locks -> cleanup_srcu_struct(&wilc->srcu) (sdio.c:
228-229), while the wiphy is still registered. cfg80211 dispatch
(e.g. set_wiphy_params, whose first statement is
srcu_read_lock(cfg80211.c:803); likewise wilc_set_wakeup) holds
only wiphy_lock -- rtnl is already released at nl80211.c:4303 --
so the driver's remove path and a late callback share no lock.
* What the destroyed-srcu accesses do
cleanup_srcu_struct() frees the sda percpu arrays (or leaks them
into the WARN branch when a reader is caught inside, which is
itself observable) and leaves srcu_ctrp dangling or the unlock
path reading sda==NULL: __srcu_read_unlock's
this_cpu_inc(&sdp->locks[...]) then computes GS-base+0x8(0,16) --
a blind percpu write. Both jumps of the hazard chain were
demonstrated (see below).
* Evidence (stock 7.3 KASAN; simulator attached)
wilcsim.c mirrors the real field/lock structure
(sim wiphy_lock <-> wiphy_lock; reader parked at the callback's
srcu_read_lock; remover executing the real cleanup_srcu_struct()
without any of the reader's locks -- faithful to "no common lock"
in the real paths):
Controlled: cleanup completes with sda freed and ctrp dangling;
a same-size alloc_percpu lands at the EXACT freed sda address
(zero retries) carrying markers; the released reader's
srcu_read_lock executes and the marker on cpu3 reads back
ctr[0].locks=+1 -- the dangling counter write, captured inside
the replacement object.
Natural (no kprobe, reader lock/unlock loop vs init/cleanup
churn): by round ~768 an interleaving hits: Oops 0002 (write
fault), RIP __srcu_read_unlock+0x25, RAX=0, RSI=0x10, instruction
65 48 ff 44 30 08 -- writing GS-base+0x18, i.e. inside the first
32 bytes of the CPU's percpu area. Kernel dead <1 s into the
churn. (Three WARNING: srcutree.c:696 events during the churn
additionally show the remover catching readers mid-section -- the
defensive leak branch.)
Fidelity note: real wilc1000 hardware was not available; the
simulator reproduces the mechanism (the exact srcu API calls and
lock topology of the real two paths) on the real library code.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: wilcsim.c --]
[-- Type: text/plain, Size: 8436 bytes --]
// SPDX-License-Identifier: GPL-2.0+
/*
* wilcsim.c - dynamic verification for the audit-report Case 2 (wilc1000):
*
* wilc_sdio_remove()/wilc_bus_remove() run wilc_netdev_cleanup() (which
* ends in cleanup_srcu_struct(&wilc->srcu), cfg80211.c:1757) BEFORE
* wiphy_unregister() (sdio.c:228-229). While the wiphy is still
* registered, cfg80211 callbacks can still enter and several of them
* (set_wiphy_params cfg80211.c:803, wilc_set_wakeup :1626) take
* wl->srcu as their first statement, dispatched under wiphy_lock only
* (nl80211_set_wiphy releases rtnl at nl80211.c:4303 before the callback;
* SET_WOWLAN has no NEED_RTNL) - the remove path holds neither.
*
* This module reproduces the exact interleaving shape with a simulated
* private struct (struct wilc equivalent) and a simulated wiphy_lock,
* and makes the otherwise-silent post-cleanup writes OBSERVABLE:
*
* Controlled mode (default):
* reader kthread = the cfg80211 callback: takes sim_wiphy_lock, "parks"
* at the audited point (before srcu_read_lock), then
* resumes on the destroyed srcu.
* remover kthread = wilc_sdio_remove: with the reader parked (and NOT
* holding any lock the remover needs - faithful),
* cleanup_srcu_struct() frees srcu_usage + percpu sda.
* Then the percpu allocator is asked for a same-size allocation until it
* hands back EXACTLY the freed sda address (percpu block reuse), and the
* reader resumes: its stale srcu_ctrp this_cpu_inc lands inside the new
* object => directly readable cross-object percpu corruption (the
* audited harm chain link 1). The unlock path additionally writes at
* percpu offset 0/16 (link 2) because ssp->sda == NULL.
*
* Natural mode (natural_secs>0 & controlled=0): reader loops
* lock/unlock and remover loops init/cleanup without any parking; the
* post-cleanup unlock writes at percpu offset 0/16 corrupt the first
* percpu symbol of the executing CPU -> expected rapid crash (severity
* demonstration).
*/
#define pr_fmt(fmt) "WILCSIM: " fmt
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/srcu.h>
#include <linux/mutex.h>
#include <linux/kthread.h>
#include <linux/completion.h>
#include <linux/atomic.h>
#include <linux/delay.h>
#include <linux/cpumask.h>
struct sim_wl {
struct mutex wiphy_lock_sim; /* models wiphy->mtx (nl80211 dispatch) */
struct srcu_struct srcu; /* models wl->srcu */
};
static int controlled = 1;
module_param(controlled, int, 0600);
MODULE_PARM_DESC(controlled, "1=controlled parking interleave, 0=natural hammer");
static int natural_secs = 30;
module_param(natural_secs, int, 0600);
static int reuse_tries = 128;
module_param(reuse_tries, int, 0600);
static struct sim_wl *wl;
static struct srcu_data __percpu *marker; /* reallocated over freed sda */
static struct completion parked;
static struct completion go;
static struct completion reader_done;
static atomic_t natural_rounds = ATOMIC_INIT(0);
static atomic_t natural_warns = ATOMIC_INIT(0);
static long ctr_read(int cpu, int slot, int which)
{
struct srcu_data *sd = per_cpu_ptr(marker, cpu);
if (slot == 0 && which == 0)
return atomic_long_read(&sd->srcu_ctrs[0].srcu_locks);
if (slot == 0 && which == 1)
return atomic_long_read(&sd->srcu_ctrs[0].srcu_unlocks);
if (slot == 1 && which == 0)
return atomic_long_read(&sd->srcu_ctrs[1].srcu_locks);
return atomic_long_read(&sd->srcu_ctrs[1].srcu_unlocks);
}
static int reader_thr(void *unused)
{
int cpu;
if (controlled) {
mutex_lock(&wl->wiphy_lock_sim);
pr_info("reader parked inside callback (holds sim wiphy_lock; remove path needs neither lock)\n");
complete(&parked);
wait_for_completion(&go);
cpu = smp_processor_id();
{
struct srcu_ctr __percpu *ctrp =
READ_ONCE(wl->srcu.srcu_ctrp);
int idx;
long b00 = ctr_read(cpu, 0, 0), b01 = ctr_read(cpu, 0, 1);
long b10 = ctr_read(cpu, 1, 0), b11 = ctr_read(cpu, 1, 1);
pr_info("reader resumed: stale srcu_ctrp=%px (sda freed), executing srcu_read_lock on DESTROYED srcu\n",
ctrp);
idx = srcu_read_lock(&wl->srcu);
/* NOTE: srcu_read_unlock() deliberately NOT executed in the
* controlled run: with ssp->sda == NULL it would write at
* percpu offset 0/16 (the first percpu symbol of this CPU)
* and could kill the evidence path itself; that write is
* demonstrated live by the wilcnat natural run instead. */
pr_info("reader after lock (idx=%d): marker[cpu%d] deltas ctr[0].locks=%ld ctr[0].unlocks=%ld ctr[1].locks=%ld ctr[1].unlocks=%ld\n",
idx, cpu,
ctr_read(cpu, 0, 0) - b00,
ctr_read(cpu, 0, 1) - b01,
ctr_read(cpu, 1, 0) - b10,
ctr_read(cpu, 1, 1) - b11);
if (ctr_read(cpu, 0, 0) != b00 || ctr_read(cpu, 1, 0) != b10)
pr_warn("CROSS-OBJECT PERCPU CORRUPTION OBSERVED: stale srcu_ctrp write landed inside the reallocated percpu object\n");
}
mutex_unlock(&wl->wiphy_lock_sim);
complete(&reader_done);
} else {
while (!kthread_should_stop()) {
int idx;
mutex_lock(&wl->wiphy_lock_sim);
idx = srcu_read_lock(&wl->srcu);
srcu_read_unlock(&wl->srcu, idx);
mutex_unlock(&wl->wiphy_lock_sim);
cond_resched();
}
}
return 0;
}
static int remove_thr(void *unused)
{
struct srcu_data __percpu *old_sda;
int i;
if (controlled) {
wait_for_completion(&parked);
old_sda = wl->srcu.sda;
pr_info("remove path: cleanup_srcu_struct while reader parked in callback (pre sda=%px ctrp=%px)\n",
old_sda, wl->srcu.srcu_ctrp);
cleanup_srcu_struct(&wl->srcu);
pr_info("cleanup done: srcu.sda=%px srcu.srcu_sup=%px (NULL=NULL => freed) srcu_ctrp=%px (stale, never reset)\n",
wl->srcu.sda, wl->srcu.srcu_sup, wl->srcu.srcu_ctrp);
/* reallocate over the freed percpu block: same size, retry until
* the allocator hands back the exact address */
for (i = 0; i < reuse_tries; i++) {
marker = alloc_percpu(struct srcu_data);
if (!marker) {
pr_err("alloc_percpu failed\n");
break;
}
if (marker == old_sda)
break;
free_percpu(marker);
marker = NULL;
}
if (marker == old_sda && marker)
pr_info("marker reallocated at EXACT freed sda address %px after %d tries\n",
marker, i);
else
pr_warn("exact reuse not achieved (marker=%px old=%px tries=%d); chunk-level overlap only\n",
marker, old_sda, i);
complete(&go);
wait_for_completion(&reader_done);
/* full per-cpu marker scan */
for_each_possible_cpu(i) {
long v0 = ctr_read(i, 0, 0), v1 = ctr_read(i, 1, 0);
if (v0 || v1)
pr_warn("marker cpu%d: ctr[0].locks=%ld ctr[1].locks=%ld\n", i, v0, v1);
}
pr_info("controlled interleave complete\n");
} else {
u64 t0 = ktime_to_ms(ktime_get());
while (ktime_to_ms(ktime_get()) - t0 < (u64)natural_secs * 1000 &&
!kthread_should_stop()) {
cleanup_srcu_struct(&wl->srcu); /* may WARN+leak if reader inside */
if (atomic_read(&natural_warns) > 4096)
break;
init_srcu_struct(&wl->srcu);
atomic_inc(&natural_rounds);
if ((atomic_read(&natural_rounds) & 0xff) == 0)
pr_warn("natural round %d\n", atomic_read(&natural_rounds));
}
pr_warn("natural done: %d init/cleanup rounds\n",
atomic_read(&natural_rounds));
}
return 0;
}
static int __init wilcsim_init(void)
{
struct task_struct *r, *m;
int ret;
wl = kzalloc(sizeof(*wl), GFP_KERNEL);
if (!wl)
return -ENOMEM;
mutex_init(&wl->wiphy_lock_sim);
ret = init_srcu_struct(&wl->srcu);
if (ret) {
kfree(wl);
return ret;
}
init_completion(&parked);
init_completion(&go);
init_completion(&reader_done);
r = kthread_run(reader_thr, NULL, "wilcsim_reader");
m = kthread_run(remove_thr, NULL, "wilcsim_remove");
if (IS_ERR(r) || IS_ERR(m))
return IS_ERR(r) ? PTR_ERR(r) : PTR_ERR(m);
pr_warn("armed: mode=%s\n", controlled ? "controlled" : "natural");
return 0;
}
static void __exit wilcsim_exit(void)
{
/* natural mode: threads self-terminate after natural_secs; controlled:
* both completions are done. cleanup may leak sup intentionally. */
if (wl) {
/* best effort: if srcu still live, clean it */
if (wl->srcu.srcu_sup)
cleanup_srcu_struct(&wl->srcu);
if (marker)
free_percpu(marker);
kfree(wl);
}
pr_warn("module exit (natural_rounds=%d)\n", atomic_read(&natural_rounds));
}
module_init(wilcsim_init);
module_exit(wilcsim_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* gpio: gpiolib: final put of the gpio_device happens inside the SRCU read critical section on the setup-failure path -- deterministic use-after-free read at guard release (unfixed upstream)
2026-10-27 14:12 ` Greg KH
` (4 preceding siblings ...)
2026-11-03 12:07 ` wifi: wilc1000: SDIO/SPI remove destroys the srcu_struct before wiphy_unregister() -- late cfg80211 callbacks write through dangling srcu state (unfixed upstream; controlled + natural Oops) 徐晗翔
@ 2026-11-03 12:10 ` 徐晗翔
2026-11-05 8:52 ` Linus Walleij
2026-11-04 12:54 ` soc: qcom: pmic_glink_altmode: fix use-after-free on unbind (no cancel_work_sync for port work and enable_work) 徐晗翔
` (15 subsequent siblings)
21 siblings, 1 reply; 32+ messages in thread
From: 徐晗翔 @ 2026-11-03 12:10 UTC (permalink / raw)
To: linusw, brgl; +Cc: linux-gpio, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 3151 bytes --]
On gpiolib's setup-failure path, the guard that protects the
gpio_device lookup (srcu_read_lock at gpiolib.c:1094) is also held
while the device is being torn down: gpiochip_setup_devs()'s error
path drops the last reference (gpiodev_release at :893-906, which
calls cleanup_srcu_struct at :904 and kfree at :905) while the
caller's guard is still inside the read critical section -- so the
guard's own release (srcu_read_unlock) reads ssp->sda out of an
already freed gpio_device. In the hog path the refcount can be
exactly 1 at that point, which is the common case, so this is not
an exotic window: it is a sequential-ordering violation that fires
whenever that error path is taken, no concurrency needed. On a
stock 7.3 KASAN kernel, replaying gpiolib's exact single-threaded
statement sequence 1:1 (lock at the :1094 guard point -> the :904
cleanup -> the :905 kfree -> the guard's unlock) hits
WARNING: kernel/rcu/srcutree.c:696 at cleanup_srcu_struct (the
self-held-reader defensive branch: sda and srcu_sup leaked, not
freed), and the subsequent srcu_read_unlock produces
BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a reading
object+8 -- exactly the ssp->sda field of the freed container. The
setup-failure path also misses the list_del_rcu() that the success
path performs, so the destroyed device can remain reachable through
the global list. As far as I can tell this is unfixed upstream.
The detailed description:
* The ordering (Linux 7.3, drivers/gpio/gpiolib.c)
The caller takes the lookup guard (srcu_read_lock, :1094), then
gpiochip_setup_devs() fails (e.g. cdev/hog registration errors),
and the error path drops the final reference;
gpiodev_release() runs cleanup_srcu_struct(&gdev->srcu) at :904
and kfree(gdev) at :905 -- while the caller is still inside the
SRCU read critical section it took at :1094. When the caller's
guard destructs, srcu_read_unlock(&gdev->srcu, idx) touches the
freed object (reads ssp->sda at object offset +8). The hog path
leaves refcount==1 here as the common case (no other holders
yet), so the final put is genuinely final.
Secondary: the failure path lacks the list_del_rcu() the success
path performs, leaving the freed device linked.
* Evidence (stock 7.3 KASAN; deterministic, single-threaded;
reproducer attached)
gpiodevsim.c replays gpiolib's own sequence with a kzalloc'd
container of the same layout: srcu_read_lock (the :1094 guard) ->
cleanup_srcu_struct (the :904 call; hits the srcutree.c:696
WARNING -- the library's own "reader still inside" defense
triggers, leaking sda/srcu_sup exactly as the source predicts) ->
kfree (the :905 call) -> srcu_read_unlock (the guard's
destructor):
WARNING: kernel/rcu/srcutree.c:696 at cleanup_srcu_struct
(leak branch: srcu.sda non-NULL, srcu.srcu_sup non-NULL)
BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a
Read of size 8, address = object+8 == the ssp->sda field
alloc: insmod kzalloc; free: the kfree step above
One run, no interleaving, no kprobe parking -- the violation is
in the ordering itself.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: gpiodevsim.c --]
[-- Type: text/plain, Size: 3476 bytes --]
// SPDX-License-Identifier: GPL-2.0+
/*
* gpiodevsim.c - dynamic verification for the audit-report Case 3 (gpiolib):
*
* gpiochip_setup_devs() (drivers/gpio/gpiolib.c:1084-1109) holds
* guard(srcu)(&gdev->srcu) across the loop body and, on
* gpiochip_setup_dev() failure, calls gpio_device_put(gdev) at :1104
* while the read lock is still held. With refcount==1 (the norm: hogs do
* not take device references) gpiodev_release() runs inline:
* :898 sync+cleanup desc_srcu (fine)
* :904 cleanup_srcu_struct(&gdev->srcu) -> WARN_ON(srcu_readers_active)
* at kernel/rcu/srcutree.c:696 -> early return, LEAK (sup/sda
* stay allocated), i.e. the defensive branch the report predicts
* :905 kfree(gdev)
* then the same stack continues on freed memory:
* :1105 dev_err(&gdev->dev, ...) (UAF read)
* loop iterator reads gdev->list.next (UAF read)
* guard destructor srcu_read_unlock() (UAF read of gdev->srcu.sda
* via __srcu_ctr_to_ptr inside __srcu_read_unlock)
*
* This module reproduces that exact single-thread sequence with a
* kzalloc()'d stand-in for struct gpio_device (embedded srcu_struct) and
* makes the final unlock observable as a KASAN use-after-free read:
*
* g = kzalloc(); init_srcu_struct(&g->srcu);
* idx = srcu_read_lock(&g->srcu); // :1094 guard
* cleanup_srcu_struct(&g->srcu); // expect WARN srcutree.c:696 + leak
* print post-cleanup sda/srcu_sup (both non-NULL => leak branch proof)
* kfree(g); // :905
* srcu_read_unlock(&g->srcu, idx); // guard destructor => KASAN UAF
*/
#define pr_fmt(fmt) "GPIODEVSIM: " fmt
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/srcu.h>
struct sim_gdev {
struct srcu_struct srcu; /* first member, like gpio_device usage */
};
static int __init gpiodevsim_init(void)
{
struct sim_gdev *g;
int idx;
g = kzalloc(sizeof(*g), GFP_KERNEL);
if (!g)
return -ENOMEM;
if (init_srcu_struct(&g->srcu)) {
kfree(g);
return -ENOMEM;
}
pr_info("sim gdev=%px srcu initialized (sda=%px sup=%px ctrp=%px)\n",
g, g->srcu.sda, g->srcu.srcu_sup, g->srcu.srcu_ctrp);
/* gpiolib.c:1094 guard(srcu)(&gdev->srcu) */
idx = srcu_read_lock(&g->srcu);
pr_info("read lock taken idx=%d (self-reader active: the put at :1104 will meet it)\n", idx);
/* gpiodev_release():904 - expect WARN at srcutree.c:696, early return */
cleanup_srcu_struct(&g->srcu);
pr_info("post-cleanup: srcu.sda=%px srcu.srcu_sup=%px (BOTH non-NULL => WARN+LEAK branch taken, no free_percpu) ctrp=%px (stale)\n",
g->srcu.sda, g->srcu.srcu_sup, g->srcu.srcu_ctrp);
/* gpiodev_release():905 */
kfree(g);
pr_info("sim gdev kfree()d; guard destructor will now read g->srcu.sda from freed slab\n");
/* gpiochip_setup_devs() loop-body end: guard(srcu) destructor */
srcu_read_unlock(&g->srcu, idx);
/* NOT REACHED on a KASAN kernel without kasan_multi_shot only if the
* access faults; with multi-shot the report is printed and execution
* continues here. */
pr_warn("sequence complete: expected a KASAN use-after-free READ report for the unlock above\n");
return 0;
}
static void __exit gpiodevsim_exit(void)
{
pr_warn("module exit (leaked srcu_usage of the WARN'd srcu is intentional, matching gpiolib behavior)\n");
}
module_init(gpiodevsim_init);
module_exit(gpiodevsim_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* Re: gpio: gpiolib: final put of the gpio_device happens inside the SRCU read critical section on the setup-failure path -- deterministic use-after-free read at guard release (unfixed upstream)
2026-11-03 12:10 ` gpio: gpiolib: final put of the gpio_device happens inside the SRCU read critical section on the setup-failure path -- deterministic use-after-free read at guard release (unfixed upstream) 徐晗翔
@ 2026-11-05 8:52 ` Linus Walleij
0 siblings, 0 replies; 32+ messages in thread
From: Linus Walleij @ 2026-11-05 8:52 UTC (permalink / raw)
To: 徐晗翔; +Cc: brgl, linux-gpio, linux-kernel
On Tue, Nov 3, 2026 at 1:10 PM 徐晗翔 <xuhx@hust.edu.cn> wrote:
> On gpiolib's setup-failure path, the guard that protects the
> gpio_device lookup (srcu_read_lock at gpiolib.c:1094) is also held
> while the device is being torn down: gpiochip_setup_devs()'s error
> path drops the last reference (gpiodev_release at :893-906, which
> calls cleanup_srcu_struct at :904 and kfree at :905) while the
> caller's guard is still inside the read critical section -- so the
> guard's own release (srcu_read_unlock) reads ssp->sda out of an
> already freed gpio_device. In the hog path the refcount can be
> exactly 1 at that point, which is the common case, so this is not
> an exotic window: it is a sequential-ordering violation that fires
> whenever that error path is taken, no concurrency needed. On a
> stock 7.3 KASAN kernel, replaying gpiolib's exact single-threaded
> statement sequence 1:1 (lock at the :1094 guard point -> the :904
> cleanup -> the :905 kfree -> the guard's unlock) hits
> WARNING: kernel/rcu/srcutree.c:696 at cleanup_srcu_struct (the
> self-held-reader defensive branch: sda and srcu_sup leaked, not
> freed), and the subsequent srcu_read_unlock produces
> BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a reading
> object+8 -- exactly the ssp->sda field of the freed container. The
> setup-failure path also misses the list_del_rcu() that the success
> path performs, so the destroyed device can remain reachable through
> the global list. As far as I can tell this is unfixed upstream.
>
> The detailed description:
> * The ordering (Linux 7.3, drivers/gpio/gpiolib.c)
> The caller takes the lookup guard (srcu_read_lock, :1094), then
> gpiochip_setup_devs() fails (e.g. cdev/hog registration errors),
> and the error path drops the final reference;
> gpiodev_release() runs cleanup_srcu_struct(&gdev->srcu) at :904
> and kfree(gdev) at :905 -- while the caller is still inside the
> SRCU read critical section it took at :1094. When the caller's
> guard destructs, srcu_read_unlock(&gdev->srcu, idx) touches the
> freed object (reads ssp->sda at object offset +8). The hog path
> leaves refcount==1 here as the common case (no other holders
> yet), so the final put is genuinely final.
> Secondary: the failure path lacks the list_del_rcu() the success
> path performs, leaving the freed device linked.
>
> * Evidence (stock 7.3 KASAN; deterministic, single-threaded;
> reproducer attached)
> gpiodevsim.c replays gpiolib's own sequence with a kzalloc'd
> container of the same layout: srcu_read_lock (the :1094 guard) ->
> cleanup_srcu_struct (the :904 call; hits the srcutree.c:696
> WARNING -- the library's own "reader still inside" defense
> triggers, leaking sda/srcu_sup exactly as the source predicts) ->
> kfree (the :905 call) -> srcu_read_unlock (the guard's
> destructor):
> WARNING: kernel/rcu/srcutree.c:696 at cleanup_srcu_struct
> (leak branch: srcu.sda non-NULL, srcu.srcu_sup non-NULL)
> BUG: KASAN: slab-use-after-free in __srcu_read_unlock+0x1a
> Read of size 8, address = object+8 == the ssp->sda field
> alloc: insmod kzalloc; free: the kfree step above
> One run, no interleaving, no kprobe parking -- the violation is
> in the ordering itself.
TL;DR, Dostoyevsky commit, make it terse and human-readable,
inline the patch do not send it as an attachment.
Yours,
Linus Walleij
^ permalink raw reply [flat|nested] 32+ messages in thread
* soc: qcom: pmic_glink_altmode: fix use-after-free on unbind (no cancel_work_sync for port work and enable_work)
2026-10-27 14:12 ` Greg KH
` (5 preceding siblings ...)
2026-11-03 12:10 ` gpio: gpiolib: final put of the gpio_device happens inside the SRCU read critical section on the setup-failure path -- deterministic use-after-free read at guard release (unfixed upstream) 徐晗翔
@ 2026-11-04 12:54 ` 徐晗翔
2026-11-04 13:04 ` Greg KH
2026-11-04 12:57 ` usb: typec: anx7411: unregister partner/altmodes before stopping the IRQ work -- use-after-free, double unregister, orphan partner on remove 徐晗翔
` (14 subsequent siblings)
21 siblings, 1 reply; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 12:54 UTC (permalink / raw)
To: andersson, konradybcio; +Cc: linux-arm-msm, linux-usb, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 5410 bytes --]
pmic_glink_altmode schedules two works from asynchronous glink/pdr
notifications -- the per-port alt_port->work (USBC/HPD state machine:
typec_mux_set/typec_switch_set/typec_retimer_set with 5-second ack
waits, pmic_glink_altmode_request() taking altmode->lock and
wait_for_completion_timeout(&altmode->pan_ack, 5*HZ), and
drm_aux_hpd_bridge_notify()) and the global enable_work (scheduled by
pmic_glink_altmode_pdr_notify() on ADSP service state changes) -- but
the driver has NO .remove and NO cancel_work_sync/flush anywhere in
the file: everything (the typec mux/switch/retimer handles, the
pmic_glink client registration, the struct pmic_glink_altmode) is
released by devm actions in reverse order while a queued or running
work is untouched by any of those steps. A USBC notification or a
pdr state transition in flight at unbind/rmmod/ADSP-restart time
therefore makes the worker run on freed handles, a freed client
binding and a freed altmode structure -- an in-tree use-after-free
with a window stretched to seconds by the 5 s ack wait. Notably the
ADSP restart (pdr down->up) is precisely the scenario that triggers
the auxiliary re-enumeration/teardown, so the enable_work instance's
reachability is as high as the port-work one. There is also a
non-UAF data race worth fixing in the same pass: the notify callback
(rpmsg/rx context) writes alt_port->orientation/svid/mode/hpd_state/
mux_ctrl and the 8-byte tbt_data struct copy, while the worker reads
the same fields with no common lock (altmode->lock is only taken
inside pmic_glink_altmode_request) -- KCSAN-visible, with tbt_data
tearable and worst case a transient wrong mux configuration.
The detailed description:
* Teardown sequence with nothing stopping the works (Linux 7.3)
No .remove (pmic_glink_altmode.c:709-713); on auxiliary unbind /
rmmod qcom_pmic_glink / ADSP restart re-enumeration, devm runs in
reverse: glink client release (pmic_glink.c:69-78; only list_del --
does not wait for driver-private works), aux-bridge unregister
(aux-bridge.c:82 devm action), then per-port
typec_switch_put/typec_retimer_put/typec_mux_put (:672-677/:657-662/
:642-647), then the devm kzalloc'd struct pmic_glink_altmode goes
last (:580).
Meanwhile instance 1, alt_port->work (worker :349-394): touches the
switch (:355), mux (:196/254/292/314/336), retimer (:204/262/300/
322/344) handles after their puts -- UAF; pmic_glink_altmode_request
(:393 -> :144-175) takes altmode->lock (:154) and waits up to 5 s on
pan_ack (:168) -- the window is seconds; drm_aux_hpd_bridge_notify
(:377) on a freed bridge.
Instance 2, enable_work: scheduled at :561 by pdr_notify
(pmic_glink.c:216-232 walks clients and calls client->pdr_notify
:230 -- the last batch before the client's list_del can queue it);
the worker (:546-554) runs pmic_glink_altmode_request
(ALTMODE_PAN_EN), touching the same lock/client/pan_ack set -- no
cancel anywhere either.
* Closed-loop exclusions (why nothing protects this today)
The client list_del only stops new callbacks (system_wq has no
relation to pg->client_lock); the devm order only decides which
freed object is hit first; the mux layer's try_module_get pins
code, not these objects; the device-model reference pins the
provider device struct, not the devm data. The probe-failure
path is safe (returns before client registration, no work ever
scheduled) -- the exposure is exactly unbind/restart with a work
in flight, and USBC notifications are high-frequency (plug/HPD/
IRQ_HPD), the worker can reside up to 5 s.
* Evidence (verification; instrument attached)
Qualcomm pmic_glink firmware/ADSP is not available in the test VM,
so the dynamic verification ran the structural isomorph of this
interleave on the native typec class layer (kmod/typcrace.c: an
anx7411-shaped worker with the same "unlocked unregister vs work
holding objects" shape, driven against the native
typec_register/unregister paths):
BUG: KASAN: slab-use-after-free in typec_altmode_update_active
(14 reports across six access points + sysfs_notify + the full
kobject_uevent chain, then a strlen wild-pointer Oops)
alloc: typec_register_altmode <- (provider probe)
free: kfree <- device_release <- kobject_put <- unregister
(the audited "handle freed while work runs" shape)
use: the parked work's continuation
natural (no instrumentation): an Oops in klist_del <- device_del
<- device_unregister <- the work function, ~minutes into a
create/work/remove loop
The pmic_glink side itself is a line-level source finding: the
above isomorph exercises the identical structure (async work vs
devm-released handles and body, no stop/wait), and the driver
file contains no cancel/flush to contradict it.
* Suggested fix
Register a devm action that cancels both works FIRST (i.e. the
last-registered devm action = first executed), or implement
.remove:
cancel_work_sync(&altmode->enable_work);
for (port...) if (altmode->ports[port].altmode)
cancel_work_sync(&altmode->ports[port].work);
placed before any put. For the data race: fold the notify-side
field writes and the worker's reads under altmode->lock (or
snapshot the fields into the work), as a separate small patch.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: typcrace.c --]
[-- Type: text/plain, Size: 18699 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* typcrace.c - dynamic verification module for drivers/usb/typec audit
* (docs/manual-review-drivers-usb-typec-2026-11-02.round3.md)
*
* Statement-faithful replicas of the audited driver state machines (anx7411,
* stusb160x), driven via the exported typec class API on a fake typec_port,
* with one-shot kprobe stalls making the audited interleavings deterministic.
* Kernel source unmodified (build-kasan-typec-7.3).
*
* tc0 - audit Case 3 interleaving 1: anx7411 work uses amode[] pointer
* (check-then-use, anx7411.c:602-613) while remove frees the device.
* Work is parked by a kprobe at its use point
* (typec_altmode_update_active) AFTER it read amode[i]; the remove
* thread unregisters (frees) the altmode during the park; on resume
* the use dereferences freed memory -> KASAN UAF.
* tc0b - audit Case 3 interleaving 2: double device_unregister. Remove is
* parked by a one-shot kprobe at typec_unregister_altmode BEFORE it
* executes; the queued work then unregisters the same altmode
* (device freed); on resume remove's unregister runs on the freed
* device -> UAF inside typec_unregister_altmode/class core.
* tc1 - audit Case 4 (stusb160x form): irq thread parked by one-shot kprobe
* at typec_unregister_partner (having seen chip->partner != NULL);
* remove thread then unregisters the same partner and unregisters the
* port; irq resumes -> unregister on freed partner -> UAF.
*/
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/delay.h>
#include <linux/ktime.h>
#include <linux/workqueue.h>
#include <linux/kthread.h>
#include <linux/completion.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/usb/typec.h>
#include <linux/usb/typec_altmode.h>
#define MAX_ALTMODE 4
struct tyc_typec {
struct typec_port *port;
struct typec_partner *partner;
struct typec_altmode *amode[MAX_ALTMODE];
};
/* ---------------- anx7411 replica (Case 3) ---------------- */
struct tyc_anx {
struct tyc_typec typec;
struct mutex lock; /* analog of ctx->lock: work side only */
struct work_struct work;
struct workqueue_struct *wq;
struct completion work_in_use; /* work parked at its use point */
};
static void anx_unregister_altmode_replica(struct tyc_anx *ctx)
{
int i;
/* anx7411.c:535-549 -- NO ctx->lock (audited) */
for (i = 0; i < MAX_ALTMODE; i++)
if (ctx->typec.amode[i]) {
typec_unregister_altmode(ctx->typec.amode[i]);
ctx->typec.amode[i] = NULL;
}
}
static void anx_unregister_partner_replica(struct tyc_anx *ctx)
{
/* anx7411.c:585-591 -- NO ctx->lock (audited) */
if (ctx->typec.partner) {
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
static bool anx_use_mode; /* tc0: work parks at use point */
static struct tyc_anx *g_anx0; /* tc0 target for kprobe lookup */
/*
* tc0 kprobe target: typec_altmode_update_active is only reached from the
* work's use point in this module (the remove thread never calls it).
* Park here AFTER the work has read amode[i] (audited check-then-use gap).
*/
static int kp_use_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
struct tyc_anx *ctx = g_anx0;
if (!anx_use_mode || !ctx)
return 0;
anx_use_mode = false;
pr_info("TC0: work parked at use point (adev=%px), remove frees now\n",
(void *)regs->di);
complete(&ctx->work_in_use);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0: work resumes use on (possibly freed) adev\n");
return 0;
}
static struct kprobe kp_use = {
.symbol_name = "typec_altmode_update_active",
.pre_handler = kp_use_pre,
};
/* work replica of anx7411_work_func (anx7411.c:906-984) */
static void anx_work_func(struct work_struct *w)
{
struct tyc_anx *ctx = container_of(w, struct tyc_anx, work);
int i;
mutex_lock(&ctx->lock); /* :916 */
/* "update_altmode" check-then-use loop (:602-613) */
for (i = 0; i < MAX_ALTMODE; i++) {
if (!ctx->typec.amode[i])
continue;
pr_info("TC0: work read amode[%d]=%px svid=%04x, calling update_active\n",
i, ctx->typec.amode[i], ctx->typec.amode[i]->svid);
typec_altmode_update_active(ctx->typec.amode[i], true);
}
/* POW_OFF branch (:943-946) */
pr_info("TC0: work unregister altmode+partner\n");
anx_unregister_altmode_replica(ctx);
anx_unregister_partner_replica(ctx);
mutex_unlock(&ctx->lock);
}
static int anx_remove_thread(void *data)
{
struct tyc_anx *ctx = data;
pr_info("TC0*: remove enters (audited order: unregister FIRST)\n");
anx_unregister_altmode_replica(ctx); /* :1566 */
anx_unregister_partner_replica(ctx); /* :1567 */
pr_info("TC0*: remove stops work (too late, audited)\n");
cancel_work_sync(&ctx->work); /* :1571 */
pr_info("TC0*: remove done\n");
return 0;
}
/* one-shot stall at typec_unregister_altmode (tc0b): park the FIRST caller */
static bool oneshot_armed;
static struct completion tc_done_wait;
static int kp_unreg_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC0b: remove parked at typec_unregister_altmode (adev=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0b: parked unregister resumes (device likely freed)\n");
return 0;
}
/* ---------------- stusb160x replica (Case 4) ---------------- */
struct tyc_stu {
struct tyc_typec typec;
struct task_struct *irq_thread;
struct completion irq_parked;
};
static int stu_irq_thread(void *data)
{
struct tyc_stu *ctx = data;
while (!kthread_should_stop()) {
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(msecs_to_jiffies(20));
if (kthread_should_stop())
break;
/* stusb160x_detach(:388-389) check-then-use */
if (ctx->typec.partner) {
pr_info("TC1: irq saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
return 0;
}
static int stu_remove_thread(void *data)
{
struct tyc_stu *ctx = data;
pr_info("TC1: remove enters (devm-irq window open)\n");
/* stusb160x.c:805-808 */
if (ctx->typec.partner) {
pr_info("TC1: remove saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
pr_info("TC1: remove unregisters port\n");
typec_unregister_port(ctx->typec.port);
ctx->typec.port = NULL;
pr_info("TC1: remove done\n");
return 0;
}
/* ---------------- orchestration ---------------- */
static struct tyc_anx *g_anx;
static struct tyc_stu *g_stu;
static struct kprobe kp_unreg;
static int tc_setup_common(struct tyc_typec *t, struct device *parent)
{
struct typec_capability cap = { };
struct typec_partner_desc pdesc = { };
struct typec_altmode_desc adesc = { };
int i;
cap.type = TYPEC_PORT_DRP;
cap.data = TYPEC_PORT_DRD;
cap.revision = USB_TYPEC_REV_1_2;
cap.driver_data = t;
t->port = typec_register_port(parent, &cap);
if (IS_ERR(t->port)) {
pr_err("typec_register_port failed: %pe\n", t->port);
return PTR_ERR(t->port);
}
t->partner = typec_register_partner(t->port, &pdesc);
if (IS_ERR(t->partner)) {
pr_err("typec_register_partner failed: %pe\n", t->partner);
typec_unregister_port(t->port);
t->port = NULL;
return PTR_ERR(t->partner);
}
for (i = 0; i < 2; i++) {
adesc.svid = 0x1234 + i;
adesc.mode = i + 1;
adesc.vdo = 0xdeadbeef;
adesc.roles = TYPEC_PORT_DRD;
t->amode[i] = typec_partner_register_altmode(t->partner, &adesc);
if (IS_ERR(t->amode[i])) {
pr_err("partner_register_altmode[%d] failed: %pe\n",
i, t->amode[i]);
t->amode[i] = NULL;
return -EFAULT;
}
}
return 0;
}
static int run_tc0(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&g_anx->work_in_use);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwq");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
ret = register_kprobe(&kp_use);
if (ret) {
pr_err("TC0: kprobe(use) failed %d\n", ret);
goto out;
}
g_anx0 = g_anx;
anx_use_mode = true;
/* 1. queue work; it parks inside kprobe at its first use, having
* already read amode[0] (audited check-then-use gap). */
queue_work(g_anx->wq, &g_anx->work);
/* 2. wait until parked */
if (!wait_for_completion_timeout(&g_anx->work_in_use, msecs_to_jiffies(2000))) {
pr_err("TC0: work never parked; abort\n");
goto out;
}
/* 3. remove thread frees altmodes+partner under the parked work */
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm");
if (!IS_ERR(rm))
kthread_stop(rm); /* joins: remove completes inside window */
/* 4. work auto-resumes after 400ms -> UAF */
pr_info("TC0: RESULT see KASAN reports above\n");
msleep(300);
anx_unregister_altmode_replica(g_anx);
anx_unregister_partner_replica(g_anx);
out:
unregister_kprobe(&kp_use);
g_anx0 = NULL;
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int run_tc0b(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0b");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&tc_done_wait);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwqb");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_altmode",
.pre_handler = kp_unreg_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC0b: kprobe failed %d\n", ret);
goto out;
}
/* 1. remove thread parks inside kprobe BEFORE its unregister */
oneshot_armed = true;
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm0b");
if (IS_ERR(rm)) {
pr_err("TC0b: kthread failed\n");
goto out;
}
/* 2. wait parked */
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC0b: remove never parked\n");
kthread_stop(rm);
goto out;
}
/* 3. now let the work run: it unregisters the same altmodes+partner
* (device freed) while remove is parked mid-unregister */
queue_work(g_anx->wq, &g_anx->work);
msleep(100);
/* 4. remove resumes -> unregister on freed device */
pr_info("TC0b: remove resumes at ~300ms\n");
msleep(700); /* let everything settle */
cancel_work_sync(&g_anx->work);
pr_info("TC0b: RESULT see KASAN reports above\n");
out:
unregister_kprobe(&kp_unreg);
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int kp_park_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC1: irq thread parked at typec_unregister_partner (partner=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC1: irq resumes unregister (partner likely freed)\n");
return 0;
}
static int run_tc1(void)
{
struct device *root_dev;
int ret;
root_dev = root_device_register("tycroot1");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_stu = kzalloc(sizeof(*g_stu), GFP_KERNEL);
if (!g_stu) {
root_device_unregister(root_dev);
return -ENOMEM;
}
init_completion(&tc_done_wait);
init_completion(&g_stu->irq_parked);
ret = tc_setup_common(&g_stu->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_partner",
.pre_handler = kp_park_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC1: kprobe failed %d\n", ret);
goto out;
}
/* 1. irq thread parks at its unregister (partner still alive) */
oneshot_armed = true;
g_stu->irq_thread = kthread_run(stu_irq_thread, g_stu, "tyc-stuirq");
if (IS_ERR(g_stu->irq_thread)) {
g_stu->irq_thread = NULL;
goto out;
}
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC1: irq never parked\n");
goto out;
}
/* 2. remove sequence inline on the insmod thread (partner still
* alive -> unregister frees it, then port); parked irq thread is
* on another CPU */
stu_remove_thread(g_stu);
/* 3. irq resumes -> unregister on freed partner */
pr_info("TC1: RESULT see KASAN reports above\n");
msleep(300);
out:
unregister_kprobe(&kp_unreg);
if (g_stu) {
if (g_stu->irq_thread)
kthread_stop(g_stu->irq_thread);
kfree(g_stu);
g_stu = NULL;
}
root_device_unregister(root_dev);
return 0;
}
/* ---------------- tc2: Case 1-B deadlock structure replica ----------------
* Faithful structure replica of the audited UCSI unbind deadlock:
* - unbind thread holds "con->lock" and calls cancel_delayed_work_sync()
* (ucsi.c:2003-2004 -> mode_selection.c:277);
* - the mode-selection work, inside its activate window, must take the
* same lock via the ucsi_con_mutex_lock() trylock loop whose exit
* condition is the STALE con->status CONNECTED snapshot
* (ucsi.c:2272-2277; ucsi.h:457 reads ->status, never refreshed on the
* unbind path).
* Expected result: both sides park forever; the VM hangs until timeout.
*/
struct tyc_dead {
struct mutex lock; /* con->lock analog */
bool connected_snapshot; /* con->status CONNECTED bit (stale) */
struct delayed_work work;
struct completion in_loop; /* handshake: work inside trylock loop */
bool ctl; /* ctl=true: lock first, queue later */
};
static void dead_work_fn(struct work_struct *w)
{
struct tyc_dead *d = container_of(w, struct tyc_dead, work.work);
bool locked = false;
pr_info("TC2: work entered activate window, needs con->lock\n");
/* first trylock fails: unbind already holds the lock */
locked = mutex_trylock(&d->lock) != 0;
if (!locked)
complete(&d->in_loop);
/* ucsi_con_mutex_lock() loop, ucsi.c:2272-2277, verbatim shape */
while (d->connected_snapshot && !locked) {
locked = mutex_trylock(&d->lock) != 0;
if (d->connected_snapshot && !locked) {
msleep(20);
}
}
if (locked)
mutex_unlock(&d->lock);
pr_info("TC2: work finished\n");
}
/* audited interleaving: work enters the loop first, unbind then takes the
* lock and cancels -- expected permanent hang (VM killed by timeout). */
static int run_tc2(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true; /* cable connected snapshot */
d->ctl = false;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
/* audited order: unbind holds con->lock FIRST, work starts and
* fails its trylocks while the lock stays held */
mutex_lock(&d->lock);
pr_info("TC2: unbind holds con->lock; scheduling work\n");
schedule_delayed_work(&d->work, 0);
if (!wait_for_completion_timeout(&d->in_loop, msecs_to_jiffies(2000))) {
pr_err("TC2: work never entered loop\n");
mutex_unlock(&d->lock);
kfree(d);
return -ETIMEDOUT;
}
pr_info("TC2: work looping on failed trylocks; unbind calls cancel (expect hang)\n");
cancel_delayed_work_sync(&d->work); /* audited hang */
pr_info("TC2: cancel returned (should never print)\n");
mutex_unlock(&d->lock);
kfree(d);
return 0;
}
/* control: unbind takes the lock BEFORE the work ever runs; cancel() kills
* the pending timer -- no deadlock (audited escape-branch 1). */
static int run_tc2ctl(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true;
d->ctl = true;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
mutex_lock(&d->lock);
pr_info("TC2CTL: unbind holds lock first, queueing work\n");
schedule_delayed_work(&d->work, msecs_to_jiffies(50));
cancel_delayed_work_sync(&d->work); /* pending -> killed */
pr_info("TC2CTL: cancel returned (expected, pending branch)\n");
mutex_unlock(&d->lock);
msleep(200);
kfree(d);
return 0;
}
/* ---------------- tcnat: natural hammering (no probes) ---------------- */
static int tcnat_rounds = 1000;
module_param(tcnat_rounds, int, 0444);
static int run_tcnat(void)
{
struct device *root_dev;
int round;
for (round = 0; round < tcnat_rounds; round++) {
root_dev = root_device_register("tycrootnat");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycnatwq");
if (tc_setup_common(&g_anx->typec, root_dev)) {
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
return -EFAULT;
}
/* natural race: queue work, then immediately remove */
queue_work(g_anx->wq, &g_anx->work);
anx_remove_thread(g_anx);
cancel_work_sync(&g_anx->work);
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
if (!(round % 100))
pr_info("TCNAT: round %d done\n", round);
}
pr_info("TCNAT: %d rounds done (check dmesg for KASAN reports)\n",
tcnat_rounds);
return 0;
}
/* ---------------- module params & entry ---------------- */
static char *action = "status";
module_param(action, charp, 0444);
MODULE_PARM_DESC(action, "tc0|tc0b|tc1|tc2|tcnat|status");
static int tyc_run_action(void)
{
if (!strcmp(action, "tc0"))
return run_tc0();
if (!strcmp(action, "tc0b"))
return run_tc0b();
if (!strcmp(action, "tc1"))
return run_tc1();
if (!strcmp(action, "tc2"))
return run_tc2();
if (!strcmp(action, "tc2ctl"))
return run_tc2ctl();
if (!strcmp(action, "tcnat"))
return run_tcnat();
pr_info("no action, done\n");
return 0;
}
static int __init tyc_init(void)
{
pr_info("action=%s\n", action);
return tyc_run_action();
}
module_init(tyc_init);
static void __exit tyc_exit(void)
{
pr_info("exit\n");
}
module_exit(tyc_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* usb: typec: anx7411: unregister partner/altmodes before stopping the IRQ work -- use-after-free, double unregister, orphan partner on remove
2026-10-27 14:12 ` Greg KH
` (6 preceding siblings ...)
2026-11-04 12:54 ` soc: qcom: pmic_glink_altmode: fix use-after-free on unbind (no cancel_work_sync for port work and enable_work) 徐晗翔
@ 2026-11-04 12:57 ` 徐晗翔
2026-11-04 13:28 ` Greg KH
2026-11-04 12:58 ` usb: typec: ucsi: deadlock on cros_ec unbind -- connector lock held across cancel_delayed_work_sync vs the mode-selection work's unbounded trylock polling 徐晗翔
` (13 subsequent siblings)
21 siblings, 1 reply; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 12:57 UTC (permalink / raw)
To: heikki.krogerus; +Cc: linux-usb, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 5160 bytes --]
anx7411_i2c_remove() does its teardown in exactly the wrong order:
it first unregisters the partner and its altmodes
(anx7411_partner_unregister_altmode at anx7411.c:1566 and
anx7411_unregister_partner at :1567 -- both UNLOCKED walks of
amode[]/partner) and only afterwards disables the IRQ (:1570) and
cancels the work (:1571). The IRQ thread (devm-requested at
:1527-1531) can enqueue anx7411_work_func() (which runs holding
ctx->lock, :916) at any moment before that, and the remove side does
not take ctx->lock -- so the two sides race on the check-then-use of
amode[] and partner with no common synchronization. Three concrete
interleavings, all verified on a pristine Linux 7.3 KASAN kernel
against the NATIVE typec class and device core with a statement-level
replica of this driver's worker/remove pair: (1) the work
dereferences an altmode already freed by the remove-side unregister
-- 14 KASAN slab-use-after-free reports across the six access points
of typec_altmode_update_active plus sysfs_notify and the whole
kobject_uevent chain, ending in a strlen wild-pointer Oops that kills
the worker; (2) a double typec_unregister_altmode (the work's
POW_OFF branch unregistering at :943/:946 against the remove-side
unregister) -- 24 KASAN reports of the device core executing on a
freed device (device_del internals, dpm_sysfs_remove, freed locks)
and two Oopses; (3) the work re-registers a partner on the already
unregistered port (orphan). The natural, uninstrumented variant of
interleaving 2 hit on its own after ~138 s of create/work/remove
hammering: an Oops in klist_del <- device_del <- device_unregister
<- the work function. The fix is to reorder the remove (stop
IRQ/work first) and take ctx->lock around the unregister walks.
The detailed description:
* The wrong order (Linux 7.3, drivers/usb/typec/anx7411.c)
anx7411_i2c_remove():
:1566 anx7411_partner_unregister_altmode() /* :535-549, NO
lock: walks amode[], typec_unregister_altmode + NULL */
:1567 anx7411_unregister_partner() /* :585-591, NO
lock */
:1570 disable_irq(...)
:1571 cancel_work_sync(&ctx->work) /* too late */
The IRQ thread (:986-993; devm IRQ :1527-1531) queues
anx7411_work_func (:906-984) at any time before :1570; the worker
holds ctx->lock, the remove side does not -- the "check then use"
of amode[i] (:603 -> :606-610), partner (:625) and the
unregister/NULL pairs (:545/:589, :943/:946) have no atomicity
across the two contexts.
* The three interleavings (each dynamically verified; module attached)
1. Work uses an altmode freed by remove: the remove side's
unregister drops the last reference (class.c:606-616 kfree);
the worker's stale amode[i] feeds typec_altmode_update_active/
notify (:606-610) -> UAF, and the resulting update cascade
(sysfs_notify -> kobject_uevent_env -> dev_uevent ->
kobject_get_path -> strlen) Oopses the worker. 14 KASAN
reports; three stacks closed:
alloc: typec_register_altmode <- probe (1024B altmode)
free: kfree <- device_release <- kobject_put <-
unregister (the audited :544 shape)
use: the parked work's continuation
2. Double unregister: the worker's POW_OFF branch (:943/:946) and
the remove side (:1566/:1567) unregister the same altmode/partner
concurrently: the device core (device_del's internals,
dpm_sysfs_remove, sysfs unmerge, bus_notify, freed mutexes,
kernfs lookups) executes on the freed device -- 24 KASAN reports
and two Oopses.
3. Orphan re-registration: the worker's register_altmode (:618-645)
uses the stale partner pointer (:625 check is TOCTOU) after the
remove side already unregistered it.
Natural reachability: with no instrumentation, a create -> queue
work -> immediate remove loop hit interleaving 2's natural shape
after ~138 s: Oops at klist_del+0x22 <- device_del+0x168 <-
device_unregister <- the work function; the worker died (exited
with irqs disabled) and the module hung. So under plain admin
unbind pressure the hit rate is nontrivial, no debugging tools
needed.
Fidelity: the class layer (typec_register/unregister_altmode/
partner, device core, kobject/klist, sysfs/uevent) is 100% native
kernel code; anx7411's worker state machine and remove sequence are
replicated statement-by-statement in the module (anx_work_func/
anx_unregister_*_replica) per the audited line numbers.
Module usage: insmod typcrace.ko; then select the action from the
evidence section's plans (tc0/tc0b/tcnat for this report's three
forms); a parked-worker kprobe (in the module) turns the natural
windows deterministic.
* Suggested fix
Move the stop/wait block (:1569-1573: disable_irq + cancel_work_
sync) BEFORE the unregister side (:1566/:1567), and take ctx->lock
around the amode[]/partner unregister walks. The mux/switch
unregister at :1577-1579 is already after the cancel and correct --
the impact is confined to partner/altmode.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: typcrace.c --]
[-- Type: text/plain, Size: 18699 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* typcrace.c - dynamic verification module for drivers/usb/typec audit
* (docs/manual-review-drivers-usb-typec-2026-11-02.round3.md)
*
* Statement-faithful replicas of the audited driver state machines (anx7411,
* stusb160x), driven via the exported typec class API on a fake typec_port,
* with one-shot kprobe stalls making the audited interleavings deterministic.
* Kernel source unmodified (build-kasan-typec-7.3).
*
* tc0 - audit Case 3 interleaving 1: anx7411 work uses amode[] pointer
* (check-then-use, anx7411.c:602-613) while remove frees the device.
* Work is parked by a kprobe at its use point
* (typec_altmode_update_active) AFTER it read amode[i]; the remove
* thread unregisters (frees) the altmode during the park; on resume
* the use dereferences freed memory -> KASAN UAF.
* tc0b - audit Case 3 interleaving 2: double device_unregister. Remove is
* parked by a one-shot kprobe at typec_unregister_altmode BEFORE it
* executes; the queued work then unregisters the same altmode
* (device freed); on resume remove's unregister runs on the freed
* device -> UAF inside typec_unregister_altmode/class core.
* tc1 - audit Case 4 (stusb160x form): irq thread parked by one-shot kprobe
* at typec_unregister_partner (having seen chip->partner != NULL);
* remove thread then unregisters the same partner and unregisters the
* port; irq resumes -> unregister on freed partner -> UAF.
*/
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/delay.h>
#include <linux/ktime.h>
#include <linux/workqueue.h>
#include <linux/kthread.h>
#include <linux/completion.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/usb/typec.h>
#include <linux/usb/typec_altmode.h>
#define MAX_ALTMODE 4
struct tyc_typec {
struct typec_port *port;
struct typec_partner *partner;
struct typec_altmode *amode[MAX_ALTMODE];
};
/* ---------------- anx7411 replica (Case 3) ---------------- */
struct tyc_anx {
struct tyc_typec typec;
struct mutex lock; /* analog of ctx->lock: work side only */
struct work_struct work;
struct workqueue_struct *wq;
struct completion work_in_use; /* work parked at its use point */
};
static void anx_unregister_altmode_replica(struct tyc_anx *ctx)
{
int i;
/* anx7411.c:535-549 -- NO ctx->lock (audited) */
for (i = 0; i < MAX_ALTMODE; i++)
if (ctx->typec.amode[i]) {
typec_unregister_altmode(ctx->typec.amode[i]);
ctx->typec.amode[i] = NULL;
}
}
static void anx_unregister_partner_replica(struct tyc_anx *ctx)
{
/* anx7411.c:585-591 -- NO ctx->lock (audited) */
if (ctx->typec.partner) {
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
static bool anx_use_mode; /* tc0: work parks at use point */
static struct tyc_anx *g_anx0; /* tc0 target for kprobe lookup */
/*
* tc0 kprobe target: typec_altmode_update_active is only reached from the
* work's use point in this module (the remove thread never calls it).
* Park here AFTER the work has read amode[i] (audited check-then-use gap).
*/
static int kp_use_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
struct tyc_anx *ctx = g_anx0;
if (!anx_use_mode || !ctx)
return 0;
anx_use_mode = false;
pr_info("TC0: work parked at use point (adev=%px), remove frees now\n",
(void *)regs->di);
complete(&ctx->work_in_use);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0: work resumes use on (possibly freed) adev\n");
return 0;
}
static struct kprobe kp_use = {
.symbol_name = "typec_altmode_update_active",
.pre_handler = kp_use_pre,
};
/* work replica of anx7411_work_func (anx7411.c:906-984) */
static void anx_work_func(struct work_struct *w)
{
struct tyc_anx *ctx = container_of(w, struct tyc_anx, work);
int i;
mutex_lock(&ctx->lock); /* :916 */
/* "update_altmode" check-then-use loop (:602-613) */
for (i = 0; i < MAX_ALTMODE; i++) {
if (!ctx->typec.amode[i])
continue;
pr_info("TC0: work read amode[%d]=%px svid=%04x, calling update_active\n",
i, ctx->typec.amode[i], ctx->typec.amode[i]->svid);
typec_altmode_update_active(ctx->typec.amode[i], true);
}
/* POW_OFF branch (:943-946) */
pr_info("TC0: work unregister altmode+partner\n");
anx_unregister_altmode_replica(ctx);
anx_unregister_partner_replica(ctx);
mutex_unlock(&ctx->lock);
}
static int anx_remove_thread(void *data)
{
struct tyc_anx *ctx = data;
pr_info("TC0*: remove enters (audited order: unregister FIRST)\n");
anx_unregister_altmode_replica(ctx); /* :1566 */
anx_unregister_partner_replica(ctx); /* :1567 */
pr_info("TC0*: remove stops work (too late, audited)\n");
cancel_work_sync(&ctx->work); /* :1571 */
pr_info("TC0*: remove done\n");
return 0;
}
/* one-shot stall at typec_unregister_altmode (tc0b): park the FIRST caller */
static bool oneshot_armed;
static struct completion tc_done_wait;
static int kp_unreg_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC0b: remove parked at typec_unregister_altmode (adev=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0b: parked unregister resumes (device likely freed)\n");
return 0;
}
/* ---------------- stusb160x replica (Case 4) ---------------- */
struct tyc_stu {
struct tyc_typec typec;
struct task_struct *irq_thread;
struct completion irq_parked;
};
static int stu_irq_thread(void *data)
{
struct tyc_stu *ctx = data;
while (!kthread_should_stop()) {
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(msecs_to_jiffies(20));
if (kthread_should_stop())
break;
/* stusb160x_detach(:388-389) check-then-use */
if (ctx->typec.partner) {
pr_info("TC1: irq saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
return 0;
}
static int stu_remove_thread(void *data)
{
struct tyc_stu *ctx = data;
pr_info("TC1: remove enters (devm-irq window open)\n");
/* stusb160x.c:805-808 */
if (ctx->typec.partner) {
pr_info("TC1: remove saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
pr_info("TC1: remove unregisters port\n");
typec_unregister_port(ctx->typec.port);
ctx->typec.port = NULL;
pr_info("TC1: remove done\n");
return 0;
}
/* ---------------- orchestration ---------------- */
static struct tyc_anx *g_anx;
static struct tyc_stu *g_stu;
static struct kprobe kp_unreg;
static int tc_setup_common(struct tyc_typec *t, struct device *parent)
{
struct typec_capability cap = { };
struct typec_partner_desc pdesc = { };
struct typec_altmode_desc adesc = { };
int i;
cap.type = TYPEC_PORT_DRP;
cap.data = TYPEC_PORT_DRD;
cap.revision = USB_TYPEC_REV_1_2;
cap.driver_data = t;
t->port = typec_register_port(parent, &cap);
if (IS_ERR(t->port)) {
pr_err("typec_register_port failed: %pe\n", t->port);
return PTR_ERR(t->port);
}
t->partner = typec_register_partner(t->port, &pdesc);
if (IS_ERR(t->partner)) {
pr_err("typec_register_partner failed: %pe\n", t->partner);
typec_unregister_port(t->port);
t->port = NULL;
return PTR_ERR(t->partner);
}
for (i = 0; i < 2; i++) {
adesc.svid = 0x1234 + i;
adesc.mode = i + 1;
adesc.vdo = 0xdeadbeef;
adesc.roles = TYPEC_PORT_DRD;
t->amode[i] = typec_partner_register_altmode(t->partner, &adesc);
if (IS_ERR(t->amode[i])) {
pr_err("partner_register_altmode[%d] failed: %pe\n",
i, t->amode[i]);
t->amode[i] = NULL;
return -EFAULT;
}
}
return 0;
}
static int run_tc0(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&g_anx->work_in_use);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwq");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
ret = register_kprobe(&kp_use);
if (ret) {
pr_err("TC0: kprobe(use) failed %d\n", ret);
goto out;
}
g_anx0 = g_anx;
anx_use_mode = true;
/* 1. queue work; it parks inside kprobe at its first use, having
* already read amode[0] (audited check-then-use gap). */
queue_work(g_anx->wq, &g_anx->work);
/* 2. wait until parked */
if (!wait_for_completion_timeout(&g_anx->work_in_use, msecs_to_jiffies(2000))) {
pr_err("TC0: work never parked; abort\n");
goto out;
}
/* 3. remove thread frees altmodes+partner under the parked work */
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm");
if (!IS_ERR(rm))
kthread_stop(rm); /* joins: remove completes inside window */
/* 4. work auto-resumes after 400ms -> UAF */
pr_info("TC0: RESULT see KASAN reports above\n");
msleep(300);
anx_unregister_altmode_replica(g_anx);
anx_unregister_partner_replica(g_anx);
out:
unregister_kprobe(&kp_use);
g_anx0 = NULL;
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int run_tc0b(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0b");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&tc_done_wait);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwqb");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_altmode",
.pre_handler = kp_unreg_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC0b: kprobe failed %d\n", ret);
goto out;
}
/* 1. remove thread parks inside kprobe BEFORE its unregister */
oneshot_armed = true;
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm0b");
if (IS_ERR(rm)) {
pr_err("TC0b: kthread failed\n");
goto out;
}
/* 2. wait parked */
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC0b: remove never parked\n");
kthread_stop(rm);
goto out;
}
/* 3. now let the work run: it unregisters the same altmodes+partner
* (device freed) while remove is parked mid-unregister */
queue_work(g_anx->wq, &g_anx->work);
msleep(100);
/* 4. remove resumes -> unregister on freed device */
pr_info("TC0b: remove resumes at ~300ms\n");
msleep(700); /* let everything settle */
cancel_work_sync(&g_anx->work);
pr_info("TC0b: RESULT see KASAN reports above\n");
out:
unregister_kprobe(&kp_unreg);
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int kp_park_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC1: irq thread parked at typec_unregister_partner (partner=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC1: irq resumes unregister (partner likely freed)\n");
return 0;
}
static int run_tc1(void)
{
struct device *root_dev;
int ret;
root_dev = root_device_register("tycroot1");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_stu = kzalloc(sizeof(*g_stu), GFP_KERNEL);
if (!g_stu) {
root_device_unregister(root_dev);
return -ENOMEM;
}
init_completion(&tc_done_wait);
init_completion(&g_stu->irq_parked);
ret = tc_setup_common(&g_stu->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_partner",
.pre_handler = kp_park_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC1: kprobe failed %d\n", ret);
goto out;
}
/* 1. irq thread parks at its unregister (partner still alive) */
oneshot_armed = true;
g_stu->irq_thread = kthread_run(stu_irq_thread, g_stu, "tyc-stuirq");
if (IS_ERR(g_stu->irq_thread)) {
g_stu->irq_thread = NULL;
goto out;
}
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC1: irq never parked\n");
goto out;
}
/* 2. remove sequence inline on the insmod thread (partner still
* alive -> unregister frees it, then port); parked irq thread is
* on another CPU */
stu_remove_thread(g_stu);
/* 3. irq resumes -> unregister on freed partner */
pr_info("TC1: RESULT see KASAN reports above\n");
msleep(300);
out:
unregister_kprobe(&kp_unreg);
if (g_stu) {
if (g_stu->irq_thread)
kthread_stop(g_stu->irq_thread);
kfree(g_stu);
g_stu = NULL;
}
root_device_unregister(root_dev);
return 0;
}
/* ---------------- tc2: Case 1-B deadlock structure replica ----------------
* Faithful structure replica of the audited UCSI unbind deadlock:
* - unbind thread holds "con->lock" and calls cancel_delayed_work_sync()
* (ucsi.c:2003-2004 -> mode_selection.c:277);
* - the mode-selection work, inside its activate window, must take the
* same lock via the ucsi_con_mutex_lock() trylock loop whose exit
* condition is the STALE con->status CONNECTED snapshot
* (ucsi.c:2272-2277; ucsi.h:457 reads ->status, never refreshed on the
* unbind path).
* Expected result: both sides park forever; the VM hangs until timeout.
*/
struct tyc_dead {
struct mutex lock; /* con->lock analog */
bool connected_snapshot; /* con->status CONNECTED bit (stale) */
struct delayed_work work;
struct completion in_loop; /* handshake: work inside trylock loop */
bool ctl; /* ctl=true: lock first, queue later */
};
static void dead_work_fn(struct work_struct *w)
{
struct tyc_dead *d = container_of(w, struct tyc_dead, work.work);
bool locked = false;
pr_info("TC2: work entered activate window, needs con->lock\n");
/* first trylock fails: unbind already holds the lock */
locked = mutex_trylock(&d->lock) != 0;
if (!locked)
complete(&d->in_loop);
/* ucsi_con_mutex_lock() loop, ucsi.c:2272-2277, verbatim shape */
while (d->connected_snapshot && !locked) {
locked = mutex_trylock(&d->lock) != 0;
if (d->connected_snapshot && !locked) {
msleep(20);
}
}
if (locked)
mutex_unlock(&d->lock);
pr_info("TC2: work finished\n");
}
/* audited interleaving: work enters the loop first, unbind then takes the
* lock and cancels -- expected permanent hang (VM killed by timeout). */
static int run_tc2(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true; /* cable connected snapshot */
d->ctl = false;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
/* audited order: unbind holds con->lock FIRST, work starts and
* fails its trylocks while the lock stays held */
mutex_lock(&d->lock);
pr_info("TC2: unbind holds con->lock; scheduling work\n");
schedule_delayed_work(&d->work, 0);
if (!wait_for_completion_timeout(&d->in_loop, msecs_to_jiffies(2000))) {
pr_err("TC2: work never entered loop\n");
mutex_unlock(&d->lock);
kfree(d);
return -ETIMEDOUT;
}
pr_info("TC2: work looping on failed trylocks; unbind calls cancel (expect hang)\n");
cancel_delayed_work_sync(&d->work); /* audited hang */
pr_info("TC2: cancel returned (should never print)\n");
mutex_unlock(&d->lock);
kfree(d);
return 0;
}
/* control: unbind takes the lock BEFORE the work ever runs; cancel() kills
* the pending timer -- no deadlock (audited escape-branch 1). */
static int run_tc2ctl(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true;
d->ctl = true;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
mutex_lock(&d->lock);
pr_info("TC2CTL: unbind holds lock first, queueing work\n");
schedule_delayed_work(&d->work, msecs_to_jiffies(50));
cancel_delayed_work_sync(&d->work); /* pending -> killed */
pr_info("TC2CTL: cancel returned (expected, pending branch)\n");
mutex_unlock(&d->lock);
msleep(200);
kfree(d);
return 0;
}
/* ---------------- tcnat: natural hammering (no probes) ---------------- */
static int tcnat_rounds = 1000;
module_param(tcnat_rounds, int, 0444);
static int run_tcnat(void)
{
struct device *root_dev;
int round;
for (round = 0; round < tcnat_rounds; round++) {
root_dev = root_device_register("tycrootnat");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycnatwq");
if (tc_setup_common(&g_anx->typec, root_dev)) {
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
return -EFAULT;
}
/* natural race: queue work, then immediately remove */
queue_work(g_anx->wq, &g_anx->work);
anx_remove_thread(g_anx);
cancel_work_sync(&g_anx->work);
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
if (!(round % 100))
pr_info("TCNAT: round %d done\n", round);
}
pr_info("TCNAT: %d rounds done (check dmesg for KASAN reports)\n",
tcnat_rounds);
return 0;
}
/* ---------------- module params & entry ---------------- */
static char *action = "status";
module_param(action, charp, 0444);
MODULE_PARM_DESC(action, "tc0|tc0b|tc1|tc2|tcnat|status");
static int tyc_run_action(void)
{
if (!strcmp(action, "tc0"))
return run_tc0();
if (!strcmp(action, "tc0b"))
return run_tc0b();
if (!strcmp(action, "tc1"))
return run_tc1();
if (!strcmp(action, "tc2"))
return run_tc2();
if (!strcmp(action, "tc2ctl"))
return run_tc2ctl();
if (!strcmp(action, "tcnat"))
return run_tcnat();
pr_info("no action, done\n");
return 0;
}
static int __init tyc_init(void)
{
pr_info("action=%s\n", action);
return tyc_run_action();
}
module_init(tyc_init);
static void __exit tyc_exit(void)
{
pr_info("exit\n");
}
module_exit(tyc_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* Re: usb: typec: anx7411: unregister partner/altmodes before stopping the IRQ work -- use-after-free, double unregister, orphan partner on remove
2026-11-04 12:57 ` usb: typec: anx7411: unregister partner/altmodes before stopping the IRQ work -- use-after-free, double unregister, orphan partner on remove 徐晗翔
@ 2026-11-04 13:28 ` Greg KH
0 siblings, 0 replies; 32+ messages in thread
From: Greg KH @ 2026-11-04 13:28 UTC (permalink / raw)
To: 徐晗翔; +Cc: heikki.krogerus, linux-usb, linux-kernel
On Wed, Nov 04, 2026 at 08:57:07PM +0800, 徐晗翔 wrote:
<snip>
Sorry, but none of this is helpful at all. Please send patches, not
walls of LLM-generated text that no one is going to read.
greg k-h
^ permalink raw reply [flat|nested] 32+ messages in thread
* usb: typec: ucsi: deadlock on cros_ec unbind -- connector lock held across cancel_delayed_work_sync vs the mode-selection work's unbounded trylock polling
2026-10-27 14:12 ` Greg KH
` (7 preceding siblings ...)
2026-11-04 12:57 ` usb: typec: anx7411: unregister partner/altmodes before stopping the IRQ work -- use-after-free, double unregister, orphan partner on remove 徐晗翔
@ 2026-11-04 12:58 ` 徐晗翔
2026-11-04 13:00 ` usb: typec: stusb160x/rt1719/hd3ss3220: IRQ thread vs remove -- double unregister / use-after-free on the partner and port 徐晗翔
` (12 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 12:58 UTC (permalink / raw)
To: heikki.krogerus; +Cc: linux-usb, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 6320 bytes --]
On cros_ec UCSI platforms (ALT_MODE_OVERRIDE firmware), unbinding the
platform driver deadlocks: ucsi_unregister_port() takes con->lock
(ucsi.c:2003) and then reaches typec_mode_selection_delete() ->
cancel_delayed_work_sync(&sel->work) (mode_selection.c:277), which
blocks waiting for the mode-selection work to return -- but that work
must, in its DP/TBT activate callbacks, acquire the very same
con->lock via ucsi_con_mutex_lock() before it can return; the unbind
path never refreshes the connector status, the polling condition
stays true, and the loop never exits. The polling is unbounded
(msleep(20) retry loop, ucsi.c:2272-2277) and its condition is a
stale in-memory snapshot (UCSI_CONSTAT reads con->status as latched
at the last connector update, ucsi.h:457-458) -- since the unbind
neither releases the lock nor updates the snapshot, the work spins
forever and the unbind thread waits forever. I reproduced the hang
deterministically on a pristine Linux 7.3 KASAN kernel with a
structural replica of the four ingredients (mutex +
cancel_delayed_work_sync + trylock/msleep polling + stale snapshot),
and closed the loop with two control runs: cancelling a merely-pending
work returns immediately, and a work that got its first trylock before
unbind finishes normally -- the deadlock is a genuine race on "the
work is executing its activate chain while unbind takes the lock
first". Consequences: the unbind/rmmod (or init-failure rollback)
hangs permanently, a system_wq worker is lost, and every later
contender for con->lock (connector-change work, poll worker, role
swap) piles up behind it -- USB-C/DP dead until reboot. Not a memory
safety issue; a liveness bug.
The detailed description:
* The deadlock path (Linux 7.3 line numbers)
cros_ucsi_remove (cros_ec_ucsi.c:313-319, :317) -> ucsi_unregister
(ucsi.c:2372-2397; :2390 only cancels the connector-change work) ->
ucsi_unregister_port (:1998-2032): :2003 mutex_lock(&con->lock) ->
:2004 ucsi_unregister_partner -> :1317-1318 -> (cros_ec_ucsi.c:157)
typec_mode_selection_delete() -> mode_selection.c:277
cancel_delayed_work_sync(&sel->work) -- which is cancel_work_sync's
delayed variant (kernel/workqueue.c:4637-4640 -> :4552 -> :4569
__flush_work(work, true), documented "guaranteed to be not pending
or executing ... even if the work re-queues itself"): the unbind
thread now WAITS for the running work.
The work (mode_selection.c:130-174) -> mode_selection_activate()
(:148/:156/:160) -> :95 releases sel->lock -> device_for_each_child
-> the partner altmode's .activate (altmodes/displayport.c:504-523;
altmodes/thunderbolt.c:247-262) -> typec_altmode_enter/exit
(bus.c:126-148/:157-177) -> the port-side ops registered by UCSI
(ucsi.c:449-463) -> ucsi_displayport_enter/exit / ucsi_thunderbolt_
enter/exit, whose FIRST statement is ucsi_con_mutex_lock()
(ucsi/displayport.c:57/:119; ucsi/thunderbolt.c:89/:122) -- the
same con->lock the unbind thread holds. The poll:
while (connected && !mutex_locked) {
mutex_trylock(...);
connected = UCSI_CONSTAT(con, CONNECTED); /* snapshot,
ucsi.h:457 */
if (connected && !mutex_locked) msleep(20);
}
never exits: the snapshot stays CONNECTED=1 (latched at connection
time), the unbind path has no ucsi_get_connector_status() call, and
msleep is not interruptible. (The DP altmode activate's cable
branch is not an escape: UCSI registers no cable_ops, so it falls
through to the same typec_altmode_enter.)
* Control runs (the race boundary, dynamically covered)
- Work merely pending (timer not fired): cancel removes it and
returns instantly -- no deadlock. So the trigger needs the work
EXECUTING its activate chain when unbind takes the lock; the work
runs on 1 s/4 s periods (cros_ec_ucsi.c:39-41) and its
activate-chain start (typec_mux_set to an i2c mux provider) takes
milliseconds -- the exposure window is real, hit probability
moderate per unbind.
- Work's first trylock succeeds before unbind takes the lock: the
work completes normally.
- The non-deadlock paths exist too: a connector-change disconnect
refreshes the status to disconnected before unregistering
(ucsi.c:1464 -> :1513), and the resume-time
ucsi_check_connection (:1387 -> :1402) likewise lets the poll
see CONNECTED=0 and return -ENOTCONN -- the unbind path is the
one that does not.
* Evidence (pristine 7.3 KASAN; module attached)
Structural replica with a handshake orchestration (hold the lock ->
start the work -> first failed trylock observed -> call the
unbind-side cancel):
TC2: work looping on failed trylocks; unbind calls cancel
(expect hang)
... no "cancel returned", no "work finished" ...
(VM killed by timeout, exit=124 -- both threads permanently
waiting)
and its mirror-image control (cancel of a pending-only work)
returned immediately and clean. Fidelity: the four deadlock
ingredients (mutex, cancel_delayed_work_sync, trylock/msleep
polling, stale CONNECTED snapshot) are replicated statement-by-
statement per ucsi.c:2003-2004 / mode_selection.c:277 /
ucsi.c:2272-2277 / ucsi.h:457-458; the workqueue primitive
semantics were verified in-kernel (workqueue.c:4552-4590).
Module usage: insmod typcrace.ko; action=tc2 (hang; expect the VM
to die by timeout) / action=tc2ctl (control; clean). On real
hardware the same shape is reached by `rmmod cros_ec_ucsi` (or
unbind of the platform driver) while a DP/TBT session is active.
* Suggested fix (any of; no patch attached)
1. ucsi_unregister_port(): refresh the connector status (or
explicitly clear the CONNECTED snapshot) BEFORE taking
con->lock, so a concurrently polled work exits its loop;
2. cros side: move typec_mode_selection_delete() out of the
con->lock critical section (detach partner->sel under the lock
against new updaters, then unlock and only then cancel+free);
3. harden ucsi_con_mutex_lock() with a bounded wait (e.g. 1 s then
-ENOTCONN) so this class of inverted lock-hold cannot wedge the
worker permanently.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: typcrace.c --]
[-- Type: text/plain, Size: 18699 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* typcrace.c - dynamic verification module for drivers/usb/typec audit
* (docs/manual-review-drivers-usb-typec-2026-11-02.round3.md)
*
* Statement-faithful replicas of the audited driver state machines (anx7411,
* stusb160x), driven via the exported typec class API on a fake typec_port,
* with one-shot kprobe stalls making the audited interleavings deterministic.
* Kernel source unmodified (build-kasan-typec-7.3).
*
* tc0 - audit Case 3 interleaving 1: anx7411 work uses amode[] pointer
* (check-then-use, anx7411.c:602-613) while remove frees the device.
* Work is parked by a kprobe at its use point
* (typec_altmode_update_active) AFTER it read amode[i]; the remove
* thread unregisters (frees) the altmode during the park; on resume
* the use dereferences freed memory -> KASAN UAF.
* tc0b - audit Case 3 interleaving 2: double device_unregister. Remove is
* parked by a one-shot kprobe at typec_unregister_altmode BEFORE it
* executes; the queued work then unregisters the same altmode
* (device freed); on resume remove's unregister runs on the freed
* device -> UAF inside typec_unregister_altmode/class core.
* tc1 - audit Case 4 (stusb160x form): irq thread parked by one-shot kprobe
* at typec_unregister_partner (having seen chip->partner != NULL);
* remove thread then unregisters the same partner and unregisters the
* port; irq resumes -> unregister on freed partner -> UAF.
*/
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/delay.h>
#include <linux/ktime.h>
#include <linux/workqueue.h>
#include <linux/kthread.h>
#include <linux/completion.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/usb/typec.h>
#include <linux/usb/typec_altmode.h>
#define MAX_ALTMODE 4
struct tyc_typec {
struct typec_port *port;
struct typec_partner *partner;
struct typec_altmode *amode[MAX_ALTMODE];
};
/* ---------------- anx7411 replica (Case 3) ---------------- */
struct tyc_anx {
struct tyc_typec typec;
struct mutex lock; /* analog of ctx->lock: work side only */
struct work_struct work;
struct workqueue_struct *wq;
struct completion work_in_use; /* work parked at its use point */
};
static void anx_unregister_altmode_replica(struct tyc_anx *ctx)
{
int i;
/* anx7411.c:535-549 -- NO ctx->lock (audited) */
for (i = 0; i < MAX_ALTMODE; i++)
if (ctx->typec.amode[i]) {
typec_unregister_altmode(ctx->typec.amode[i]);
ctx->typec.amode[i] = NULL;
}
}
static void anx_unregister_partner_replica(struct tyc_anx *ctx)
{
/* anx7411.c:585-591 -- NO ctx->lock (audited) */
if (ctx->typec.partner) {
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
static bool anx_use_mode; /* tc0: work parks at use point */
static struct tyc_anx *g_anx0; /* tc0 target for kprobe lookup */
/*
* tc0 kprobe target: typec_altmode_update_active is only reached from the
* work's use point in this module (the remove thread never calls it).
* Park here AFTER the work has read amode[i] (audited check-then-use gap).
*/
static int kp_use_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
struct tyc_anx *ctx = g_anx0;
if (!anx_use_mode || !ctx)
return 0;
anx_use_mode = false;
pr_info("TC0: work parked at use point (adev=%px), remove frees now\n",
(void *)regs->di);
complete(&ctx->work_in_use);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0: work resumes use on (possibly freed) adev\n");
return 0;
}
static struct kprobe kp_use = {
.symbol_name = "typec_altmode_update_active",
.pre_handler = kp_use_pre,
};
/* work replica of anx7411_work_func (anx7411.c:906-984) */
static void anx_work_func(struct work_struct *w)
{
struct tyc_anx *ctx = container_of(w, struct tyc_anx, work);
int i;
mutex_lock(&ctx->lock); /* :916 */
/* "update_altmode" check-then-use loop (:602-613) */
for (i = 0; i < MAX_ALTMODE; i++) {
if (!ctx->typec.amode[i])
continue;
pr_info("TC0: work read amode[%d]=%px svid=%04x, calling update_active\n",
i, ctx->typec.amode[i], ctx->typec.amode[i]->svid);
typec_altmode_update_active(ctx->typec.amode[i], true);
}
/* POW_OFF branch (:943-946) */
pr_info("TC0: work unregister altmode+partner\n");
anx_unregister_altmode_replica(ctx);
anx_unregister_partner_replica(ctx);
mutex_unlock(&ctx->lock);
}
static int anx_remove_thread(void *data)
{
struct tyc_anx *ctx = data;
pr_info("TC0*: remove enters (audited order: unregister FIRST)\n");
anx_unregister_altmode_replica(ctx); /* :1566 */
anx_unregister_partner_replica(ctx); /* :1567 */
pr_info("TC0*: remove stops work (too late, audited)\n");
cancel_work_sync(&ctx->work); /* :1571 */
pr_info("TC0*: remove done\n");
return 0;
}
/* one-shot stall at typec_unregister_altmode (tc0b): park the FIRST caller */
static bool oneshot_armed;
static struct completion tc_done_wait;
static int kp_unreg_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC0b: remove parked at typec_unregister_altmode (adev=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0b: parked unregister resumes (device likely freed)\n");
return 0;
}
/* ---------------- stusb160x replica (Case 4) ---------------- */
struct tyc_stu {
struct tyc_typec typec;
struct task_struct *irq_thread;
struct completion irq_parked;
};
static int stu_irq_thread(void *data)
{
struct tyc_stu *ctx = data;
while (!kthread_should_stop()) {
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(msecs_to_jiffies(20));
if (kthread_should_stop())
break;
/* stusb160x_detach(:388-389) check-then-use */
if (ctx->typec.partner) {
pr_info("TC1: irq saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
return 0;
}
static int stu_remove_thread(void *data)
{
struct tyc_stu *ctx = data;
pr_info("TC1: remove enters (devm-irq window open)\n");
/* stusb160x.c:805-808 */
if (ctx->typec.partner) {
pr_info("TC1: remove saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
pr_info("TC1: remove unregisters port\n");
typec_unregister_port(ctx->typec.port);
ctx->typec.port = NULL;
pr_info("TC1: remove done\n");
return 0;
}
/* ---------------- orchestration ---------------- */
static struct tyc_anx *g_anx;
static struct tyc_stu *g_stu;
static struct kprobe kp_unreg;
static int tc_setup_common(struct tyc_typec *t, struct device *parent)
{
struct typec_capability cap = { };
struct typec_partner_desc pdesc = { };
struct typec_altmode_desc adesc = { };
int i;
cap.type = TYPEC_PORT_DRP;
cap.data = TYPEC_PORT_DRD;
cap.revision = USB_TYPEC_REV_1_2;
cap.driver_data = t;
t->port = typec_register_port(parent, &cap);
if (IS_ERR(t->port)) {
pr_err("typec_register_port failed: %pe\n", t->port);
return PTR_ERR(t->port);
}
t->partner = typec_register_partner(t->port, &pdesc);
if (IS_ERR(t->partner)) {
pr_err("typec_register_partner failed: %pe\n", t->partner);
typec_unregister_port(t->port);
t->port = NULL;
return PTR_ERR(t->partner);
}
for (i = 0; i < 2; i++) {
adesc.svid = 0x1234 + i;
adesc.mode = i + 1;
adesc.vdo = 0xdeadbeef;
adesc.roles = TYPEC_PORT_DRD;
t->amode[i] = typec_partner_register_altmode(t->partner, &adesc);
if (IS_ERR(t->amode[i])) {
pr_err("partner_register_altmode[%d] failed: %pe\n",
i, t->amode[i]);
t->amode[i] = NULL;
return -EFAULT;
}
}
return 0;
}
static int run_tc0(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&g_anx->work_in_use);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwq");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
ret = register_kprobe(&kp_use);
if (ret) {
pr_err("TC0: kprobe(use) failed %d\n", ret);
goto out;
}
g_anx0 = g_anx;
anx_use_mode = true;
/* 1. queue work; it parks inside kprobe at its first use, having
* already read amode[0] (audited check-then-use gap). */
queue_work(g_anx->wq, &g_anx->work);
/* 2. wait until parked */
if (!wait_for_completion_timeout(&g_anx->work_in_use, msecs_to_jiffies(2000))) {
pr_err("TC0: work never parked; abort\n");
goto out;
}
/* 3. remove thread frees altmodes+partner under the parked work */
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm");
if (!IS_ERR(rm))
kthread_stop(rm); /* joins: remove completes inside window */
/* 4. work auto-resumes after 400ms -> UAF */
pr_info("TC0: RESULT see KASAN reports above\n");
msleep(300);
anx_unregister_altmode_replica(g_anx);
anx_unregister_partner_replica(g_anx);
out:
unregister_kprobe(&kp_use);
g_anx0 = NULL;
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int run_tc0b(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0b");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&tc_done_wait);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwqb");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_altmode",
.pre_handler = kp_unreg_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC0b: kprobe failed %d\n", ret);
goto out;
}
/* 1. remove thread parks inside kprobe BEFORE its unregister */
oneshot_armed = true;
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm0b");
if (IS_ERR(rm)) {
pr_err("TC0b: kthread failed\n");
goto out;
}
/* 2. wait parked */
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC0b: remove never parked\n");
kthread_stop(rm);
goto out;
}
/* 3. now let the work run: it unregisters the same altmodes+partner
* (device freed) while remove is parked mid-unregister */
queue_work(g_anx->wq, &g_anx->work);
msleep(100);
/* 4. remove resumes -> unregister on freed device */
pr_info("TC0b: remove resumes at ~300ms\n");
msleep(700); /* let everything settle */
cancel_work_sync(&g_anx->work);
pr_info("TC0b: RESULT see KASAN reports above\n");
out:
unregister_kprobe(&kp_unreg);
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int kp_park_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC1: irq thread parked at typec_unregister_partner (partner=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC1: irq resumes unregister (partner likely freed)\n");
return 0;
}
static int run_tc1(void)
{
struct device *root_dev;
int ret;
root_dev = root_device_register("tycroot1");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_stu = kzalloc(sizeof(*g_stu), GFP_KERNEL);
if (!g_stu) {
root_device_unregister(root_dev);
return -ENOMEM;
}
init_completion(&tc_done_wait);
init_completion(&g_stu->irq_parked);
ret = tc_setup_common(&g_stu->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_partner",
.pre_handler = kp_park_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC1: kprobe failed %d\n", ret);
goto out;
}
/* 1. irq thread parks at its unregister (partner still alive) */
oneshot_armed = true;
g_stu->irq_thread = kthread_run(stu_irq_thread, g_stu, "tyc-stuirq");
if (IS_ERR(g_stu->irq_thread)) {
g_stu->irq_thread = NULL;
goto out;
}
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC1: irq never parked\n");
goto out;
}
/* 2. remove sequence inline on the insmod thread (partner still
* alive -> unregister frees it, then port); parked irq thread is
* on another CPU */
stu_remove_thread(g_stu);
/* 3. irq resumes -> unregister on freed partner */
pr_info("TC1: RESULT see KASAN reports above\n");
msleep(300);
out:
unregister_kprobe(&kp_unreg);
if (g_stu) {
if (g_stu->irq_thread)
kthread_stop(g_stu->irq_thread);
kfree(g_stu);
g_stu = NULL;
}
root_device_unregister(root_dev);
return 0;
}
/* ---------------- tc2: Case 1-B deadlock structure replica ----------------
* Faithful structure replica of the audited UCSI unbind deadlock:
* - unbind thread holds "con->lock" and calls cancel_delayed_work_sync()
* (ucsi.c:2003-2004 -> mode_selection.c:277);
* - the mode-selection work, inside its activate window, must take the
* same lock via the ucsi_con_mutex_lock() trylock loop whose exit
* condition is the STALE con->status CONNECTED snapshot
* (ucsi.c:2272-2277; ucsi.h:457 reads ->status, never refreshed on the
* unbind path).
* Expected result: both sides park forever; the VM hangs until timeout.
*/
struct tyc_dead {
struct mutex lock; /* con->lock analog */
bool connected_snapshot; /* con->status CONNECTED bit (stale) */
struct delayed_work work;
struct completion in_loop; /* handshake: work inside trylock loop */
bool ctl; /* ctl=true: lock first, queue later */
};
static void dead_work_fn(struct work_struct *w)
{
struct tyc_dead *d = container_of(w, struct tyc_dead, work.work);
bool locked = false;
pr_info("TC2: work entered activate window, needs con->lock\n");
/* first trylock fails: unbind already holds the lock */
locked = mutex_trylock(&d->lock) != 0;
if (!locked)
complete(&d->in_loop);
/* ucsi_con_mutex_lock() loop, ucsi.c:2272-2277, verbatim shape */
while (d->connected_snapshot && !locked) {
locked = mutex_trylock(&d->lock) != 0;
if (d->connected_snapshot && !locked) {
msleep(20);
}
}
if (locked)
mutex_unlock(&d->lock);
pr_info("TC2: work finished\n");
}
/* audited interleaving: work enters the loop first, unbind then takes the
* lock and cancels -- expected permanent hang (VM killed by timeout). */
static int run_tc2(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true; /* cable connected snapshot */
d->ctl = false;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
/* audited order: unbind holds con->lock FIRST, work starts and
* fails its trylocks while the lock stays held */
mutex_lock(&d->lock);
pr_info("TC2: unbind holds con->lock; scheduling work\n");
schedule_delayed_work(&d->work, 0);
if (!wait_for_completion_timeout(&d->in_loop, msecs_to_jiffies(2000))) {
pr_err("TC2: work never entered loop\n");
mutex_unlock(&d->lock);
kfree(d);
return -ETIMEDOUT;
}
pr_info("TC2: work looping on failed trylocks; unbind calls cancel (expect hang)\n");
cancel_delayed_work_sync(&d->work); /* audited hang */
pr_info("TC2: cancel returned (should never print)\n");
mutex_unlock(&d->lock);
kfree(d);
return 0;
}
/* control: unbind takes the lock BEFORE the work ever runs; cancel() kills
* the pending timer -- no deadlock (audited escape-branch 1). */
static int run_tc2ctl(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true;
d->ctl = true;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
mutex_lock(&d->lock);
pr_info("TC2CTL: unbind holds lock first, queueing work\n");
schedule_delayed_work(&d->work, msecs_to_jiffies(50));
cancel_delayed_work_sync(&d->work); /* pending -> killed */
pr_info("TC2CTL: cancel returned (expected, pending branch)\n");
mutex_unlock(&d->lock);
msleep(200);
kfree(d);
return 0;
}
/* ---------------- tcnat: natural hammering (no probes) ---------------- */
static int tcnat_rounds = 1000;
module_param(tcnat_rounds, int, 0444);
static int run_tcnat(void)
{
struct device *root_dev;
int round;
for (round = 0; round < tcnat_rounds; round++) {
root_dev = root_device_register("tycrootnat");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycnatwq");
if (tc_setup_common(&g_anx->typec, root_dev)) {
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
return -EFAULT;
}
/* natural race: queue work, then immediately remove */
queue_work(g_anx->wq, &g_anx->work);
anx_remove_thread(g_anx);
cancel_work_sync(&g_anx->work);
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
if (!(round % 100))
pr_info("TCNAT: round %d done\n", round);
}
pr_info("TCNAT: %d rounds done (check dmesg for KASAN reports)\n",
tcnat_rounds);
return 0;
}
/* ---------------- module params & entry ---------------- */
static char *action = "status";
module_param(action, charp, 0444);
MODULE_PARM_DESC(action, "tc0|tc0b|tc1|tc2|tcnat|status");
static int tyc_run_action(void)
{
if (!strcmp(action, "tc0"))
return run_tc0();
if (!strcmp(action, "tc0b"))
return run_tc0b();
if (!strcmp(action, "tc1"))
return run_tc1();
if (!strcmp(action, "tc2"))
return run_tc2();
if (!strcmp(action, "tc2ctl"))
return run_tc2ctl();
if (!strcmp(action, "tcnat"))
return run_tcnat();
pr_info("no action, done\n");
return 0;
}
static int __init tyc_init(void)
{
pr_info("action=%s\n", action);
return tyc_run_action();
}
module_init(tyc_init);
static void __exit tyc_exit(void)
{
pr_info("exit\n");
}
module_exit(tyc_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* usb: typec: stusb160x/rt1719/hd3ss3220: IRQ thread vs remove -- double unregister / use-after-free on the partner and port
2026-10-27 14:12 ` Greg KH
` (8 preceding siblings ...)
2026-11-04 12:58 ` usb: typec: ucsi: deadlock on cros_ec unbind -- connector lock held across cancel_delayed_work_sync vs the mode-selection work's unbounded trylock polling 徐晗翔
@ 2026-11-04 13:00 ` 徐晗翔
2026-11-04 13:01 ` usb: typec: ucsi: displayport: data race on dp->header/ vdo_data/vdo_size -- work reads them unlocked while writers hold con->lock (VDM mismatch) 徐晗翔
` (11 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 13:00 UTC (permalink / raw)
To: heikki.krogerus; +Cc: linux-usb, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 5091 bytes --]
Three typec controller drivers share one teardown defect: their IRQ
thread mutates the registered partner (typec_register_partner /
typec_unregister_partner, and data-role setting) with no
synchronization against .remove(), and the IRQ itself is devm-req-
uested -- so it keeps running until AFTER .remove returns and the
port/partner objects may already be gone. stusb160x_remove() even
performs the same unlocked read-then-unregister on chip->partner
(:805-808) that the IRQ thread performs (:388-389): both sides' "see
non-NULL -> unregister" pairs race, producing a double
typec_unregister_partner on a possibly freed partner (use-after-free
writes inside the class layer), and the trailing
typec_unregister_port() frees the port while the IRQ thread may
still hold the stale port pointer. rt1719_remove() does not touch
the partner at all, so its IRQ thread can unregister the partner
after remove and even re-ATTACH one onto the removed port (orphan),
calling typec_set_pwr_opmode() on the freed port (sysfs_notify on a
dead kobject). hd3ss3220 (IRQ mode) races typec_set_data_role()
against the port teardown the same way. I verified the core
interleaving on a pristine Linux 7.3 KASAN kernel with a
statement-level replica of the stusb160x shape over the NATIVE typec
class and device core: an IRQ thread parked right after seeing a
non-NULL partner, then the remove side unregistering partner+port,
then the resumed IRQ unregistering on the freed partner -- four
typec_unregister_partner UAF reports, a freed-mutex wait, a kernfs
NULL-deref and two Oopses, with the timeline showing both sides
reading the SAME partner pointer before the free. The fix is the
ordering the sibling driver wusb3801 already uses: stop/wait the IRQ
(free_irq, which waits for a running ONESHOT thread) before touching
partner/port in remove.
The detailed description:
* The three drivers (Linux 7.3 line numbers)
- stusb160x: IRQ thread stusb160x_irq_handler (:402-429) calls
stusb160x_attach (:416 -> :364 chip->partner = typec_register_
partner(...)) / stusb160x_detach (:418 -> :388-389 unregister +
NULL) directly; stusb160x_remove (:800-815) does the same
unlocked operations at :805-808 and unregisters the port at
:815; devm IRQ at :452.
- rt1719: rt1719_irq_handler (:586-624) calls rt1719_attach
(:611; re-register at :247 after a defensive pre-unregister
:238-240) / rt1719_detach (:614 -> :288); rt1719_remove
(:928-933) only unregisters the port; devm IRQ :645.
- hd3ss3220 (IRQ mode): hd3ss3220_irq_handler (:280-286) ->
hd3ss3220_set_role (:226-241) -> typec_set_data_role (:237/:240);
remove (:522-532) cancels only the polling-mode worker (:527)
and unregisters the port (:529); devm IRQ :438/:489.
Why nothing protects this: devres_release_all runs after .remove
returns (the window covers the whole remove body); IRQF_ONESHOT
only prevents re-entry of the thread, not its concurrency with
remove; typec_unregister_partner()'s port->partner_link_lock
(class.c:1183-1194) orders the class-side list ops but neither
guards the driver's chip->partner pointer nor the port's
lifetime; and after the port is freed the IRQ thread's port
pointer is dangling (typec_port_release kfrees it).
* Evidence (pristine 7.3 KASAN; module attached)
The stusb160x-shape replica (stu_irq/stu_remove in the module)
over the native class layer: the IRQ thread is parked right after
it observed chip->partner != NULL; the remove side then reads the
same non-NULL pointer, unregisters partner and port; on release,
the IRQ thread's typec_unregister_partner() lands on the freed
partner:
irq saw partner ... parked
remove saw partner (same pointer) -> unregistered + port gone
(400 ms later) irq resumes ->
BUG: KASAN: slab-use-after-free in
typec_unregister_partner+0x51/+0xd6 (x4)
+ mutex_lock on freed lock + kernfs_find_and_get_ns NULL-deref
+ Oops x2
Fidelity: typec_register/unregister_partner, the port release and
the whole device core are native; the driver's dual
read-then-unregister pairs are replicated statement-by-statement
per stusb160x.c:388-389/:800-815.
Module usage: insmod typcrace.ko; action=tc1 (the stusb160x
choreography). On real hardware the shape is reached by unbinding
the i2c driver while a cable attach/detach event is in flight --
plain plug/unplug traffic suffices to have the IRQ thread inside
attach/detach at unbind time.
* Suggested fix
Stop the IRQ before mutating partner/port in remove:
stusb160x: free_irq() (or disable+cancel) first, then the
:805-808 pair and the port unregister; rt1719: free_irq() first,
and make remove also detach the partner (or have the IRQ thread
check a removing flag); hd3ss3220: free_irq() in both modes
before the port unregister. The correct-order precedent is
wusb3801.c:402-414, which free_irq()s -- waiting for the in-flight
ONESHOT thread -- before touching partner/port.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: typcrace.c --]
[-- Type: text/plain, Size: 18699 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* typcrace.c - dynamic verification module for drivers/usb/typec audit
* (docs/manual-review-drivers-usb-typec-2026-11-02.round3.md)
*
* Statement-faithful replicas of the audited driver state machines (anx7411,
* stusb160x), driven via the exported typec class API on a fake typec_port,
* with one-shot kprobe stalls making the audited interleavings deterministic.
* Kernel source unmodified (build-kasan-typec-7.3).
*
* tc0 - audit Case 3 interleaving 1: anx7411 work uses amode[] pointer
* (check-then-use, anx7411.c:602-613) while remove frees the device.
* Work is parked by a kprobe at its use point
* (typec_altmode_update_active) AFTER it read amode[i]; the remove
* thread unregisters (frees) the altmode during the park; on resume
* the use dereferences freed memory -> KASAN UAF.
* tc0b - audit Case 3 interleaving 2: double device_unregister. Remove is
* parked by a one-shot kprobe at typec_unregister_altmode BEFORE it
* executes; the queued work then unregisters the same altmode
* (device freed); on resume remove's unregister runs on the freed
* device -> UAF inside typec_unregister_altmode/class core.
* tc1 - audit Case 4 (stusb160x form): irq thread parked by one-shot kprobe
* at typec_unregister_partner (having seen chip->partner != NULL);
* remove thread then unregisters the same partner and unregisters the
* port; irq resumes -> unregister on freed partner -> UAF.
*/
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/delay.h>
#include <linux/ktime.h>
#include <linux/workqueue.h>
#include <linux/kthread.h>
#include <linux/completion.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/usb/typec.h>
#include <linux/usb/typec_altmode.h>
#define MAX_ALTMODE 4
struct tyc_typec {
struct typec_port *port;
struct typec_partner *partner;
struct typec_altmode *amode[MAX_ALTMODE];
};
/* ---------------- anx7411 replica (Case 3) ---------------- */
struct tyc_anx {
struct tyc_typec typec;
struct mutex lock; /* analog of ctx->lock: work side only */
struct work_struct work;
struct workqueue_struct *wq;
struct completion work_in_use; /* work parked at its use point */
};
static void anx_unregister_altmode_replica(struct tyc_anx *ctx)
{
int i;
/* anx7411.c:535-549 -- NO ctx->lock (audited) */
for (i = 0; i < MAX_ALTMODE; i++)
if (ctx->typec.amode[i]) {
typec_unregister_altmode(ctx->typec.amode[i]);
ctx->typec.amode[i] = NULL;
}
}
static void anx_unregister_partner_replica(struct tyc_anx *ctx)
{
/* anx7411.c:585-591 -- NO ctx->lock (audited) */
if (ctx->typec.partner) {
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
static bool anx_use_mode; /* tc0: work parks at use point */
static struct tyc_anx *g_anx0; /* tc0 target for kprobe lookup */
/*
* tc0 kprobe target: typec_altmode_update_active is only reached from the
* work's use point in this module (the remove thread never calls it).
* Park here AFTER the work has read amode[i] (audited check-then-use gap).
*/
static int kp_use_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
struct tyc_anx *ctx = g_anx0;
if (!anx_use_mode || !ctx)
return 0;
anx_use_mode = false;
pr_info("TC0: work parked at use point (adev=%px), remove frees now\n",
(void *)regs->di);
complete(&ctx->work_in_use);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0: work resumes use on (possibly freed) adev\n");
return 0;
}
static struct kprobe kp_use = {
.symbol_name = "typec_altmode_update_active",
.pre_handler = kp_use_pre,
};
/* work replica of anx7411_work_func (anx7411.c:906-984) */
static void anx_work_func(struct work_struct *w)
{
struct tyc_anx *ctx = container_of(w, struct tyc_anx, work);
int i;
mutex_lock(&ctx->lock); /* :916 */
/* "update_altmode" check-then-use loop (:602-613) */
for (i = 0; i < MAX_ALTMODE; i++) {
if (!ctx->typec.amode[i])
continue;
pr_info("TC0: work read amode[%d]=%px svid=%04x, calling update_active\n",
i, ctx->typec.amode[i], ctx->typec.amode[i]->svid);
typec_altmode_update_active(ctx->typec.amode[i], true);
}
/* POW_OFF branch (:943-946) */
pr_info("TC0: work unregister altmode+partner\n");
anx_unregister_altmode_replica(ctx);
anx_unregister_partner_replica(ctx);
mutex_unlock(&ctx->lock);
}
static int anx_remove_thread(void *data)
{
struct tyc_anx *ctx = data;
pr_info("TC0*: remove enters (audited order: unregister FIRST)\n");
anx_unregister_altmode_replica(ctx); /* :1566 */
anx_unregister_partner_replica(ctx); /* :1567 */
pr_info("TC0*: remove stops work (too late, audited)\n");
cancel_work_sync(&ctx->work); /* :1571 */
pr_info("TC0*: remove done\n");
return 0;
}
/* one-shot stall at typec_unregister_altmode (tc0b): park the FIRST caller */
static bool oneshot_armed;
static struct completion tc_done_wait;
static int kp_unreg_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC0b: remove parked at typec_unregister_altmode (adev=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC0b: parked unregister resumes (device likely freed)\n");
return 0;
}
/* ---------------- stusb160x replica (Case 4) ---------------- */
struct tyc_stu {
struct tyc_typec typec;
struct task_struct *irq_thread;
struct completion irq_parked;
};
static int stu_irq_thread(void *data)
{
struct tyc_stu *ctx = data;
while (!kthread_should_stop()) {
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(msecs_to_jiffies(20));
if (kthread_should_stop())
break;
/* stusb160x_detach(:388-389) check-then-use */
if (ctx->typec.partner) {
pr_info("TC1: irq saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
}
return 0;
}
static int stu_remove_thread(void *data)
{
struct tyc_stu *ctx = data;
pr_info("TC1: remove enters (devm-irq window open)\n");
/* stusb160x.c:805-808 */
if (ctx->typec.partner) {
pr_info("TC1: remove saw partner=%px, unregistering\n",
ctx->typec.partner);
typec_unregister_partner(ctx->typec.partner);
ctx->typec.partner = NULL;
}
pr_info("TC1: remove unregisters port\n");
typec_unregister_port(ctx->typec.port);
ctx->typec.port = NULL;
pr_info("TC1: remove done\n");
return 0;
}
/* ---------------- orchestration ---------------- */
static struct tyc_anx *g_anx;
static struct tyc_stu *g_stu;
static struct kprobe kp_unreg;
static int tc_setup_common(struct tyc_typec *t, struct device *parent)
{
struct typec_capability cap = { };
struct typec_partner_desc pdesc = { };
struct typec_altmode_desc adesc = { };
int i;
cap.type = TYPEC_PORT_DRP;
cap.data = TYPEC_PORT_DRD;
cap.revision = USB_TYPEC_REV_1_2;
cap.driver_data = t;
t->port = typec_register_port(parent, &cap);
if (IS_ERR(t->port)) {
pr_err("typec_register_port failed: %pe\n", t->port);
return PTR_ERR(t->port);
}
t->partner = typec_register_partner(t->port, &pdesc);
if (IS_ERR(t->partner)) {
pr_err("typec_register_partner failed: %pe\n", t->partner);
typec_unregister_port(t->port);
t->port = NULL;
return PTR_ERR(t->partner);
}
for (i = 0; i < 2; i++) {
adesc.svid = 0x1234 + i;
adesc.mode = i + 1;
adesc.vdo = 0xdeadbeef;
adesc.roles = TYPEC_PORT_DRD;
t->amode[i] = typec_partner_register_altmode(t->partner, &adesc);
if (IS_ERR(t->amode[i])) {
pr_err("partner_register_altmode[%d] failed: %pe\n",
i, t->amode[i]);
t->amode[i] = NULL;
return -EFAULT;
}
}
return 0;
}
static int run_tc0(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&g_anx->work_in_use);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwq");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
ret = register_kprobe(&kp_use);
if (ret) {
pr_err("TC0: kprobe(use) failed %d\n", ret);
goto out;
}
g_anx0 = g_anx;
anx_use_mode = true;
/* 1. queue work; it parks inside kprobe at its first use, having
* already read amode[0] (audited check-then-use gap). */
queue_work(g_anx->wq, &g_anx->work);
/* 2. wait until parked */
if (!wait_for_completion_timeout(&g_anx->work_in_use, msecs_to_jiffies(2000))) {
pr_err("TC0: work never parked; abort\n");
goto out;
}
/* 3. remove thread frees altmodes+partner under the parked work */
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm");
if (!IS_ERR(rm))
kthread_stop(rm); /* joins: remove completes inside window */
/* 4. work auto-resumes after 400ms -> UAF */
pr_info("TC0: RESULT see KASAN reports above\n");
msleep(300);
anx_unregister_altmode_replica(g_anx);
anx_unregister_partner_replica(g_anx);
out:
unregister_kprobe(&kp_use);
g_anx0 = NULL;
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int run_tc0b(void)
{
struct device *root_dev;
struct task_struct *rm;
int ret;
root_dev = root_device_register("tycroot0b");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
init_completion(&tc_done_wait);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycanxwqb");
ret = tc_setup_common(&g_anx->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_altmode",
.pre_handler = kp_unreg_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC0b: kprobe failed %d\n", ret);
goto out;
}
/* 1. remove thread parks inside kprobe BEFORE its unregister */
oneshot_armed = true;
rm = kthread_run(anx_remove_thread, g_anx, "tyc-anxrm0b");
if (IS_ERR(rm)) {
pr_err("TC0b: kthread failed\n");
goto out;
}
/* 2. wait parked */
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC0b: remove never parked\n");
kthread_stop(rm);
goto out;
}
/* 3. now let the work run: it unregisters the same altmodes+partner
* (device freed) while remove is parked mid-unregister */
queue_work(g_anx->wq, &g_anx->work);
msleep(100);
/* 4. remove resumes -> unregister on freed device */
pr_info("TC0b: remove resumes at ~300ms\n");
msleep(700); /* let everything settle */
cancel_work_sync(&g_anx->work);
pr_info("TC0b: RESULT see KASAN reports above\n");
out:
unregister_kprobe(&kp_unreg);
if (g_anx) {
if (g_anx->wq)
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
}
root_device_unregister(root_dev);
return 0;
}
static int kp_park_pre(struct kprobe *p, struct pt_regs *regs)
{
unsigned long long t0;
if (!oneshot_armed)
return 0;
oneshot_armed = false;
pr_info("TC1: irq thread parked at typec_unregister_partner (partner=%px)\n",
(void *)regs->di);
complete(&tc_done_wait);
t0 = ktime_get_mono_fast_ns();
while (ktime_get_mono_fast_ns() - t0 < 400ULL * NSEC_PER_MSEC)
cpu_relax();
pr_info("TC1: irq resumes unregister (partner likely freed)\n");
return 0;
}
static int run_tc1(void)
{
struct device *root_dev;
int ret;
root_dev = root_device_register("tycroot1");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_stu = kzalloc(sizeof(*g_stu), GFP_KERNEL);
if (!g_stu) {
root_device_unregister(root_dev);
return -ENOMEM;
}
init_completion(&tc_done_wait);
init_completion(&g_stu->irq_parked);
ret = tc_setup_common(&g_stu->typec, root_dev);
if (ret)
goto out;
kp_unreg = (struct kprobe){
.symbol_name = "typec_unregister_partner",
.pre_handler = kp_park_pre,
};
ret = register_kprobe(&kp_unreg);
if (ret) {
pr_err("TC1: kprobe failed %d\n", ret);
goto out;
}
/* 1. irq thread parks at its unregister (partner still alive) */
oneshot_armed = true;
g_stu->irq_thread = kthread_run(stu_irq_thread, g_stu, "tyc-stuirq");
if (IS_ERR(g_stu->irq_thread)) {
g_stu->irq_thread = NULL;
goto out;
}
if (!wait_for_completion_timeout(&tc_done_wait, msecs_to_jiffies(2000))) {
pr_err("TC1: irq never parked\n");
goto out;
}
/* 2. remove sequence inline on the insmod thread (partner still
* alive -> unregister frees it, then port); parked irq thread is
* on another CPU */
stu_remove_thread(g_stu);
/* 3. irq resumes -> unregister on freed partner */
pr_info("TC1: RESULT see KASAN reports above\n");
msleep(300);
out:
unregister_kprobe(&kp_unreg);
if (g_stu) {
if (g_stu->irq_thread)
kthread_stop(g_stu->irq_thread);
kfree(g_stu);
g_stu = NULL;
}
root_device_unregister(root_dev);
return 0;
}
/* ---------------- tc2: Case 1-B deadlock structure replica ----------------
* Faithful structure replica of the audited UCSI unbind deadlock:
* - unbind thread holds "con->lock" and calls cancel_delayed_work_sync()
* (ucsi.c:2003-2004 -> mode_selection.c:277);
* - the mode-selection work, inside its activate window, must take the
* same lock via the ucsi_con_mutex_lock() trylock loop whose exit
* condition is the STALE con->status CONNECTED snapshot
* (ucsi.c:2272-2277; ucsi.h:457 reads ->status, never refreshed on the
* unbind path).
* Expected result: both sides park forever; the VM hangs until timeout.
*/
struct tyc_dead {
struct mutex lock; /* con->lock analog */
bool connected_snapshot; /* con->status CONNECTED bit (stale) */
struct delayed_work work;
struct completion in_loop; /* handshake: work inside trylock loop */
bool ctl; /* ctl=true: lock first, queue later */
};
static void dead_work_fn(struct work_struct *w)
{
struct tyc_dead *d = container_of(w, struct tyc_dead, work.work);
bool locked = false;
pr_info("TC2: work entered activate window, needs con->lock\n");
/* first trylock fails: unbind already holds the lock */
locked = mutex_trylock(&d->lock) != 0;
if (!locked)
complete(&d->in_loop);
/* ucsi_con_mutex_lock() loop, ucsi.c:2272-2277, verbatim shape */
while (d->connected_snapshot && !locked) {
locked = mutex_trylock(&d->lock) != 0;
if (d->connected_snapshot && !locked) {
msleep(20);
}
}
if (locked)
mutex_unlock(&d->lock);
pr_info("TC2: work finished\n");
}
/* audited interleaving: work enters the loop first, unbind then takes the
* lock and cancels -- expected permanent hang (VM killed by timeout). */
static int run_tc2(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true; /* cable connected snapshot */
d->ctl = false;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
/* audited order: unbind holds con->lock FIRST, work starts and
* fails its trylocks while the lock stays held */
mutex_lock(&d->lock);
pr_info("TC2: unbind holds con->lock; scheduling work\n");
schedule_delayed_work(&d->work, 0);
if (!wait_for_completion_timeout(&d->in_loop, msecs_to_jiffies(2000))) {
pr_err("TC2: work never entered loop\n");
mutex_unlock(&d->lock);
kfree(d);
return -ETIMEDOUT;
}
pr_info("TC2: work looping on failed trylocks; unbind calls cancel (expect hang)\n");
cancel_delayed_work_sync(&d->work); /* audited hang */
pr_info("TC2: cancel returned (should never print)\n");
mutex_unlock(&d->lock);
kfree(d);
return 0;
}
/* control: unbind takes the lock BEFORE the work ever runs; cancel() kills
* the pending timer -- no deadlock (audited escape-branch 1). */
static int run_tc2ctl(void)
{
struct tyc_dead *d;
d = kzalloc(sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
mutex_init(&d->lock);
init_completion(&d->in_loop);
d->connected_snapshot = true;
d->ctl = true;
INIT_DELAYED_WORK(&d->work, dead_work_fn);
mutex_lock(&d->lock);
pr_info("TC2CTL: unbind holds lock first, queueing work\n");
schedule_delayed_work(&d->work, msecs_to_jiffies(50));
cancel_delayed_work_sync(&d->work); /* pending -> killed */
pr_info("TC2CTL: cancel returned (expected, pending branch)\n");
mutex_unlock(&d->lock);
msleep(200);
kfree(d);
return 0;
}
/* ---------------- tcnat: natural hammering (no probes) ---------------- */
static int tcnat_rounds = 1000;
module_param(tcnat_rounds, int, 0444);
static int run_tcnat(void)
{
struct device *root_dev;
int round;
for (round = 0; round < tcnat_rounds; round++) {
root_dev = root_device_register("tycrootnat");
if (IS_ERR(root_dev))
return PTR_ERR(root_dev);
g_anx = kzalloc(sizeof(*g_anx), GFP_KERNEL);
if (!g_anx) {
root_device_unregister(root_dev);
return -ENOMEM;
}
mutex_init(&g_anx->lock);
INIT_WORK(&g_anx->work, anx_work_func);
g_anx->wq = create_singlethread_workqueue("tycnatwq");
if (tc_setup_common(&g_anx->typec, root_dev)) {
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
return -EFAULT;
}
/* natural race: queue work, then immediately remove */
queue_work(g_anx->wq, &g_anx->work);
anx_remove_thread(g_anx);
cancel_work_sync(&g_anx->work);
destroy_workqueue(g_anx->wq);
kfree(g_anx);
g_anx = NULL;
root_device_unregister(root_dev);
if (!(round % 100))
pr_info("TCNAT: round %d done\n", round);
}
pr_info("TCNAT: %d rounds done (check dmesg for KASAN reports)\n",
tcnat_rounds);
return 0;
}
/* ---------------- module params & entry ---------------- */
static char *action = "status";
module_param(action, charp, 0444);
MODULE_PARM_DESC(action, "tc0|tc0b|tc1|tc2|tcnat|status");
static int tyc_run_action(void)
{
if (!strcmp(action, "tc0"))
return run_tc0();
if (!strcmp(action, "tc0b"))
return run_tc0b();
if (!strcmp(action, "tc1"))
return run_tc1();
if (!strcmp(action, "tc2"))
return run_tc2();
if (!strcmp(action, "tc2ctl"))
return run_tc2ctl();
if (!strcmp(action, "tcnat"))
return run_tcnat();
pr_info("no action, done\n");
return 0;
}
static int __init tyc_init(void)
{
pr_info("action=%s\n", action);
return tyc_run_action();
}
module_init(tyc_init);
static void __exit tyc_exit(void)
{
pr_info("exit\n");
}
module_exit(tyc_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* usb: typec: ucsi: displayport: data race on dp->header/ vdo_data/vdo_size -- work reads them unlocked while writers hold con->lock (VDM mismatch)
2026-10-27 14:12 ` Greg KH
` (9 preceding siblings ...)
2026-11-04 13:00 ` usb: typec: stusb160x/rt1719/hd3ss3220: IRQ thread vs remove -- double unregister / use-after-free on the partner and port 徐晗翔
@ 2026-11-04 13:01 ` 徐晗翔
2026-11-04 13:02 ` usb: typec: class: three lifetime/locking gaps around the mux/switch handles and mode_selection (drvdata UAF after provider unbind; no concurrent protocol for set/put; unlocked state_update vs delete) 徐晗翔
` (10 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 13:01 UTC (permalink / raw)
To: heikki.krogerus; +Cc: linux-usb, linux-kernel
The UCSI displayport altmode's reply triple (dp->header / vdo_data /
vdo_size, ucsi/displayport.c:28-30) is written by three writers that
all hold con->lock (enter :97-102, exit :142-147, vdm :234+) but read
by ucsi_displayport_work (:282-295) with NO lock at all -- the work
is scheduled asynchronously (:104/:149/:265) and never acquires
con->lock, so the lock only serializes the writers among themselves
and does not cover the reader. When the worker runs while a writer
updates the triple (the mode_selection activate path entering
ucsi_displayport_enter, or the next EC event arriving while the
worker is inside typec_altmode_vdm -> the partner driver), the VDM
message assembled from the mixed fields is sent to the partner
driver: a stale/mixed header with fresh data or vice versa -- a DP
state machine glitch that the partner sees as a malformed or
misdirected VDM (self-healing on the next event, but still a protocol
violation and a plain KCSAN data race). Lifetime is NOT at risk
(vdo_data only ever points at the persistent &dp->data.status or
NULL; the worker is cancel_work_sync'ed before teardown), so this is
a small locking fix: read/clear the triple under the same lock (or a
dedicated dp->lock) in the worker.
The detailed description:
* The racy pair (Linux 7.3, drivers/usb/typec/ucsi/displayport.c)
Writers (all inside ucsi_con_mutex_lock ... unlock, :57/:119/:209):
- enter path: :97-102 sets dp->header = ENTER_ACK (with
vdo_data/vdo_size) and schedules dp->work (:104);
- exit path: :142-147 + :149;
- vdm path: :234+ (with :183-184 for the ATTENTION-derived
variant) + :265.
Reader (no lock): ucsi_displayport_work (:282-295) reads
dp->header / dp->vdo_data / dp->vdo_size (:287-288) and clears
them (:292-294), then calls typec_altmode_vdm(dp->alt, ...) ->
bus.c:214-235 -> the partner driver's dp_altmode_vdm
(altmodes/displayport.c:372-443, under the driver's own dp->lock).
The worker never takes con->lock, and nothing stops a writer from
taking con->lock while the unlocked worker runs (the mode_selection
activate path enters ucsi_displayport_enter without holding
con->lock -- it takes it inside). schedule_work coalescing does
not close the window.
* Concrete interleavings
1. mode_selection activate vs an in-flight worker: the activate
path (thread A) writes ENTER_ACK and schedules; the previous
round's worker (thread B) is concurrently reading the old
header/vdo_data at :287-288 -- the partner receives a mixed
old/new VDM.
2. back-to-back vdm events: the worker is inside
typec_altmode_vdm (which enters the partner driver and takes
its dp->lock -- nonzero time), the next EC event drives
ucsi_displayport_vdm to overwrite the triple under con->lock --
same mixed-VDM outcome.
* Scope / severity
vdo_data only ever points at the persistent &dp->data.status (or
NULL, :101/:146/:183) and vdo_size is a u8 -- no dangling pointer,
no UAF; the worker is drained by ucsi_displayport_remove_partner's
cancel_work_sync (:297-313, :308) before the altmode teardown
(verified: ucsi.c:834-841 ordering). So the impact is: KCSAN
data-race reports plus transient VDM mismatches (partner-side DP
state machine warnings/retries; self-healing). Severity low; the
fix is small.
* Suggested fix
Have ucsi_displayport_work() snapshot the triple under
ucsi_con_mutex_lock(dp->con) (or a new dedicated dp->lock shared by
writers and the worker), then send the snapshot outside the lock.
Alternatively, writers coalesce under the lock with a
cancel_work_sync before overwriting -- more costly; the snapshot
is the clean minimal fix.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread* usb: typec: class: three lifetime/locking gaps around the mux/switch handles and mode_selection (drvdata UAF after provider unbind; no concurrent protocol for set/put; unlocked state_update vs delete)
2026-10-27 14:12 ` Greg KH
` (10 preceding siblings ...)
2026-11-04 13:01 ` usb: typec: ucsi: displayport: data race on dp->header/ vdo_data/vdo_size -- work reads them unlocked while writers hold con->lock (VDM mismatch) 徐晗翔
@ 2026-11-04 13:02 ` 徐晗翔
2026-11-10 12:56 ` driver core: uevent: netns teardown frees uevent_sock while a tagged uevent is still being broadcast (UAF) 徐晗翔
` (9 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-04 13:02 UTC (permalink / raw)
To: heikki.krogerus; +Cc: linux-usb, linux-kernel
The typec class layer leaves the lifetime of its mux/switch machinery
to caller discipline that nothing enforces or documents, and three
concrete gaps fall out of that. The reportable one is the provider
drvdata gap: a mux/switch handle holder keeps a device reference on
the provider's class device (class_find_device() takes get_device,
drivers/base/class.c:470), so after the PROVIDER driver unbinds,
typec_mux_unregister() only does device_unregister -- the mux_dev
shell (and its ->set callback pointer) survives as long as any owner
holds a handle, while the provider's devm private data is freed the
moment its .remove returns. The next orientation/mode change then
calls mux_dev->set() (mux.c:393-412) into e.g.
qmp_combo_typec_mux_set() (phy-qcom-qmp-combo.c:4804) or
rk_udphy_typec_mux_set() (phy-rockchip-usbdp.c:1346), which fetch
devm drvdata of a freed object -- use-after-free read/write with
KASAN visibility; the same shape applies to the switch/retimer-side
providers with explicit removes (ptn36502, nb7vpq904m). Two more
gaps in the same layer are contract-shaped and currently shielded
only by callers: (a) the aggregate typec_mux/typec_switch handles are
freshly allocated per get with no refcount/lock/RCU, typec_mux_set()
walks the callback list unlocked and typec_mux_put() kfrees on the
final put (the one in-tree violator of the resulting "drain before
put" discipline -- pmic_glink_altmode -- is reported separately); and
(b) mode_selection.c's exported state_update/delete pair has no
common lock or lifetime protocol at all (delete: partner->sel = NULL,
cancel_delayed_work_sync, mode_list_clean -- which does NOT take
sel->lock -- mutex_destroy, kfree; update: unlocked read of
partner->sel then mutex_lock + list access; today's only protection
is that every in-tree caller holds ucsi's con->lock, per the
enumeration below). Fixes: a dead-flag + -ENODEV check in the set
path for (the drvdata gap), a documented drain+put contract (or a
class-layer primitive) for the handle protocol, and a lock/lifetime
contract (or in-delete locking) for mode_selection.
The detailed description:
* Gap 1 (reportable UAF): provider drvdata outlived by the handle
Linux 7.3: the owner takes the handle via class_find_device()
(mux.c:295) which get_device()s the mux_dev (class.c:470); the
final typec_mux_put() (mux.c:376-391) drops it; the provider's
unregister (mux.c:478-483) only device_unregister()s. So:
provider unbind: .remove -> typec_mux_unregister (shell kept)
.remove returns -> devres releases the
provider's private data (qmp/udphy/ptn)
later mode/orientation change on the owner:
typec_mux_set(handle) -> mux_dev->set(...)
-> typec_mux_get_drvdata() -> FREED data
Instances with this devm-drvdata pattern: qcom-qmp-combo (set
:4804-4806, devm qmp, register :4924, unregister devm action
:4894-4898/:4930), rockchip-usbdp (set :1346, register :1413-1415,
unregister :1402-1406); ptn36502/nb7vpq904m have explicit removes
but devm priv/regmap, and their own put does not cover an owner
still holding the handle. Nothing else closes this: module refs
pin code; the parent-device reference pins the provider device
struct, not its devm data; the unregister's device_del has no
kernfs-drain semantics for this path (set is reached via the
owner's private pointer, not sysfs).
Fix: make the class layer mark the mux/switch dead at unregister
(WRITE_ONCE flag) and have typec_mux_set()/typec_switch_set()
return -ENODEV on it (READ_ONCE), or provide a drain primitive;
a transitional provider-side option is to allocate the drvdata
with the class device so the lifetimes coincide.
* Gap 2 (contract): aggregate handles have no concurrent protocol
mux.c allocates a fresh aggregate handle per get (:82-129/:320-368)
with no refcount/lock/RCU; typec_mux_set() (:393-412) walks the
callback list unlocked; typec_mux_put() kfrees on the final put;
and typec_unregister_altmode() even suggests the fragile order
(put at class.c:712 before device_unregister at :713). I audited
all owners: UCSI/cros/tipd/gaokun serialize set-vs-put by their
own construction (work drains, free_irq, notifier unregister --
tabulated in my notes; the one in-tree violator,
pmic_glink_altmode, has no cancel/flush at all and is reported
separately). The retimer class does it right (kref'd device,
retimer.c:65-81) -- the mux/switch aggregate handles are the
historical weak spot. Fix: document the drain+put discipline in
include/linux/usb/typec_mux.h (it currently says nothing about
concurrency) or provide a class-layer drain+put primitive.
* Gap 3 (latent): mode_selection state_update vs delete
mode_selection.c:176-197 (state_update: unlocked read of
partner->sel at :179, then mutex_lock(&sel->lock) + list access)
vs :271-283 (delete: partner->sel = NULL; cancel_delayed_work_sync
(covers only that work); mode_list_clean() at :102-110 which does
NOT take sel->lock and kfrees every node; mutex_destroy(); kfree
(sel)). Any overlap = UAF (locking a destroyed mutex; racing the
list cleanup). Today unreachable: the single update entry
(ucsi_altmode_update_active, ucsi.c:354-384) is only called under
con->lock (ucsi.c:1029/:1522/displayport.c:255 via the locked
ucsi_con_mutex_lock/thunderbolt.c:109), and the only delete
implementation (cros_ec) is likewise under con->lock
(ucsi.c:1317-1318 from :2004/:1513/:1402/:2156) -- the header
(typec_altmode.h:247-285) documents state_update as a generic
altmode-driver entry point but states no locking precondition, so
the first future caller that trusts the header reintroduces the
UAF. Fix: in-delete locking (mutex_lock/unlock(&sel->lock) before
the cleanup) or a kref/RCU anchor, and document the contract.
These three are reported together because they are the same layer
and the same root style: safety outsourced to undocumented caller
discipline. The class layer's own retimer implementation shows
the fix pattern.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread* driver core: uevent: netns teardown frees uevent_sock while a tagged uevent is still being broadcast (UAF)
2026-10-27 14:12 ` Greg KH
` (11 preceding siblings ...)
2026-11-04 13:02 ` usb: typec: class: three lifetime/locking gaps around the mux/switch handles and mode_selection (drvdata UAF after provider unbind; no concurrent protocol for set/put; unlocked state_update vs delete) 徐晗翔
@ 2026-11-10 12:56 ` 徐晗翔
2026-11-10 13:00 ` 徐晗翔
` (8 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 12:56 UTC (permalink / raw)
To: dakr, rafael; +Cc: driver-core, linux-kernel
net->uevent_sock is published as a bare pointer by uevent_net_init()
and freed by uevent_net_exit(), but the tagged broadcast path
dereferences net->uevent_sock->sk without holding uevent_sock_mutex or
taking a socket reference. uevent_net_exit() drops the mutex before
releasing the socket and never clears net->uevent_sock, so a kobject
whose namespace callback returns an unpinned net pointer can emit a
tagged uevent after the per-net exit has freed the socket; KASAN
reports a slab-use-after-free read in kobject_uevent_net_broadcast()
(inlined into kobject_uevent_env in the report below).
An IB compat device supplies a concrete path. Its rdma_net is a raw
possible_net_t, and its asynchronous unregister can still be inside
device_del() after the net namespace has run uevent_net_exit(). This
was reported by syzbot (KASAN: slab-use-after-free Read in
disable_device, extid 4393dfdddf166f2de2b0,
https://syzkaller.appspot.com/bug?extid=4393dfdddf166f2de2b0) and I
reproduced it locally on 7.3 with a KASAN kernel; the same reproducer
is clean with a no-rxe control and with the v4 locking change described
below.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (vanilla mainline tarball), x86_64, QEMU/KVM guest,
8 vCPU / 4 GB RAM, KASAN generic. The tested
lib/kobject_uevent.c is byte-identical to the audited 7.3 tree
(sha256 bbcf120ea86f42c74dec10e255a8cd0b3034c9f34cd5bb911fd94015729dd076).
Kernel config included CONFIG_NET_NS=y, CONFIG_INFINIBAND=y,
CONFIG_INFINIBAND_USER_ACCESS=y, CONFIG_RDMA_RXE=y, CONFIG_DUMMY=y
and CONFIG_KASAN=y. The kernel source was not modified to inject
the race; only the IB/RXE configuration was enabled and a userspace
reproducer was added.
* Mechanism
Three sides do not share a lifetime protocol:
- Publish side: uevent_net_init() allocates ue_sk and stores it
with net->uevent_sock = ue_sk (lib/kobject_uevent.c:773-803).
The field is a plain pointer in struct net, with no refcount,
RCU head, or socket reference (include/net/net_namespace.h:113).
- Free side: uevent_net_exit() reads the pointer, removes ue_sk
from the global uevent_sock_list under uevent_sock_mutex only
when it belongs to the initial user namespace, then unlocks and
calls netlink_kernel_release(ue_sk->sk) and kfree(ue_sk)
outside the mutex (lib/kobject_uevent.c:805-817). It never
writes net->uevent_sock = NULL.
- Read side: kobject_uevent_net_broadcast() obtains the target
net from the kobject namespace callback and passes
net->uevent_sock->sk directly to
uevent_net_broadcast_tagged() (lib/kobject_uevent.c:382-421;
the tagged dereference is at line 415). This path takes no
uevent_sock_mutex, no socket reference, and performs no NULL
check.
The IB trigger makes the window concrete:
- ib_class.namespace is net_namespace(), which only does
read_pnet(&coredev->rdma_net); rdma_init_coredev() stored the
net with write_pnet() without get_net()
(drivers/infiniband/core/device.c:536-550, 553-580).
- remove_one_compat_dev() erases the compat device from the
xarray under compat_devs_mutex, releases the mutex, and then
calls ib_free_port_attrs() and device_del()
(drivers/infiniband/core/device.c:1027-1039). device_del()
emits KOBJ_REMOVE at drivers/base/core.c:3983.
- device_del() has several sleeping or blocking points between
the xa_erase() and KOBJ_REMOVE (device_lock(), bus_notify(),
sysfs removal, class callbacks, devres_release_all()), so the
window is not merely theoretical.
On netns cleanup, rdma_dev_net_ops is registered with
register_pernet_device() (drivers/infiniband/core/device.c:3274),
while uevent_net_ops is registered with register_pernet_subsys()
(lib/kobject_uevent.c:826). Because the pernet list is walked in
reverse on exit, rdma_dev_exit_net() runs before
uevent_net_exit(). If the async IB path wins the xa_erase() in
remove_one_compat_dev(), rdma_dev_exit_net() sees an empty xarray
and proceeds to uevent_net_exit(), which frees ue_sk. The IB
worker can then continue through device_del() and read the freed
ue_sk through net->uevent_sock.
cleanup_net() calls ops_undo_list() (where uevent_net_exit() frees
ue_sk) and only later rcu_barrier()/net_complete_free()
(net/core/net_namespace.c:220-258, 666-729). struct net itself is
deferred, so KASAN reports the freed kmalloc-32 ue_sk, not net.
* Reproducer (attached: uevent_uaf_repro.c) and results on 7.3
The attached file is based on the syzbot C reproducer and adds a
no-rxe control mode plus a duration argument. It uses only
userspace netlink/ioctl calls; no kernel module or source change is
required.
# with dummy and rdma_rxe available, as root
gcc -O2 -pthread uevent_uaf_repro.c -o uevent_uaf_repro
./uevent_uaf_repro 60
./uevent_uaf_repro no-rxe 30 # negative control
Results (one QEMU/KVM guest, 8 vCPU):
- vulnerable 7.3, dummy + rxe, 60 s: 125 iterations, 4 KASAN
slab-use-after-free reports;
- vulnerable 7.3, dummy + netns churn without rxe, 30 s:
191 iterations, 0 KASAN reports;
- v4-patched kernel, same dummy + rxe trigger, 60 s: 0 reports.
The first positive report is:
BUG: KASAN: slab-use-after-free in kobject_uevent_env+0x64b/0x710
Read of size 8 ...
Workqueue: ib-unreg-wq ib_unregister_work
kobject_uevent_env
device_del
disable_device
__ib_unregister_device
ib_unregister_work
Allocated by: uevent_net_init
Freed by: ops_undo_list -> cleanup_net
cache: kmalloc-32
The race is probabilistic: an earlier 60 s run of the same reproducer
produced no report, and the successful run had kprobes installed on the path
(the probe overhead can widen the window). The v4 run used the same
kprobe configuration and stayed clean; the no-rxe control also
stayed clean. The syzbot report is independent of this local run.
* Suggested direction
Serialize the tagged broadcast with uevent_net_exit() using the
existing uevent_sock_mutex, and clear net->uevent_sock before
dropping the lock:
- in kobject_uevent_net_broadcast(), take uevent_sock_mutex,
read ue_sk = net->uevent_sock, and check ue_sk && ue_sk->sk
before calling uevent_net_broadcast_tagged();
- in uevent_net_exit(), take the same mutex, set
net->uevent_sock = NULL while removing ue_sk from the list, then
release the socket and free ue_sk outside the lock.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread* driver core: uevent: netns teardown frees uevent_sock while a tagged uevent is still being broadcast (UAF)
2026-10-27 14:12 ` Greg KH
` (12 preceding siblings ...)
2026-11-10 12:56 ` driver core: uevent: netns teardown frees uevent_sock while a tagged uevent is still being broadcast (UAF) 徐晗翔
@ 2026-11-10 13:00 ` 徐晗翔
2026-11-10 13:05 ` ACPI: CPPC: per-CPU cpc_desc is freed on processor unbind without clearing the published pointer, causing UAF in cpufreq accessors 徐晗翔
` (7 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:00 UTC (permalink / raw)
To: dakr, rafael; +Cc: driver-core, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 6706 bytes --]
net->uevent_sock is published as a bare pointer by uevent_net_init()
and freed by uevent_net_exit(), but the tagged broadcast path
dereferences net->uevent_sock->sk without holding uevent_sock_mutex or
taking a socket reference. uevent_net_exit() drops the mutex before
releasing the socket and never clears net->uevent_sock, so a kobject
whose namespace callback returns an unpinned net pointer can emit a
tagged uevent after the per-net exit has freed the socket; KASAN
reports a slab-use-after-free read in kobject_uevent_net_broadcast()
(inlined into kobject_uevent_env in the report below).
An IB compat device supplies a concrete path. Its rdma_net is a raw
possible_net_t, and its asynchronous unregister can still be inside
device_del() after the net namespace has run uevent_net_exit(). This
was reported by syzbot (KASAN: slab-use-after-free Read in
disable_device, extid 4393dfdddf166f2de2b0,
https://syzkaller.appspot.com/bug?extid=4393dfdddf166f2de2b0) and I
reproduced it locally on 7.3 with a KASAN kernel; the same reproducer
is clean with a no-rxe control and with the v4 locking change described
below.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (vanilla mainline tarball), x86_64, QEMU/KVM guest,
8 vCPU / 4 GB RAM, KASAN generic. The tested
lib/kobject_uevent.c is byte-identical to the audited 7.3 tree
(sha256 bbcf120ea86f42c74dec10e255a8cd0b3034c9f34cd5bb911fd94015729dd076).
Kernel config included CONFIG_NET_NS=y, CONFIG_INFINIBAND=y,
CONFIG_INFINIBAND_USER_ACCESS=y, CONFIG_RDMA_RXE=y, CONFIG_DUMMY=y
and CONFIG_KASAN=y. The kernel source was not modified to inject
the race; only the IB/RXE configuration was enabled and a userspace
reproducer was added.
* Mechanism
Three sides do not share a lifetime protocol:
- Publish side: uevent_net_init() allocates ue_sk and stores it
with net->uevent_sock = ue_sk (lib/kobject_uevent.c:773-803).
The field is a plain pointer in struct net, with no refcount,
RCU head, or socket reference (include/net/net_namespace.h:113).
- Free side: uevent_net_exit() reads the pointer, removes ue_sk
from the global uevent_sock_list under uevent_sock_mutex only
when it belongs to the initial user namespace, then unlocks and
calls netlink_kernel_release(ue_sk->sk) and kfree(ue_sk)
outside the mutex (lib/kobject_uevent.c:805-817). It never
writes net->uevent_sock = NULL.
- Read side: kobject_uevent_net_broadcast() obtains the target
net from the kobject namespace callback and passes
net->uevent_sock->sk directly to
uevent_net_broadcast_tagged() (lib/kobject_uevent.c:382-421;
the tagged dereference is at line 415). This path takes no
uevent_sock_mutex, no socket reference, and performs no NULL
check.
The IB trigger makes the window concrete:
- ib_class.namespace is net_namespace(), which only does
read_pnet(&coredev->rdma_net); rdma_init_coredev() stored the
net with write_pnet() without get_net()
(drivers/infiniband/core/device.c:536-550, 553-580).
- remove_one_compat_dev() erases the compat device from the
xarray under compat_devs_mutex, releases the mutex, and then
calls ib_free_port_attrs() and device_del()
(drivers/infiniband/core/device.c:1027-1039). device_del()
emits KOBJ_REMOVE at drivers/base/core.c:3983.
- device_del() has several sleeping or blocking points between
the xa_erase() and KOBJ_REMOVE (device_lock(), bus_notify(),
sysfs removal, class callbacks, devres_release_all()), so the
window is not merely theoretical.
On netns cleanup, rdma_dev_net_ops is registered with
register_pernet_device() (drivers/infiniband/core/device.c:3274),
while uevent_net_ops is registered with register_pernet_subsys()
(lib/kobject_uevent.c:826). Because the pernet list is walked in
reverse on exit, rdma_dev_exit_net() runs before
uevent_net_exit(). If the async IB path wins the xa_erase() in
remove_one_compat_dev(), rdma_dev_exit_net() sees an empty xarray
and proceeds to uevent_net_exit(), which frees ue_sk. The IB
worker can then continue through device_del() and read the freed
ue_sk through net->uevent_sock.
cleanup_net() calls ops_undo_list() (where uevent_net_exit() frees
ue_sk) and only later rcu_barrier()/net_complete_free()
(net/core/net_namespace.c:220-258, 666-729). struct net itself is
deferred, so KASAN reports the freed kmalloc-32 ue_sk, not net.
* Reproducer (attached: uevent_uaf_repro.c) and results on 7.3
The attached file is based on the syzbot C reproducer and adds a
no-rxe control mode plus a duration argument. It uses only
userspace netlink/ioctl calls; no kernel module or source change is
required.
# with dummy and rdma_rxe available, as root
gcc -O2 -pthread uevent_uaf_repro.c -o uevent_uaf_repro
./uevent_uaf_repro 60
./uevent_uaf_repro no-rxe 30 # negative control
Results (one QEMU/KVM guest, 8 vCPU):
- vulnerable 7.3, dummy + rxe, 60 s: 125 iterations, 4 KASAN
slab-use-after-free reports;
- vulnerable 7.3, dummy + netns churn without rxe, 30 s:
191 iterations, 0 KASAN reports;
- v4-patched kernel, same dummy + rxe trigger, 60 s: 0 reports.
The first positive report is:
BUG: KASAN: slab-use-after-free in kobject_uevent_env+0x64b/0x710
Read of size 8 ...
Workqueue: ib-unreg-wq ib_unregister_work
kobject_uevent_env
device_del
disable_device
__ib_unregister_device
ib_unregister_work
Allocated by: uevent_net_init
Freed by: ops_undo_list -> cleanup_net
cache: kmalloc-32
The race is probabilistic: an earlier 60 s run of the same reproducer
produced no report, and the successful run had kprobes installed on the path
(the probe overhead can widen the window). The v4 run used the same
kprobe configuration and stayed clean; the no-rxe control also
stayed clean. The syzbot report is independent of this local run.
* Suggested direction
Serialize the tagged broadcast with uevent_net_exit() using the
existing uevent_sock_mutex, and clear net->uevent_sock before
dropping the lock:
- in kobject_uevent_net_broadcast(), take uevent_sock_mutex,
read ue_sk = net->uevent_sock, and check ue_sk && ue_sk->sk
before calling uevent_net_broadcast_tagged();
- in uevent_net_exit(), take the same mutex, set
net->uevent_sock = NULL while removing ue_sk from the list, then
release the socket and free ue_sk outside the lock.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: uevent_uaf_repro.c --]
[-- Type: text/plain, Size: 13479 bytes --]
// https://syzkaller.appspot.com/bug?id=62db7f0b628381f621ab352d501352f0cb359ba1
// Copyright 2026 syzkaller project authors. All rights reserved.
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
// IMPORTANT: Do not copy the macros or definitions below directly into your reproducer.
// Instead, add the following line to your reproducer:
// #include "race_toolkit.h"
// --- Race Condition Toolkit ---
// Macros and snippets for CPU pinning, memory barriers, and userfaultfd.
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/futex.h>
#include <linux/userfaultfd.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
// Unbuffered I/O: Ensure logs are written immediately.
#define SETUP_UNBUFFERED_IO() setvbuf(stdout, NULL, _IONBF, 0)
// CPU Pinning: Pin the current thread to a specific CPU core.
#define PIN_TO_CPU(cpu) \
do { \
cpu_set_t mask; \
CPU_ZERO(&mask); \
CPU_SET(cpu, &mask); \
if (sched_setaffinity(0, sizeof(mask), &mask) == -1) { \
perror("sched_setaffinity"); \
} \
} while (0)
// Memory Barrier: Ensure memory ordering.
#define MB() __atomic_thread_fence(__ATOMIC_SEQ_CST)
// Spin-wait Barrier: Wait until a memory location has a specific value.
// Best for tight race windows (low latency, no context switches).
#define WAIT_ON(addr, val) \
do { \
while (__atomic_load_n(addr, __ATOMIC_ACQUIRE) != (val)) \
; \
} while (0)
// Signal: Set a memory location to a specific value to release a WAIT_ON.
#define SIGNAL(addr, val) __atomic_store_n(addr, val, __ATOMIC_RELEASE)
// --- Timing Primitives ---
// Robust timing loops in VM environments (using CLOCK_MONOTONIC to avoid time(NULL) jumps).
static inline double timer_elapsed_sec(struct timespec* start)
{
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
perror("clock_gettime(CLOCK_MONOTONIC) elapsed");
exit(1);
}
return (double)(now.tv_sec - start->tv_sec) + (double)(now.tv_nsec - start->tv_nsec) / 1e9;
}
// Initialize a monotonic timer variable.
#define TIMER_START(t) \
struct timespec t; \
if (clock_gettime(CLOCK_MONOTONIC, &t) == -1) { \
perror("clock_gettime(CLOCK_MONOTONIC) start"); \
exit(1); \
}
// Check if the elapsed time since 't' is less than 'sec' seconds.
#define TIMER_NOT_EXPIRED(t, sec) (timer_elapsed_sec(&(t)) < (double)(sec))
// Futex-based Event: Shared with syzkaller executor.
// Best for general synchronization or longer waits to save CPU.
typedef struct {
int state;
} event_t;
static void event_init(event_t* ev)
{
ev->state = 0;
}
static void event_reset(event_t* ev)
{
ev->state = 0;
}
static void event_set(event_t* ev)
{
if (__atomic_load_n(&ev->state, __ATOMIC_ACQUIRE)) {
fprintf(stderr, "event already set\n");
exit(1);
}
__atomic_store_n(&ev->state, 1, __ATOMIC_RELEASE);
syscall(SYS_futex, &ev->state, FUTEX_WAKE | FUTEX_PRIVATE_FLAG, 1000000);
}
static void event_wait(event_t* ev)
{
while (!__atomic_load_n(&ev->state, __ATOMIC_ACQUIRE))
syscall(SYS_futex, &ev->state, FUTEX_WAIT | FUTEX_PRIVATE_FLAG, 0, 0);
}
// userfaultfd setup: Register a memory range for page fault handling.
static int setup_uffd(void* addr, size_t len)
{
int uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
return -1;
struct uffdio_api api = {.api = UFFD_API, .features = 0};
if (ioctl(uffd, UFFDIO_API, &api) == -1) {
close(uffd);
return -1;
}
struct uffdio_register reg = {
.range = {.start = (uintptr_t)addr, .len = len},
.mode = UFFDIO_REGISTER_MODE_MISSING};
if (ioctl(uffd, UFFDIO_REGISTER, ®) == -1) {
close(uffd);
return -1;
}
return uffd;
}
// --- Guidance on Usage ---
// 1. Use WAIT_ON/SIGNAL for tight race conditions to avoid scheduling overhead.
// 2. Use event_t (futexes) for general coordination or when waiting for longer periods.
// 3. Always use PIN_TO_CPU to increase race probability on multi-core systems.
// 4. Use setup_uffd to register a memory range for page fault handling. This allows you to
// pause a thread accessing that memory until you handle the fault, creating a reliable
// and controllable race window.
// 5. Call SETUP_UNBUFFERED_IO() at the start of main() to ensure that logs are printed
// immediately. This is essential for understanding the exact interleaving of events
// when debugging race conditions.
// 6. For timing-based loops (e.g., running a race for 10 seconds), do NOT use time(NULL)
// or loops relying on real-time clocks, as VM clocks are highly unreliable and can fail or drift.
// Instead, use the robust monotonic timing primitives TIMER_START and TIMER_NOT_EXPIRED:
// TIMER_START(start);
// while (TIMER_NOT_EXPIRED(start, 10.0)) {
// // Your race logic here
// }
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <sched.h>
#include <sys/wait.h>
#include <sys/ioctl.h>
#include <net/if.h>
#include <signal.h>
#include <errno.h>
#include <time.h>
#define NETLINK_RDMA 20
#define RDMA_NL_NLDEV 5
#define RDMA_NLDEV_CMD_NEWLINK 3
#define RDMA_NLDEV_ATTR_DEV_NAME 2
#define RDMA_NLDEV_ATTR_NDEV_NAME 51
#define RDMA_NLDEV_ATTR_LINK_TYPE 65
static int create_dummy_interface(const char *name) {
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (fd < 0) return -1;
struct {
struct nlmsghdr nlh;
struct ifinfomsg ifm;
char buf[1024];
} req = {0};
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK;
req.nlh.nlmsg_type = RTM_NEWLINK;
req.nlh.nlmsg_seq = 1;
req.ifm.ifi_family = AF_UNSPEC;
struct rtattr *rta;
rta = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
rta->rta_type = IFLA_IFNAME;
rta->rta_len = RTA_LENGTH(strlen(name) + 1);
strcpy(RTA_DATA(rta), name);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(rta->rta_len);
struct rtattr *linkinfo = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
linkinfo->rta_type = IFLA_LINKINFO;
linkinfo->rta_len = RTA_LENGTH(0);
rta = (struct rtattr *)(((char *)linkinfo) + RTA_ALIGN(linkinfo->rta_len));
rta->rta_type = IFLA_INFO_KIND;
rta->rta_len = RTA_LENGTH(strlen("dummy") + 1);
strcpy(RTA_DATA(rta), "dummy");
linkinfo->rta_len += RTA_ALIGN(rta->rta_len);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(linkinfo->rta_len);
struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
sendto(fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa));
struct {
struct nlmsghdr nlh;
struct nlmsgerr err;
} resp;
recv(fd, &resp, sizeof(resp), 0);
close(fd);
return resp.err.error;
}
static int delete_interface(const char *name) {
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (fd < 0) return -1;
struct {
struct nlmsghdr nlh;
struct ifinfomsg ifm;
char buf[1024];
} req = {0};
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
req.nlh.nlmsg_type = RTM_DELLINK;
req.nlh.nlmsg_seq = 1;
req.ifm.ifi_family = AF_UNSPEC;
struct rtattr *rta;
rta = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
rta->rta_type = IFLA_IFNAME;
rta->rta_len = RTA_LENGTH(strlen(name) + 1);
strcpy(RTA_DATA(rta), name);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(rta->rta_len);
struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
sendto(fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa));
struct {
struct nlmsghdr nlh;
struct nlmsgerr err;
} resp;
recv(fd, &resp, sizeof(resp), 0);
close(fd);
return resp.err.error;
}
static void bring_up_interface(const char *name) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) return;
struct ifreq ifr;
strcpy(ifr.ifr_name, name);
if (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0) {
ifr.ifr_flags |= IFF_UP;
ioctl(fd, SIOCSIFFLAGS, &ifr);
}
close(fd);
}
static int send_rdma_newlink(const char *ibdev_name, const char *link_type, const char *ndev_name) {
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_RDMA);
if (fd < 0) return -1;
struct {
struct nlmsghdr nlh;
char buf[1024];
} req = {0};
req.nlh.nlmsg_len = NLMSG_LENGTH(0);
req.nlh.nlmsg_type = (RDMA_NL_NLDEV << 10) | RDMA_NLDEV_CMD_NEWLINK;
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
req.nlh.nlmsg_seq = 1;
struct rtattr *rta;
rta = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
rta->rta_type = RDMA_NLDEV_ATTR_DEV_NAME;
rta->rta_len = RTA_LENGTH(strlen(ibdev_name) + 1);
strcpy(RTA_DATA(rta), ibdev_name);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(rta->rta_len);
rta = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
rta->rta_type = RDMA_NLDEV_ATTR_LINK_TYPE;
rta->rta_len = RTA_LENGTH(strlen(link_type) + 1);
strcpy(RTA_DATA(rta), link_type);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(rta->rta_len);
rta = (struct rtattr *)(((char *)&req) + NLMSG_ALIGN(req.nlh.nlmsg_len));
rta->rta_type = RDMA_NLDEV_ATTR_NDEV_NAME;
rta->rta_len = RTA_LENGTH(strlen(ndev_name) + 1);
strcpy(RTA_DATA(rta), ndev_name);
req.nlh.nlmsg_len = NLMSG_ALIGN(req.nlh.nlmsg_len) + RTA_ALIGN(rta->rta_len);
struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
sendto(fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa));
struct {
struct nlmsghdr nlh;
struct nlmsgerr err;
} resp;
recv(fd, &resp, sizeof(resp), 0);
close(fd);
return resp.err.error;
}
int main(int argc, char **argv) {
/* no-rxe: negative control; keep the same dummy/netns churn without IB.
* Optional numeric arg: run duration in seconds (default 10).
*/
int use_rxe = 1;
double secs = 10.0;
for (int ai = 1; ai < argc; ai++) {
if (strcmp(argv[ai], "no-rxe") == 0) {
use_rxe = 0;
} else {
double v = atof(argv[ai]);
if (v > 0)
secs = v;
}
}
SETUP_UNBUFFERED_IO();
TIMER_START(start);
srand(time(NULL) ^ getpid());
printf("[*] uevent_uaf_repro: use_rxe=%d secs=%.0f pid=%d\n", use_rxe, secs, getpid());
int iter = 0;
while (TIMER_NOT_EXPIRED(start, secs)) {
char dummy_name[32];
char rxe_name[32];
sprintf(dummy_name, "d%d_%d", getpid(), iter);
sprintf(rxe_name, "r%d_%d", getpid(), iter);
int res = create_dummy_interface(dummy_name);
if (res < 0 && res != -EEXIST) {
printf("[-] Failed to create dummy interface: %d\n", res);
exit(1);
}
bring_up_interface(dummy_name);
if (use_rxe) {
res = send_rdma_newlink(rxe_name, "rxe", dummy_name);
if (res < 0) {
printf("[-] Failed to create RXE device: %d\n", res);
}
}
int p[2];
if (pipe(p) < 0) {
exit(1);
}
pid_t pid = fork();
if (pid == 0) {
close(p[0]);
if (unshare(CLONE_NEWNET) < 0) {
printf("[-] unshare(CLONE_NEWNET) failed: %d\n", errno);
exit(1);
}
// Signal parent that we are in new netns
if (write(p[1], "1", 1) != 1) {}
// Wait for parent to signal us to exit
char c;
if (read(p[1], &c, 1) != 1) {}
// Exit, triggering cleanup_net
exit(0);
}
close(p[1]);
char c;
// Wait for child to unshare
if (read(p[0], &c, 1) != 1) {}
close(p[0]);
// Signal child to exit
kill(pid, SIGKILL);
// Random delay between 0 and 50ms to hit the race window
// cleanup_net blocks on synchronize_rcu() which takes some time
usleep(rand() % 50000);
// Immediately delete the dummy interface to trigger ib_unregister_work
delete_interface(dummy_name);
waitpid(pid, NULL, 0);
// Wait a bit for workqueues to finish
usleep(50000);
iter++;
}
printf("[+] Reproducer finished after %d iterations.\n", iter);
return 0;
}
^ permalink raw reply [flat|nested] 32+ messages in thread* ACPI: CPPC: per-CPU cpc_desc is freed on processor unbind without clearing the published pointer, causing UAF in cpufreq accessors
2026-10-27 14:12 ` Greg KH
` (13 preceding siblings ...)
2026-11-10 13:00 ` 徐晗翔
@ 2026-11-10 13:05 ` 徐晗翔
2026-11-10 13:07 ` ACPI: CPPC: shared PCC subspace data can be freed while another CPU probes, reads, or tears it down 徐晗翔
` (6 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:05 UTC (permalink / raw)
To: rafael, lenb; +Cc: linux-acpi, viresh.kumar, linux-pm, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 7640 bytes --]
acpi_processor_stop() can be reached through the processor driver's
sysfs unbind attribute while the target CPU is still online. It calls
acpi_cppc_processor_exit(), which iounmap()s the _CPC system-memory
registers and kfree()s the per-CPU struct cpc_desc, but never clears
per_cpu(cpc_desc_ptr, pr->id). The cpufreq accessors (and the
acpi_cppc sysfs attributes) read that per-CPU pointer without a common
lock, refcount, or RCU grace period, so after the unbind they can
continue to use the freed descriptor. A KASAN kernel reproduced the
slab-use-after-free with a real "echo cpu0 > .../processor/unbind",
not with a synthetic direct call to the exit function.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (mainline 7.3 source, unmodified), KASAN build from
verification/build-kasan-7.3, x86_64 QEMU/KVM guest, 2 vCPU,
CONFIG_KASAN=y, CONFIG_BUG=y, CONFIG_HOTPLUG_SMT=y. The guest used a
test SSDT (cpcint.aml) that adds a 21-entry, all-integer _CPC package
to \_SB.CPUS.C000/C001, so the normal acpi_cppc_processor_probe()
created a real cpc_desc and a real acpi_cppc sysfs directory.
The test kernel has CONFIG_ACPI_CPPC_LIB=y and
CONFIG_ACPI_PROCESSOR=y, but CONFIG_ACPI_CPPC_CPUFREQ is not enabled.
The concurrent accessor in the reproducer is therefore a module
calling the exported cppc_get_perf_caps(0), not a real
cppc_cpufreq tick. The release side is the real driver-core
unbind path; the accessor function and the released object are the
same as in the cpufreq/sysfs paths described below. Line numbers
are from Linux 7.3.
* Mechanism
The ACPI processor driver is bound to the cpu_subsys CPU device and
has .remove = acpi_processor_stop (drivers/acpi/processor_driver.c:
45-50). Writing a CPU name to
/sys/bus/cpu/drivers/processor/unbind runs
unbind_store() -> device_release_driver() -> acpi_processor_stop()
(drivers/base/bus.c:235-251, drivers/base/dd.c:1318-1375).
__device_release_driver() takes only the CPU device's device_lock;
it does not take a CPU-hotplug lock and does not require the CPU to
be offline. acpi_processor_stop() has no online check either and
calls acpi_cppc_processor_exit(pr) at processor_driver.c:204.
acpi_cppc_processor_exit() (drivers/acpi/cppc_acpi.c:1037-1068)
performs, in this order:
pcc_data refcount/free
-> iounmap(cpc_ptr->cpc_regs[i].sys_mem_vaddr)
-> kobject_put(&cpc_ptr->kobj)
-> kfree(cpc_ptr)
There is no corresponding
per_cpu(cpc_desc_ptr, pr->id) = NULL. The pointer was published by
acpi_cppc_processor_probe() at cppc_acpi.c:1002. cppc_ktype has no
.release callback (cppc_acpi.c:300-303), so kobject_put() only
removes the sysfs directory and drains its active references; it is
not a lifetime barrier for the descriptor.
The accessors take the pointer outside any lock:
cppc_get_reg_val(): cpc_desc = per_cpu(cpc_desc_ptr, cpu);
if (!cpc_desc) return -ENODEV;
reg = &cpc_desc->cpc_regs[reg_idx];
cppc_get_perf_caps(): same pattern (cppc_acpi.c:1488 onwards)
cppc_get_perf_ctrs(): same pattern (cppc_acpi.c:1678 onwards)
The NULL check does not help: after exit the per-CPU slot is still
non-NULL and points to freed memory.
The cpufreq policy is owned by the cpufreq core, not by the ACPI
processor driver. acpi_processor_stop() neither offlines the CPU nor
tears down the policy, so a registered CPPC frequency-invariance
source can keep calling cppc_get_perf_ctrs() after unbind
(drivers/cpufreq/cppc_cpufreq.c:69-104, 150-184). The
cpuinfo_cur_freq sysfs attribute reaches the driver's .get callback
through __cpufreq_get() (drivers/cpufreq/cpufreq.c:755-764), and the
acpi_cppc attributes call cppc_get_perf_caps() from show_cppc_data()
(cppc_acpi.c:160-186).
There is also a mapping-lifetime sub-case. For _CPC registers in
ACPI_ADR_SPACE_SYSTEM_MEMORY, cpc_read()/cpc_write() use
reg_res->sys_mem_vaddr directly (cppc_acpi.c:1162-1180 and
1235-1250), while exit iounmap()s those addresses before
kobject_put(). A sysfs show that is still running can therefore
reach readb/readw/readl/readq_relaxed() after the mapping was
revoked; an EPP store can do the same through cpc_write(). The
kernfs drain inside kobject_put() protects the later kfree(cpc_ptr),
but it starts only after iounmap(), so it does not protect the
mapping. This sub-case is source-level only in this report; the
tested guest had an all-integer _CPC and did not establish a
system-memory _CPC successfully.
A separate SMT path is worth fixing even though it was not the
reproduced one: cpuhp_smt_enable() calls _cpu_up(cpu, 0,
CPUHP_ONLINE) without taking the CPU device's device_lock
(kernel/cpu.c:2702-2725, called from the smt/control store at
2920-2940), while unbind_store() takes only that device_lock. For a
never-online SMT sibling, the first acpi_soft_cpu_online() probe can
therefore overlap the same CPU's unbind and hit the 1002-1004 window
(publish cpc_desc, then kobject_init_and_add()).
* Reproducer (attached: acpi_case1unbind.c) and results on 7.3
The attached module starts a kthread that repeatedly calls the
exported cppc_get_perf_caps(0, &caps), then the guest performs the
real unbind:
insmod /acpi_case1unbind.ko
sleep 1
echo cpu0 > /sys/bus/cpu/drivers/processor/unbind
It does not modify kernel source. It needs the test SSDT that
provides the _CPC package, and the module build uses the same
build-kasan-7.3 tree.
Actual KASAN output (trimmed):
CASE1UNBIND: unbinding cpu0
BUG: KASAN: slab-use-after-free in cppc_get_perf_caps+0x14a/0x880
Read of size 4 at addr ff110000019a6018 by task cppc_hammer_unb/73
Call Trace:
cppc_get_perf_caps+0x14a/0x880
hammer_fn+0xf5/0x110 [acpi_case1unbind]
kthread
...
Allocated by task 20:
acpi_cppc_processor_probe+0x174/0xe60
acpi_soft_cpu_online+0x90/0x190
...
Freed by task 1:
kfree+0x121/0x390
acpi_processor_stop+0x77/0x90
device_release_driver_internal+0x246/0x2e0
unbind_store+0xa9/0xb0
kernfs_fop_write_iter+0x1ef/0x290
...
CASE1UNBIND: unbind returned 0
The allocation stack is the normal probe path, and the free stack is
the real sysfs unbind path. This confirms that the remove callback
ran while CPU0 was online and that the published per-CPU pointer
survived the free. The module is an accessor stand-in: it does not
prove the scheduler-tick timing or the system-memory iounmap
sub-case, and the test kernel did not have cppc_cpufreq enabled.
The core "exit frees, published pointer remains, accessor reads"
lifetime is nevertheless demonstrated end to end.
* Suggested direction
Clear per_cpu(cpc_desc_ptr, pr->id) before releasing the object, and
give accessors a real lifetime rule: an RCU/refcount-protected
lookup, or a quiescence point that stops the cpufreq policy/FIE and
waits for in-flight accessors before iounmap()/kfree(). The remove
path should also refuse to run while the CPU is online (or offline
and stop cpufreq first). For the SMT path, make the first-online
probe and driver remove share a lock, or exclude remove during the
probe window. If sysfs mappings remain reachable during removal,
move iounmap() after the kernfs drain or protect the vaddr with the
same lifetime mechanism. Report-first, no patch attached; happy to
test any proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: acpi_case1unbind.c --]
[-- Type: text/plain, Size: 968 bytes --]
// SPDX-License-Identifier: GPL-2.0
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/acpi.h>
#include <acpi/cppc_acpi.h>
static struct task_struct *hammer;
static int hammer_fn(void *data)
{
struct cppc_perf_caps caps;
unsigned long n = 0;
while (!kthread_should_stop()) {
memset(&caps, 0, sizeof(caps));
cppc_get_perf_caps(0, &caps);
n++;
if (!(n & 0x3ff))
cond_resched();
}
pr_info("ACPI_CASE1UNBIND: hammer iterations=%lu\n", n);
return 0;
}
static int __init case1unbind_init(void)
{
hammer = kthread_run(hammer_fn, NULL, "cppc_hammer_unbind");
if (IS_ERR(hammer)) {
pr_err("ACPI_CASE1UNBIND: kthread_run failed\n");
return PTR_ERR(hammer);
}
pr_info("ACPI_CASE1UNBIND: hammer started; unbind cpu0 now\n");
return 0;
}
static void __exit case1unbind_exit(void)
{
if (hammer)
kthread_stop(hammer);
}
module_init(case1unbind_init);
module_exit(case1unbind_exit);
MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 32+ messages in thread* ACPI: CPPC: shared PCC subspace data can be freed while another CPU probes, reads, or tears it down
2026-10-27 14:12 ` Greg KH
` (14 preceding siblings ...)
2026-11-10 13:05 ` ACPI: CPPC: per-CPU cpc_desc is freed on processor unbind without clearing the published pointer, causing UAF in cpufreq accessors 徐晗翔
@ 2026-11-10 13:07 ` 徐晗翔
2026-11-10 13:11 ` ACPI: ACPICA: Reset can delete an event semaphore while another thread is blocked in Wait 徐晗翔
` (5 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:07 UTC (permalink / raw)
To: rafael, sudeep.holla, jassisinghbrar
Cc: linux-acpi, viresh.kumar, linux-pm, linux-kernel, lenb
pcc_data[] is a global array of shared PCC-subspace objects. The
reference count is an ordinary int updated without a lock, and
acpi_cppc_processor_exit() can kfree() the object and set the slot to
NULL while another CPU's accessor is using it, while another CPU's
first-online probe is allocating/registering it, or while another
remove is decrementing the same count. The result is a UAF, a NULL
dereference, a lost refcount update/leak, and a PCC channel/shmem
lifetime problem. This report is source-level: the test environment
does not have a PCC/PCCT controller, so no dynamic reproducer is
attached. I am not claiming the double-free variant.
The detailed description:
* Test environment / affected version
Linux 7.3.0 source (verification/linux-7.3), line numbers from 7.3.
The available QEMU/KASAN guest used for the sibling CPPC report has
no PCCT table and no PCC mailbox controller, so it cannot create a
real pcc_data object. I did not fake a pcc_data object or call the
teardown path with a synthetic pointer; that would not be a valid
dynamic reproduction. The affected code is the normal
CONFIG_ACPI_CPPC_LIB path in drivers/acpi/cppc_acpi.c plus the PCC
mailbox release in drivers/mailbox/pcc.c.
* Mechanism
A PCC subspace can be shared by multiple CPUs. pcc_data[pcc_ss_id]
is the global slot, and its refcount is the number of participating
CPUs (cppc_acpi.c:48-83).
Allocation (cppc_acpi.c:714-729) is:
if (pcc_data[pcc_ss_id])
pcc_data[pcc_ss_id]->refcount++;
else {
pcc_data[pcc_ss_id] = kzalloc(...);
...
pcc_data[pcc_ss_id]->refcount++;
}
There is no lock, and the slot check, allocation, publication and
increment are not atomic.
Teardown (cppc_acpi.c:1037-1051) is:
if (pcc_ss_id >= 0 && pcc_data[pcc_ss_id]) {
if (pcc_data[pcc_ss_id]->pcc_channel_acquired) {
pcc_data[pcc_ss_id]->refcount--;
if (!pcc_data[pcc_ss_id]->refcount) {
pcc_mbox_free_channel(...);
kfree(pcc_data[pcc_ss_id]);
pcc_data[pcc_ss_id] = NULL;
}
}
}
This is a plain read-modify-write, followed by a separate read for
the zero test, with no pcc_lock and no teardown mutex. The release
happens before kobject_put(&cpc_ptr->kobj), so the kernfs drain that
protects the cpc_desc sysfs object does not protect pcc_data at all.
Accessors take the global slot and immediately use it:
cppc_get_reg_val_in_pcc(): pcc_ss_data = pcc_data[pcc_ss_id];
down_write(&pcc_ss_data->pcc_lock);
cppc_set_reg_val_in_pcc(): cpc_write(...); then the same lookup
cppc_get_perf_caps(): same pattern (cppc_acpi.c:1488 onwards)
cppc_get_perf_ctrs(): same pattern (cppc_acpi.c:1678 onwards)
GET_PCC_VADDR(): pcc_data[id]->pcc_channel->shmem directly
None of these check for NULL, and none hold a reference that would
keep pcc_data alive across the use.
Four independent interleavings follow:
A. exit vs accessor. The last CPU is unbound while its tick,
cpuinfo_cur_freq, or an already-running acpi_cppc sysfs show is
in a PCC accessor. exit frees pcc_data and iounmap()s the
channel shmem; the accessor then calls down_write() on freed
memory or dereferences NULL. This is the simplest UAF and does
not require two CPUs to be removed at once.
B. remove vs remove. Two CPUs sharing the subspace both read
refcount == 2, both write 1, and neither frees: the object and
channel leak. If one remove has already freed and cleared the
slot while the other has a stale non-NULL value, the second
remove can dereference NULL or freed memory. The exact
double-free requires both removals to observe zero, which needs
an under-count or another remove path; I am not claiming it.
C. first-online probe vs remove. CPU B is doing its first
physical online and runs acpi_cppc_processor_probe(); CPU A is
being unbound and runs acpi_cppc_processor_exit(). They share
the subspace but hold different CPU device locks. The probe's
pcc_data_alloc() can read the slot just before exit frees it,
then increment freed memory; or it can enter register_pcc_channel(),
sleep in pcc_mbox_request_channel(), and then write the channel
pointer into a slot that exit has already freed and cleared.
acpi_cppc_processor_probe() also dereferences
pcc_data[pcc_subspace_id]->pcc_channel_acquired at
cppc_acpi.c:981 without a NULL check.
D. channel release vs in-flight send. pcc_mbox_free_channel()
(drivers/mailbox/pcc.c:431-448) only iounmap()s the shmem and
calls mbox_free_channel(); it does not wait for an in-flight
mbox_send_message()/txdone or for send_pcc_cmd() to finish.
An accessor already in send_pcc_cmd() can therefore use the
revoked mapping or the freed channel.
The probe failure path is a separate, non-race lifetime defect:
acpi_cppc_processor_probe()'s out_free path iounmap()s and
kfree()s cpc_ptr but does not decrement the pcc_data refcount that
pcc_data_alloc() just took (cppc_acpi.c:1015-1023). It can leak the
channel/object, but it is not the concurrent UAF above.
* Reproducer and results
No dynamic reproducer is attached. The current QEMU test
environment has no PCCT/PCC mailbox controller, so a real pcc_data
cannot be created; I did not substitute a fake object. The evidence
for this report is the source-level lifetime above, with the exact
line references in the mechanism section.
Static evidence summary:
- cppc_acpi.c:714-729: plain refcount++ in pcc_data_alloc()
- cppc_acpi.c:1037-1051: plain refcount--, zero test, free, NULL,
all without pcc_lock
- cppc_acpi.c:981-989: probe reads pcc_data[...] without a NULL
check and calls register_pcc_channel()
- cppc_acpi.c:644-675: register_pcc_channel() writes the channel
after a sleeping pcc_mbox_request_channel()
- cppc_acpi.c:1313-1336, 1366-1389, 1488+, 1678+: accessors use
pcc_data[] without a reference or NULL check
- cppc_acpi.c:97-98: GET_PCC_VADDR() dereferences the slot directly
- drivers/mailbox/pcc.c:431-448: free does not wait for in-flight
sends
* Suggested direction
Make pcc_data refcounted with refcount_t and update it under a
dedicated teardown mutex (or pcc_lock), with the slot lookup and
increment done atomically. Accessors should acquire a reference (or
use RCU) before dropping the lock, and the free path should clear the
slot first, then wait for readers/in-flight sends before
pcc_mbox_free_channel(). Free pcc_data after the kernfs drain, or
make the sysfs show hold a pcc_data reference for its whole call.
The probe error path should roll back the pcc_data_alloc() increment.
Report-first, no patch attached; I can test a proposal on PCC
hardware or a suitable QEMU model if one is available.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread* ACPI: ACPICA: Reset can delete an event semaphore while another thread is blocked in Wait
2026-10-27 14:12 ` Greg KH
` (15 preceding siblings ...)
2026-11-10 13:07 ` ACPI: CPPC: shared PCC subspace data can be freed while another CPU probes, reads, or tears it down 徐晗翔
@ 2026-11-10 13:11 ` 徐晗翔
2026-11-10 13:14 ` power: supply: bq25890: pump-express work can call power_supply_changed after the psy is unregistered 徐晗翔
` (4 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:11 UTC (permalink / raw)
To: rafael, maciej.wieczor-retman, pawel.chmielewski
Cc: linux-acpi, lenb, acpica-devel, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 5981 bytes --]
acpi_ex_system_reset_event() creates a new semaphore, deletes the old
one, and replaces obj_desc->event.os_semaphore without checking whether
a Wait is already blocked on the old pointer. A waiting thread takes
the old semaphore pointer, drops the ACPICA interpreter lock, and
blocks in down_timeout(); Reset can then call
acpi_os_delete_semaphore(), whose BUG_ON(sem->first_waiter) and
kfree(sem) are performed without sem->lock. If the waiter is already
queued the BUG_ON fires; if the check passes before the waiter is
queued, the waiter uses freed memory. The BUG_ON was reproduced on a
KASAN 7.3 guest with a custom SSDT and a module driving concurrent
Wait/Reset, not just inferred from the source.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (mainline 7.3 source, unmodified), KASAN build from
verification/build-kasan-7.3, x86_64 QEMU/KVM guest, 2 vCPU,
CONFIG_KASAN=y, CONFIG_BUG=y. A test SSDT was loaded with QEMU's
-acpitable option and provided a device \_SB.TST0 with _HID
"RAC0001", Event(EVT0), Method(MWAI) executing
Wait(EVT0, 0xFFFFFFFF), and Method(MRST) executing Reset(EVT0).
The module acpi_case5.ko calls MWAI from one kthread, waits one
second for the waiter to block, and calls MRST from the main thread.
Line numbers are from Linux 7.3.
* Mechanism
1. Wait. acpi_ex_system_wait_semaphore()
(drivers/acpi/acpica/exsystem.c:31-59) first tries
acpi_os_wait_semaphore(..., ACPI_DO_NOT_WAIT). On AE_TIME it
calls acpi_ex_exit_interpreter(), which drops
ACPI_MTX_NAMESPACE and ACPI_MTX_INTERPRETER
(drivers/acpi/acpica/exutils.c:93-110), then blocks in
acpi_os_wait_semaphore(semaphore, 1, timeout). The semaphore
argument is the old pointer read before the interpreter lock was
dropped.
2. Reset. acpi_ex_system_reset_event()
(drivers/acpi/acpica/exsystem.c:261-280) does:
status = acpi_os_create_semaphore(..., &temp_semaphore);
if (ACPI_SUCCESS(status)) {
(void)acpi_os_delete_semaphore(obj_desc->event.os_semaphore);
obj_desc->event.os_semaphore = temp_semaphore;
}
It does not take a per-event lock, does not check for waiters,
does not wake or cancel them, and ignores the delete result.
3. Delete. acpi_os_delete_semaphore()
(drivers/acpi/osl.c:1245-1271) contains the kernel's own TODO
about non-synchronized consumers:
BUG_ON(sem->first_waiter);
kfree(sem);
sem = NULL;
The BUG_ON is an unlocked read and kfree() follows immediately.
down_timeout() checks sem->count under sem->lock and, if it must
block, chains the waiter into sem->first_waiter under the same
lock (kernel/locking/semaphore.c:198-211 and 276-284).
acpi_os_delete_semaphore() does not take sem->lock, so "no waiter
at the check" does not imply "no waiter at kfree".
Two interleavings matter:
A. Waiter already queued. Wait has linked its waiter into
first_waiter and is sleeping; Reset deletes the old semaphore;
BUG_ON(first_waiter) fires. With CONFIG_BUG=y this is an oops,
and panic_on_oops=1 turns it into a panic.
B. Delete wins the check. BUG_ON sees NULL, kfree(old_sem) runs,
and only then does the waiter enter __down_timeout() and take
old_sem->lock / write old_sem->first_waiter. This is a UAF and
can corrupt the wait list or the raw spinlock.
Signal is not the primary trigger. acpi_ex_system_signal_event()
(exsystem.c:199-215) also reads obj_desc->event.os_semaphore, but it
runs under the ACPICA interpreter lock, so it cannot overlap Reset's
delete. After Reset replaces the pointer, Signal operates on the new
semaphore and can leave the old waiter without a wakeup; that is an
event-semantics/liveness bug, not the UAF above.
The utdelete path (drivers/acpi/acpica/utdelete.c:153-161) also
deletes an event semaphore, but it requires the operand object's
refcount to reach zero while a Wait is using it; that is a narrower
and less direct path, so it is not the main trigger here.
* Reproducer (attached: acpi_case5.c) and results on 7.3
The attached module is self-contained and uses the test SSDT
described above:
insmod /acpi_case5.ko
It resolves \_SB.TST0 with acpi_get_handle(), starts a kthread that
evaluates MWAI (Wait), sleeps one second, then evaluates MRST
(Reset) from the module's main thread. Actual serial output
(trimmed):
ACPI_CASE5: get_handle st=0
ACPI_CASE5: waiter evaluating MWAI
ACPI_CASE5: evaluating MRST while waiter blocked
kernel BUG at drivers/acpi/osl.c:1266!
Oops: invalid opcode: 0000 [#1] SMP KASAN NOPTI
RIP: 0010:acpi_os_delete_semaphore+0x90/0xa0
Call Trace:
acpi_ex_system_reset_event+0xcd/0xf0
acpi_ex_opcode_1A_0T_0R
acpi_ps_parse_loop
acpi_ps_execute_method
acpi_ns_evaluate
acpi_evaluate_object
init_module+0xa0/0xff0 [acpi_case5]
This is the "waiter already queued" interleaving, not the UAF
interleaving B. It proves that Reset can reach delete with a
non-empty waiter list and that the BUG_ON is not a theoretical
assertion. It does not prove a user-triggerable path: the custom
SSDT and module are test harnesses.
* Suggested direction
Give the event object a lock or reference that covers Wait, Signal,
Reset, and destruction; make Reset either wait for/cancel all
waiters or replace the semaphore atomically while Wait revalidates
the pointer under the same lock. Do not use BUG_ON(first_waiter) as
a lifetime check: delete should take sem->lock, wake/cancel queued
waiters, and wait for them to leave before freeing. Alternatively,
use a completion or another primitive with a defined destroy
protocol. Report-first, no patch attached; happy to test any
proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: acpi_case5.c --]
[-- Type: text/plain, Size: 1233 bytes --]
// SPDX-License-Identifier: GPL-2.0
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/acpi.h>
#include <acpi/acpi_bus.h>
static acpi_handle test_handle;
static int acpi_wait_thread(void *data)
{
acpi_status st;
pr_info("ACPI_CASE5: waiter evaluating MWAI\n");
st = acpi_evaluate_object(test_handle, "MWAI", NULL, NULL);
pr_info("ACPI_CASE5: waiter returned st=%d\n", st);
return 0;
}
static int __init acpi_case5_init(void)
{
struct task_struct *t;
acpi_status st;
st = acpi_get_handle(NULL, "\\_SB.TST0", &test_handle);
pr_info("ACPI_CASE5: get_handle st=%d handle=%px\n", st, test_handle);
if (ACPI_FAILURE(st))
return -ENODEV;
t = kthread_run(acpi_wait_thread, NULL, "acpi_case5_wait");
if (IS_ERR(t))
return PTR_ERR(t);
msleep(1000);
pr_info("ACPI_CASE5: evaluating MRST while waiter blocked\n");
st = acpi_evaluate_object(test_handle, "MRST", NULL, NULL);
pr_info("ACPI_CASE5: MRST returned st=%d\n", st);
msleep(1000);
return 0;
}
static void __exit acpi_case5_exit(void)
{
pr_info("ACPI_CASE5: unload\n");
}
module_init(acpi_case5_init);
module_exit(acpi_case5_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("ACPI event Reset-vs-Wait race probe");
^ permalink raw reply [flat|nested] 32+ messages in thread* power: supply: bq25890: pump-express work can call power_supply_changed after the psy is unregistered
2026-10-27 14:12 ` Greg KH
` (16 preceding siblings ...)
2026-11-10 13:11 ` ACPI: ACPICA: Reset can delete an event semaphore while another thread is blocked in Wait 徐晗翔
@ 2026-11-10 13:14 ` 徐晗翔
2026-11-10 13:17 ` power: supply: core: get/set property callbacks can run after unregister and read freed driver data 徐晗翔
` (3 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:14 UTC (permalink / raw)
To: sre; +Cc: linux-pm, linux-kernel
The bq25890 probe registers the power_supply with devm and registers
bq25890_non_devm_cleanup() as an earlier devm action. Devres is LIFO,
so on unbind the psy is unregistered first and the pump-express work is
canceled only afterwards. bq25890_remove() does not cancel the work.
The pump work can therefore run after power_supply_unregister() and call
power_supply_changed(bq->charger) (and set_property on the secondary
charger), dereferencing a psy that the core may already have freed.
This is a driver remove-order bug, not the core wrapper bug.
The detailed description:
* Test environment / affected version
Linux 7.3.0 source tree (unmodified kernel.org 7.3), x86_64. This is
a source-level report. I did not run it on bq25890 hardware: the
KASAN build used for my core tests does not enable
CONFIG_CHARGER_BQ25890 and has no I2C bq25890 device. No reproducer
is attached. Line numbers are from 7.3.
* Mechanism
Probe order:
:1409 bq->secondary_chrg = power_supply_get_by_name(...)
:1413 devm_add_action_or_reset(..., bq25890_release_secondary_chrg)
:1477 INIT_DELAYED_WORK(&bq->pump_express_work, ...)
:1522 devm_add_action_or_reset(..., bq25890_non_devm_cleanup)
:1530 bq25890_power_supply_init()
:1040 devm_power_supply_register(...)
:1534 devm_request_threaded_irq(...)
The comment at :1518-1520 says the cleanup action must be registered
before bq25890_power_supply_init() so that it runs after devm
unregisters the power_supply. That is exactly the wrong order for a
work that calls into the psy: the psy is gone before the work is
canceled.
bq25890_remove() (:1550-1563) only cancels the usb_phy work and resets
the chip; it does not cancel pump_express_work.
bq25890_non_devm_cleanup() (:1449-1460) calls
cancel_delayed_work_sync(&bq->pump_express_work), but only after the
psy unregister.
bq25890_charger_external_power_changed() (:744-779) queues the work at
:763-765. The work (:1056-1116) sleeps/polls for up to several
seconds (3 s poll + 1 s sleep per iteration), calls
power_supply_set_property(secondary, ONLINE) at :1068/:1104, and
power_supply_changed(bq->charger) at :1110.
Once power_supply_unregister() has run and the last reference is gone,
bq->charger is a dangling pointer; power_supply_changed() dereferences
psy->changed_lock at its first line. If a consumer still holds a
reference, the psy survives but is already removed; the changed work
then hits the core 5B problem.
* Concrete corruption / impact
power_supply_changed() on the freed psy is a slab-use-after-free
(spin_lock on the freed changed_lock, then schedule_work on the freed
work item). It can corrupt the workqueue or crash the kernel; if a
consumer keeps the psy alive, the same call reaches the freed LED
triggers described in the core 5B report on a 7.3 kernel without the
9f7f7a859451 get/set guard. I have not demonstrated
privilege escalation, and I have not run this on hardware.
* Trigger model
bq25890 with pump-express enabled and a DCP charger; unbind/remove
while the pump work is queued or running. The work deliberately
sleeps, so the window is seconds, not a single instruction. Removing
the device or unbinding the driver requires root; a charger
unplug/replug can also start the work. No generic unprivileged
userspace interface.
* Reproducer / results
No reproducer attached. This is a source-level report; I can test a
proposed fix if bq25890 hardware is available. The relevant call
chain is fully visible in 7.3 at the line numbers above.
* Suggested direction
Quiesce the work before unregistering the psy. In bq25890_remove(),
set a stop flag checked by bq25890_charger_external_power_changed(),
cancel_delayed_work_sync(&bq->pump_express_work), then ensure the
power_supply is unregistered after that (explicit unregister or a
devres order that puts cleanup before unregister). Reordering the
cleanup action alone is not enough because external_power_changed()
can requeue the work after the cancel. Also clear or check
bq->charger before use. Report-first, no patch attached; happy to
test a proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread* power: supply: core: get/set property callbacks can run after unregister and read freed driver data
2026-10-27 14:12 ` Greg KH
` (17 preceding siblings ...)
2026-11-10 13:14 ` power: supply: bq25890: pump-express work can call power_supply_changed after the psy is unregistered 徐晗翔
@ 2026-11-10 13:17 ` 徐晗翔
2026-11-10 13:18 ` power: supply: core: system-battery enumeration can return an unregistered battery 徐晗翔
` (2 subsequent siblings)
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:17 UTC (permalink / raw)
To: sre; +Cc: linux-pm, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 6921 bytes --]
power_supply_get_property() and power_supply_set_property() can invoke a
driver's ->get_property()/->set_property() callback after
power_supply_unregister() has set psy->removing and after the driver's
private data has been freed. The wrappers take neither psy->epc_sem nor
check psy->removing; their only check is a check-then-act on
psy->use_cnt, which protects the struct power_supply device reference but
not devm-allocated driver data. A consumer that obtained the psy before
unregister can therefore read freed drv_data. I reproduced the
resulting KASAN slab-use-after-free on an unmodified 7.3 kernel with a
minimal out-of-tree consumer; tcpm, sun4i-usb, bq25890 and
sc27xx-fuel-gauge contain in-tree consumers with the same pattern.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (unmodified kernel.org 7.3 source tree), gcc 13.3.0,
x86_64, SMP PREEMPT_DYNAMIC, KASAN (generic/outline/stack/vmalloc),
QEMU/KVM. CONFIG_POWER_SUPPLY=y, CONFIG_POWER_SUPPLY_HWMON=y,
CONFIG_LEDS_TRIGGERS=y, CONFIG_MODULES=y. The kernel was built from
the same build tree used for the supplied module; no source changes.
The upstream fix 9f7f7a859451 ("power_supply: serialize property
access with unregister") adds the missing guard to these two wrappers
on mainline. The 7.3 tree I tested does not contain it; this report
documents the 7.3 exposure and requests a backport. I have not
attached a new patch.
* Mechanism
power_supply_get_property() (power_supply_core.c:1422-1427) is:
return __power_supply_get_property(psy, psp, val, true);
and power_supply_set_property() (:1479-1484) is:
return __power_supply_set_property(psy, psp, val, true);
Neither wrapper takes psy->epc_sem or tests psy->removing.
__power_supply_get_property() does:
if (atomic_read(&psy->use_cnt) <= 0) {
if (!psy->initialized)
return -EAGAIN;
return -ENODEV;
}
(:1397-1401) and then calls psy->desc->get_property() (:1414-1415);
the set helper has the same use_cnt test at :1455 and calls
psy->desc->set_property() at :1473-1476.
use_cnt is not a lifetime barrier for the driver's private data. A
consumer gets a device reference and increments use_cnt in
power_supply_get_by_name() (:465-478) or
power_supply_get_by_reference() (:664-687); unregister drops the
registration's own reference at :1930, but the consumer's reference
keeps the struct power_supply and use_cnt alive. The driver's
drv_data is normally devm_kzalloc'ed on the parent device and is freed
when the parent's remove path finishes, independently of the struct
power_supply. unregister does set removing under epc_sem write
(:1939-1940), cancels work (:1942-1943) and device_unregisters
(:1949), but none of that is observed by the get/set wrappers.
The interleaving:
A: consumer calls get_property(); __... reads use_cnt == 1 and
passes the test.
B: supplier's remove path calls power_supply_unregister(); it sets
removing, cancels work, device_unregisters, and the parent
remove frees drv_data (or devres releases it).
C: consumer enters desc->get_property() and dereferences drv_data.
A second variant uses psy->battery_info: it is devm_kzalloc'ed on
psy->dev (:768), installed at :1783-1787, and released by
devres_release_all() from device_del() (drivers/base/core.c:3980)
before kobject_del(). A consumer that already holds the psy can reach
the battery_info fallback at :1416-1417 after that release; tcpm's
CHARGE_FULL_DESIGN/CHARGE_FULL reads (tcpm.c:1681-1697) are a
concrete caller. This is still the same wrapper/lifetime bug, not a
separate explicit power_supply_put_battery_info() race.
sysfs is not an independent trigger here: device_remove_attrs()
(drivers/base/core.c:3964) drains the attribute files before
devres_release_all() at :3980. The reachable path is a cross-device
caller that already cached the psy.
* Concrete corruption / impact
KASAN reports a slab-use-after-free read in the driver's
->get_property() callback, e.g. ps_get_property+0x7c/0x110, Read of
size 4 at ff11000003721a80; the alloc and free stacks are both in the
test consumer/driver path. With a freed object reallocated, the
callback can return stale or reused memory as a property value; with
poisoned memory it can crash. unregister also hits
WARN_ON(atomic_dec_return(&psy->use_cnt)) at :1930 because the
consumer's reference is still held; with panic_on_warn this is a
local DoS. I have not demonstrated privilege escalation.
* Trigger model
A consumer driver must cache the supplier psy across the supplier's
unbind/remove. In-tree examples:
- tcpm caches the system batteries in port->fixed_batt
(tcpm.c:1503-1515), uses them for PD battery status/capacity
(:1607-1638, :1681-1697), and only puts them in
tcpm_unregister_port() (:8988).
- sun4i-usb caches the vbus supply (phy-sun4i-usb.c:757) and reads
PRESENT from a polling work (:427, :596).
- bq25890 caches a secondary charger (bq25890_charger.c:1409) and
calls set_property(ONLINE) from its pump work (:1066-1068,
:1102-1104).
- sc27xx-fuel-gauge has a short get_by_name()/get_property() window
(sc27xx_fuel_gauge.c:556-566).
The trigger is a supplier unbind, failed probe/remove, or module
unload while the consumer stays alive; for tcpm a PD peer can request
battery status. The test module is loaded by root, but the real paths
are hardware/remove driven, not a generic unprivileged userspace
interface. No special hardware is required for the core reproduction
beyond the module.
* Reproducer / results
Out-of-tree module psrace.c (attached, text/plain), built with:
make -C <build-kasan-7.3> CC=clang M=$PWD modules
and run under the supplied QEMU image with plan=ps3. The module
registers a test psy, has its get_property() signal entry, sleep, and
read drv_data; a consumer thread calls get_by_name() then
get_property(); the init thread unregisters and kfree()s drv_data.
Observed on 7.3:
BUG: KASAN: slab-use-after-free in ps_get_property+0x7c/0x110
Read of size 4 at addr ff11000003721a80
...
WARNING: ... power_supply_core.c:1930 ...
A control run (same module, control=1) waits for the callback to
finish before unregister/free and reports no KASAN. Full logs are
available on request.
* Suggested direction
Apply the 9f7f7a859451 guard to both wrappers:
guard(rwsem_read)(&psy->epc_sem);
if (psy->removing)
return -ENODEV;
and keep the _direct variants lockless for extension callbacks. A
stable backport to 7.3 is the immediate request. I have not attached
a patch; happy to test one.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: psrace.c --]
[-- Type: text/plain, Size: 9089 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* psrace.c - minimal power_supply lifetime-race reproducers for the
* v7.3 audit report docs/manual-review-drivers-power-2026-11-3.md.
*
* action=3 : Case 3, get_property() after power_supply_unregister()/kfree(drvdata)
* action=4 : Case 4, get_system_batteries() resurrects an unregistered battery
* action=5 : Case 5B, power_supply_changed() after unregister frees LED triggers
*
* The module only uses EXPORT_SYMBOL_GPL power_supply APIs; the test psy is a
* normal class device whose private data is deliberately freed after unregister.
*/
#define pr_fmt(fmt) "PSRACE: " fmt
#include <linux/atomic.h>
#include <linux/completion.h>
#include <linux/delay.h>
#include <linux/device.h>
#include <linux/err.h>
#include <linux/kthread.h>
#include <linux/module.h>
#include <linux/power_supply.h>
#include <linux/slab.h>
#include <linux/wait.h>
static int action = 3;
module_param(action, int, 0444);
MODULE_PARM_DESC(action, "3=get_property UAF, 4=system-battery resurrection, 5=changed-after-unregister");
static bool control;
module_param(control, bool, 0444);
MODULE_PARM_DESC(control, "1=negative control: do not free/trigger the racy object");
struct ps_priv {
int magic;
};
static struct device *root;
static struct power_supply *psy;
static struct ps_priv *priv;
static struct task_struct *thr;
static struct completion entered;
static struct completion finished;
static atomic_t armed = ATOMIC_INIT(0);
static atomic_t scope_calls = ATOMIC_INIT(0);
static struct power_supply **sysbatt;
static int sysbatt_cnt;
static int ps_get_property(struct power_supply *psy,
enum power_supply_property psp,
union power_supply_propval *val)
{
struct ps_priv *p = power_supply_get_drvdata(psy);
switch (psp) {
case POWER_SUPPLY_PROP_PRESENT:
if (atomic_read(&armed)) {
pr_info("case3 get_property entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
val->intval = READ_ONCE(p->magic);
pr_info("case3 get_property read %#x after unregister\n",
val->intval);
complete(&finished);
return 0;
}
/* Case 4 reads drvdata after the battery has been unregistered. */
val->intval = (action == 4) ? READ_ONCE(p->magic) : 1;
return 0;
case POWER_SUPPLY_PROP_ONLINE:
val->intval = 1;
return 0;
case POWER_SUPPLY_PROP_SCOPE:
/* The first call is the count pass; the second is populate. */
if (atomic_inc_return(&scope_calls) == 2) {
pr_info("case4 populate scope callback entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
}
val->intval = POWER_SUPPLY_SCOPE_SYSTEM;
return 0;
default:
return -EINVAL;
}
}
static const enum power_supply_property props3[] = {
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props4[] = {
POWER_SUPPLY_PROP_SCOPE,
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props5[] = {
POWER_SUPPLY_PROP_ONLINE,
};
static const struct power_supply_desc desc3 = {
.name = "psrace3",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props3,
.num_properties = ARRAY_SIZE(props3),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc4 = {
.name = "psrace4",
.type = POWER_SUPPLY_TYPE_BATTERY,
.properties = props4,
.num_properties = ARRAY_SIZE(props4),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc5 = {
.name = "psrace5",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props5,
.num_properties = ARRAY_SIZE(props5),
.get_property = ps_get_property,
};
static int consumer3(void *unused)
{
union power_supply_propval val;
struct power_supply *p;
msleep(100);
p = power_supply_get_by_name("psrace3");
if (!p) {
pr_err("case3 get_by_name failed\n");
complete(&finished);
return 0;
}
pr_info("case3 consumer got %px\n", p);
atomic_set(&armed, 1);
smp_mb();
power_supply_get_property(p, POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put(p);
pr_info("case3 consumer put, done\n");
return 0;
}
static int sysbatt_thread(void *unused)
{
sysbatt_cnt = power_supply_get_system_batteries(root, &sysbatt);
pr_info("case4 get_system_batteries -> %d\n", sysbatt_cnt);
complete(&finished);
return 0;
}
static int run_case3(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x12345678;
psy = power_supply_register(root, &desc3, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case3 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(consumer3, NULL, "psrace3c");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case3 callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
pr_info("case3 control: callback completed before free\n");
msleep(500);
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case3 control done (no KASAN expected)\n");
return 0;
}
pr_info("case3 unregister + kfree(drvdata)\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
/* callback wakes up and reads freed drvdata */
msleep(1000);
pr_info("case3 init done\n");
return 0;
}
static int run_case4(void)
{
int ret;
union power_supply_propval val;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x4b4b4b4b;
psy = power_supply_register(root, &desc4, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case4 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(sysbatt_thread, NULL, "psrace4s");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case4 populate callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
if (sysbatt_cnt > 0) {
pr_info("case4 control: read array[0]=%px before free\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0],
POWER_SUPPLY_PROP_PRESENT,
&val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
}
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case4 control done (no KASAN expected)\n");
return 0;
}
pr_info("case4 unregister + kfree(drvdata) during populate\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
ret = wait_for_completion_timeout(&finished, 5 * HZ);
if (!ret) {
pr_err("case4 get_system_batteries did not finish\n");
return -ETIMEDOUT;
}
if (sysbatt_cnt <= 0) {
pr_err("case4 no battery resurrected (%d)\n", sysbatt_cnt);
return -ENOENT;
}
pr_info("case4 array[0]=%px (unregistered battery), now get_property\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0], POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
pr_info("case4 init done\n");
return 0;
}
static int run_case5(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x5a5a5a5a;
psy = power_supply_register(root, &desc5, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case5 register failed %d\n", ret);
kfree(priv);
return ret;
}
pr_info("case5 online_trig=%px\n", psy->online_trig);
if (!psy->online_trig) {
pr_err("case5 no online trigger, abort\n");
return -ENOENT;
}
/*
* Emulate a consumer that keeps both the struct power_supply and a
* positive use_cnt alive. Without the use_cnt, get_property() in
* changed_work would return -ENODEV before reaching the trigger.
*/
{
struct power_supply *held = power_supply_get_by_name("psrace5");
if (!held) {
pr_err("case5 get_by_name failed\n");
return -ENOENT;
}
pr_info("case5 held=%px use_cnt now >0\n", held);
}
pr_info("case5 unregister (frees LED trigger), then changed\n");
power_supply_unregister(psy);
pr_info("case5 after unregister online_trig=%px\n", psy->online_trig);
if (control) {
msleep(1000);
power_supply_put(psy);
pr_info("case5 control done (no changed, no KASAN expected)\n");
return 0;
}
power_supply_changed(psy);
msleep(1000);
/* Drop the consumer reference only after changed_work has run. */
power_supply_put(psy);
pr_info("case5 init done\n");
return 0;
}
static int __init psrace_init(void)
{
int ret;
root = root_device_register("psrace-root");
if (IS_ERR(root))
return PTR_ERR(root);
init_completion(&entered);
init_completion(&finished);
switch (action) {
case 3:
ret = run_case3();
break;
case 4:
ret = run_case4();
break;
case 5:
ret = run_case5();
break;
default:
pr_err("unknown action %d\n", action);
ret = -EINVAL;
}
pr_info("action=%d init ret=%d\n", action, ret);
return ret;
}
module_init(psrace_init);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("power_supply v7.3 lifetime-race reproducer");
^ permalink raw reply [flat|nested] 32+ messages in thread* power: supply: core: system-battery enumeration can return an unregistered battery
2026-10-27 14:12 ` Greg KH
` (18 preceding siblings ...)
2026-11-10 13:17 ` power: supply: core: get/set property callbacks can run after unregister and read freed driver data 徐晗翔
@ 2026-11-10 13:18 ` 徐晗翔
2026-11-10 13:21 ` power: supply: core: changed work can run after unregister and use freed LED triggers 徐晗翔
2026-11-10 13:23 ` power: supply: bq24190: IRQ handler can call power_supply_changed after the psy is unregistered 徐晗翔
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:18 UTC (permalink / raw)
To: sre; +Cc: linux-pm, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 5510 bytes --]
power_supply_get_system_batteries() counts system batteries in one class
iteration and fills the array in a second one. The count pass calls
power_supply_is_system_battery(), which uses
power_supply_get_property_direct() without epc_sem or a removing check; a
failed _direct read (including -ENODEV after unregister) is treated as
"not DEVICE" and still returns true. The fill pass then calls
get_device() and atomic_inc(&psy->use_cnt) on the already unregistered
psy, putting a dead battery into the returned array. tcpm is the only
in-tree caller; it caches the array for the port lifetime. KASAN
reproduced the resulting UAF on 7.3.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (unmodified kernel.org 7.3 source tree), gcc 13.3.0,
x86_64, SMP PREEMPT_DYNAMIC, KASAN, QEMU/KVM.
CONFIG_POWER_SUPPLY=y, CONFIG_POWER_SUPPLY_HWMON=y,
CONFIG_LEDS_TRIGGERS=y, CONFIG_MODULES=y. No source changes.
The upstream 9f7f7a859451 fix adds the epc_sem/removing check to the
get/set wrappers, but deliberately leaves
power_supply_get_property_direct() lockless. It therefore does not
cover this enumeration path; 7.3 is affected and mainline still has
the two-pass logic and the _direct call.
* Mechanism
power_supply_is_system_battery() (:480-493) does:
if (psy->desc->type != POWER_SUPPLY_TYPE_BATTERY)
return false;
if (!power_supply_get_property_direct(psy,
POWER_SUPPLY_PROP_SCOPE, &val))
if (val.intval == POWER_SUPPLY_SCOPE_DEVICE)
return false;
return true;
The _direct helper (:1442-1447) calls __power_supply_get_property()
with lock=false. The helper only checks use_cnt (:1397-1401) and then
calls the driver callback; there is no epc_sem and no removing check.
If the battery is unregistered between the two passes, the direct
SCOPE read returns -ENODEV; the code treats that as "not DEVICE" and
returns true, so the battery remains a "system battery" for the count.
In the fill pass (:527-544):
if (power_supply_is_system_battery(epsy)) {
if (data->size <= data->cnt)
return -EOVERFLOW;
get_device(&epsy->dev);
data->psys[data->cnt] = epsy;
atomic_inc(&epsy->use_cnt);
data->cnt++;
}
get_device() only keeps the struct device; it does not keep the
driver's drv_data. klist_del() from device_del() does not wait for
iterator users, so this get_device() can succeed after unregister.
atomic_inc() then moves use_cnt from 0 back to 1, so a later
get_property() passes the use_cnt test (and, without the 9f7f guard,
reaches the freed driver callback).
Both passes are separate class_for_each_device() walks
(:495-519 and :527-544); there is no lock held across them and no
generation counter. power_supply_get_system_batteries() (:588-611)
allocates the array after the count, so the window also includes an
unbounded kzalloc.
* Concrete corruption / impact
The returned array can contain a battery whose power_supply was
already unregistered; tcpm then uses it for PD battery status and
capacity requests. Without the 9f7f guard, the follow-up
get_property() reads freed drv_data; KASAN reports
ps_get_property+0x5d/0x130, Read of size 4 at ff110000034f69a0.
Even with the 9f7f guard, the array is stale and tcpm will keep
querying a removed battery, returning -ENODEV or wrong data until the
port is unregistered. I have not demonstrated privilege escalation.
* Trigger model
tcpm is the only external caller (tcpm.c:1510). It enumerates system
batteries only when the port is self-powered (tcpm.c:8326) and caches
them until tcpm_unregister_port() (:8988). A battery supplier
unbind/remove (or failed probe) concurrent with
tcpm_get_fixed_batt() is the trigger; a PD peer can request
GET_BATTERY_STATUS/BATT_CAP, which reaches
tcpm_pd_send_batt_status()/batt_cap(). Requires a self-powered
Type-C/PD port and a removable/async-probed battery; not a generic
unprivileged userspace interface.
* Reproducer / results
psrace.c (attached, text/plain), plan=ps4. It registers a BATTERY
test psy whose SCOPE callback returns SYSTEM; the count pass is fast,
the populate pass signals entry and sleeps; the init thread then
unregisters and frees drv_data. The API returns cnt=1 with
array[0] pointing at the unregistered battery; a subsequent
get_property(PRESENT) reads the freed drvdata.
Observed on 7.3:
get_system_batteries -> 1
array[0]=ff11000003109000 (unregistered battery)
BUG: KASAN: slab-use-after-free in ps_get_property+0x5d/0x130
Read of size 4 at ff110000034f69a0
A control run (control=1, no unregister/free) reports no KASAN.
Full logs are available on request.
* Suggested direction
Make the enumeration robust against concurrent removal: take
epc_sem/read and reject psy->removing in is_system_battery(); treat
-ENODEV/-EAGAIN from the direct SCOPE read as "not a system battery";
after get_device() in the fill pass, revalidate the psy (or retry the
whole enumeration) before atomic_inc(); and consider exposing a
stable snapshot/generation to callers. tcpm should also invalidate
fixed_batt on a battery notifier/removal instead of caching forever.
Report-first, no patch attached; happy to test a proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: psrace.c --]
[-- Type: text/plain, Size: 9089 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* psrace.c - minimal power_supply lifetime-race reproducers for the
* v7.3 audit report docs/manual-review-drivers-power-2026-11-3.md.
*
* action=3 : Case 3, get_property() after power_supply_unregister()/kfree(drvdata)
* action=4 : Case 4, get_system_batteries() resurrects an unregistered battery
* action=5 : Case 5B, power_supply_changed() after unregister frees LED triggers
*
* The module only uses EXPORT_SYMBOL_GPL power_supply APIs; the test psy is a
* normal class device whose private data is deliberately freed after unregister.
*/
#define pr_fmt(fmt) "PSRACE: " fmt
#include <linux/atomic.h>
#include <linux/completion.h>
#include <linux/delay.h>
#include <linux/device.h>
#include <linux/err.h>
#include <linux/kthread.h>
#include <linux/module.h>
#include <linux/power_supply.h>
#include <linux/slab.h>
#include <linux/wait.h>
static int action = 3;
module_param(action, int, 0444);
MODULE_PARM_DESC(action, "3=get_property UAF, 4=system-battery resurrection, 5=changed-after-unregister");
static bool control;
module_param(control, bool, 0444);
MODULE_PARM_DESC(control, "1=negative control: do not free/trigger the racy object");
struct ps_priv {
int magic;
};
static struct device *root;
static struct power_supply *psy;
static struct ps_priv *priv;
static struct task_struct *thr;
static struct completion entered;
static struct completion finished;
static atomic_t armed = ATOMIC_INIT(0);
static atomic_t scope_calls = ATOMIC_INIT(0);
static struct power_supply **sysbatt;
static int sysbatt_cnt;
static int ps_get_property(struct power_supply *psy,
enum power_supply_property psp,
union power_supply_propval *val)
{
struct ps_priv *p = power_supply_get_drvdata(psy);
switch (psp) {
case POWER_SUPPLY_PROP_PRESENT:
if (atomic_read(&armed)) {
pr_info("case3 get_property entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
val->intval = READ_ONCE(p->magic);
pr_info("case3 get_property read %#x after unregister\n",
val->intval);
complete(&finished);
return 0;
}
/* Case 4 reads drvdata after the battery has been unregistered. */
val->intval = (action == 4) ? READ_ONCE(p->magic) : 1;
return 0;
case POWER_SUPPLY_PROP_ONLINE:
val->intval = 1;
return 0;
case POWER_SUPPLY_PROP_SCOPE:
/* The first call is the count pass; the second is populate. */
if (atomic_inc_return(&scope_calls) == 2) {
pr_info("case4 populate scope callback entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
}
val->intval = POWER_SUPPLY_SCOPE_SYSTEM;
return 0;
default:
return -EINVAL;
}
}
static const enum power_supply_property props3[] = {
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props4[] = {
POWER_SUPPLY_PROP_SCOPE,
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props5[] = {
POWER_SUPPLY_PROP_ONLINE,
};
static const struct power_supply_desc desc3 = {
.name = "psrace3",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props3,
.num_properties = ARRAY_SIZE(props3),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc4 = {
.name = "psrace4",
.type = POWER_SUPPLY_TYPE_BATTERY,
.properties = props4,
.num_properties = ARRAY_SIZE(props4),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc5 = {
.name = "psrace5",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props5,
.num_properties = ARRAY_SIZE(props5),
.get_property = ps_get_property,
};
static int consumer3(void *unused)
{
union power_supply_propval val;
struct power_supply *p;
msleep(100);
p = power_supply_get_by_name("psrace3");
if (!p) {
pr_err("case3 get_by_name failed\n");
complete(&finished);
return 0;
}
pr_info("case3 consumer got %px\n", p);
atomic_set(&armed, 1);
smp_mb();
power_supply_get_property(p, POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put(p);
pr_info("case3 consumer put, done\n");
return 0;
}
static int sysbatt_thread(void *unused)
{
sysbatt_cnt = power_supply_get_system_batteries(root, &sysbatt);
pr_info("case4 get_system_batteries -> %d\n", sysbatt_cnt);
complete(&finished);
return 0;
}
static int run_case3(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x12345678;
psy = power_supply_register(root, &desc3, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case3 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(consumer3, NULL, "psrace3c");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case3 callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
pr_info("case3 control: callback completed before free\n");
msleep(500);
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case3 control done (no KASAN expected)\n");
return 0;
}
pr_info("case3 unregister + kfree(drvdata)\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
/* callback wakes up and reads freed drvdata */
msleep(1000);
pr_info("case3 init done\n");
return 0;
}
static int run_case4(void)
{
int ret;
union power_supply_propval val;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x4b4b4b4b;
psy = power_supply_register(root, &desc4, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case4 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(sysbatt_thread, NULL, "psrace4s");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case4 populate callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
if (sysbatt_cnt > 0) {
pr_info("case4 control: read array[0]=%px before free\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0],
POWER_SUPPLY_PROP_PRESENT,
&val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
}
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case4 control done (no KASAN expected)\n");
return 0;
}
pr_info("case4 unregister + kfree(drvdata) during populate\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
ret = wait_for_completion_timeout(&finished, 5 * HZ);
if (!ret) {
pr_err("case4 get_system_batteries did not finish\n");
return -ETIMEDOUT;
}
if (sysbatt_cnt <= 0) {
pr_err("case4 no battery resurrected (%d)\n", sysbatt_cnt);
return -ENOENT;
}
pr_info("case4 array[0]=%px (unregistered battery), now get_property\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0], POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
pr_info("case4 init done\n");
return 0;
}
static int run_case5(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x5a5a5a5a;
psy = power_supply_register(root, &desc5, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case5 register failed %d\n", ret);
kfree(priv);
return ret;
}
pr_info("case5 online_trig=%px\n", psy->online_trig);
if (!psy->online_trig) {
pr_err("case5 no online trigger, abort\n");
return -ENOENT;
}
/*
* Emulate a consumer that keeps both the struct power_supply and a
* positive use_cnt alive. Without the use_cnt, get_property() in
* changed_work would return -ENODEV before reaching the trigger.
*/
{
struct power_supply *held = power_supply_get_by_name("psrace5");
if (!held) {
pr_err("case5 get_by_name failed\n");
return -ENOENT;
}
pr_info("case5 held=%px use_cnt now >0\n", held);
}
pr_info("case5 unregister (frees LED trigger), then changed\n");
power_supply_unregister(psy);
pr_info("case5 after unregister online_trig=%px\n", psy->online_trig);
if (control) {
msleep(1000);
power_supply_put(psy);
pr_info("case5 control done (no changed, no KASAN expected)\n");
return 0;
}
power_supply_changed(psy);
msleep(1000);
/* Drop the consumer reference only after changed_work has run. */
power_supply_put(psy);
pr_info("case5 init done\n");
return 0;
}
static int __init psrace_init(void)
{
int ret;
root = root_device_register("psrace-root");
if (IS_ERR(root))
return PTR_ERR(root);
init_completion(&entered);
init_completion(&finished);
switch (action) {
case 3:
ret = run_case3();
break;
case 4:
ret = run_case4();
break;
case 5:
ret = run_case5();
break;
default:
pr_err("unknown action %d\n", action);
ret = -EINVAL;
}
pr_info("action=%d init ret=%d\n", action, ret);
return ret;
}
module_init(psrace_init);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("power_supply v7.3 lifetime-race reproducer");
^ permalink raw reply [flat|nested] 32+ messages in thread* power: supply: core: changed work can run after unregister and use freed LED triggers
2026-10-27 14:12 ` Greg KH
` (19 preceding siblings ...)
2026-11-10 13:18 ` power: supply: core: system-battery enumeration can return an unregistered battery 徐晗翔
@ 2026-11-10 13:21 ` 徐晗翔
2026-11-10 13:23 ` power: supply: bq24190: IRQ handler can call power_supply_changed after the psy is unregistered 徐晗翔
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:21 UTC (permalink / raw)
To: sre; +Cc: linux-pm, linux-kernel
[-- Attachment #1: Type: text/plain, Size: 6156 bytes --]
power_supply_changed() has no removing check and always schedules
psy->changed_work. power_supply_unregister() sets removing and calls
cancel_work_sync() once, but a driver can call power_supply_changed()
after that point (from its own work or IRQ) and requeue the work.
changed_work then runs after power_supply_remove_triggers() has freed
the LED triggers; the trigger pointers are not cleared, so update_leds()
writes through freed memory. KASAN reproduced the trigger UAF on 7.3.
This is a 7.3 report. The 9f7f7a859451 guard on the get/set wrappers is
not an explicit changed_work fix, but it masks the LED dereference below
because update_leds() first calls power_supply_get_property(); on a
kernel with 9f7f that call returns -ENODEV and the trigger write is
skipped. changed_work still runs after unregister and the trigger
pointers are still dangling, so I report this as 7.3 exposure and as an
explicit changed_work/trigger cleanup gap, not as a new mainline UAF.
The detailed description:
* Test environment / affected version
Linux 7.3.0 (unmodified kernel.org 7.3 source tree), gcc 13.3.0,
x86_64, SMP PREEMPT_DYNAMIC, KASAN, QEMU/KVM.
CONFIG_POWER_SUPPLY=y, CONFIG_POWER_SUPPLY_HWMON=y,
CONFIG_LEDS_TRIGGERS=y, CONFIG_MODULES=y. No source changes.
The 7.3 tree I tested has no removing check in power_supply_changed()
and no NULL store in power_supply_remove_triggers(). I did not run a
9f7f-patched kernel; the masking statement above follows from the
guard in power_supply_get_property() and the first call in
update_leds().
* Mechanism
power_supply_changed() (:150-162):
spin_lock_irqsave(&psy->changed_lock, flags);
psy->changed = true;
pm_stay_awake(&psy->dev);
spin_unlock_irqrestore(&psy->changed_lock, flags);
schedule_work(&psy->changed_work);
There is no psy->removing check.
unregister (:1928-1951):
WARN_ON(atomic_dec_return(&psy->use_cnt));
scoped_guard(rwsem_write, &psy->epc_sem)
psy->removing = true;
cancel_work_sync(&psy->changed_work);
cancel_delayed_work_sync(&psy->deferred_register_work);
...
power_supply_remove_triggers(psy);
...
device_unregister(&psy->dev);
The cancel only cancels work queued before the call. Any
power_supply_changed() after :1942 queues it again; changed_work has
no removing check either (:78-124).
power_supply_remove_triggers() (power_supply_leds.c:207-215)
unregisters and frees the online/status/etc triggers but does not set
psy->online_trig/psy->status_trig to NULL. update_online_leds()
(:157-167) calls led_trigger_event(psy->online_trig, ...) after a
successful get_property(ONLINE); update_status_leds() (:90-134) does
the same for status. A consumer reference can keep the struct
power_supply alive, but it does not keep the triggers alive: they are
freed by unregister. changed_work then writes to freed trigger
memory.
If the caller has no reference, the struct power_supply itself can be
freed first; that is the driver-side 5A variant (bq25890/bq24190).
This report is the core-side 5B variant: the psy is still referenced,
but the triggers are gone.
* Concrete corruption / impact
KASAN reports a slab-use-after-free write in led_trigger_event() from
power_supply_changed_work:
BUG: KASAN: slab-use-after-free in led_trigger_event+0x1f/0x80
Write of size 4 at addr ff1100000368b918
...
BUG: KASAN: slab-use-after-free in led_trigger_event+0x2f/0x80
Read of size 8
The freed object is the LED trigger allocated by
power_supply_register_led_trigger() and freed by
power_supply_remove_triggers(). The write can corrupt the LED
trigger list or the LED class device; at minimum it is a KASAN
splat/panic. I have not demonstrated privilege escalation.
* Trigger model
A consumer must hold a power_supply reference (so use_cnt > 0 and the
get_property(ONLINE) in update_leds() can succeed), and a driver must
call power_supply_changed() after unregister. On 7.3 without the 9f7f
guard this reaches the freed trigger; with 9f7f the trigger write is
skipped and the residual issue is changed_work running after
unregister. In-tree asynchronous
callers include bq25890's pump work and bq24190's threaded IRQ; both
are reported separately as 5A because they can also free the psy
itself. The core path is generic and can be exercised by any driver
that calls changed from a work/IRQ without a remove-order guarantee.
The test module is loaded by root; real triggers are unbind/remove or
hardware events, not a generic unprivileged interface.
* Reproducer / results
psrace.c (attached), plan=ps5. The module registers a USB
psy with ONLINE, keeps a get_by_name() reference, unregisters the psy
(freeing the online trigger), calls power_supply_changed(), sleeps for
changed_work, and finally puts the reference.
Observed on 7.3:
online_trig=ff1100000368b900
held=ff11000003707000
unregister
WARNING: ... power_supply_core.c:1930 ...
after unregister online_trig=ff1100000368b900
BUG: KASAN: slab-use-after-free in led_trigger_event+0x1f/0x80
Write of size 4 at addr ff1100000368b918
task kworker/3:0/34, Workqueue: power_supply_changed_work
A control run (control=1, no changed after unregister) reports no
KASAN. Full logs are available on request.
* Suggested direction
If 9f7f is backported, the LED dereference is already masked; the
remaining cleanup is still worth doing. Under psy->changed_lock, have
power_supply_changed() reject psy->removing; set removing under the
same lock in unregister before cancel_work_sync(); and recheck
removing at the top of power_supply_changed_work(). Also set the
trigger pointers to NULL in power_supply_remove_triggers() and make
update_leds() tolerate NULL. This is defense in depth; drivers must
still quiesce their async callers before unregister. Report-first,
no patch attached; happy to test a proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: psrace.c --]
[-- Type: text/plain, Size: 9089 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* psrace.c - minimal power_supply lifetime-race reproducers for the
* v7.3 audit report docs/manual-review-drivers-power-2026-11-3.md.
*
* action=3 : Case 3, get_property() after power_supply_unregister()/kfree(drvdata)
* action=4 : Case 4, get_system_batteries() resurrects an unregistered battery
* action=5 : Case 5B, power_supply_changed() after unregister frees LED triggers
*
* The module only uses EXPORT_SYMBOL_GPL power_supply APIs; the test psy is a
* normal class device whose private data is deliberately freed after unregister.
*/
#define pr_fmt(fmt) "PSRACE: " fmt
#include <linux/atomic.h>
#include <linux/completion.h>
#include <linux/delay.h>
#include <linux/device.h>
#include <linux/err.h>
#include <linux/kthread.h>
#include <linux/module.h>
#include <linux/power_supply.h>
#include <linux/slab.h>
#include <linux/wait.h>
static int action = 3;
module_param(action, int, 0444);
MODULE_PARM_DESC(action, "3=get_property UAF, 4=system-battery resurrection, 5=changed-after-unregister");
static bool control;
module_param(control, bool, 0444);
MODULE_PARM_DESC(control, "1=negative control: do not free/trigger the racy object");
struct ps_priv {
int magic;
};
static struct device *root;
static struct power_supply *psy;
static struct ps_priv *priv;
static struct task_struct *thr;
static struct completion entered;
static struct completion finished;
static atomic_t armed = ATOMIC_INIT(0);
static atomic_t scope_calls = ATOMIC_INIT(0);
static struct power_supply **sysbatt;
static int sysbatt_cnt;
static int ps_get_property(struct power_supply *psy,
enum power_supply_property psp,
union power_supply_propval *val)
{
struct ps_priv *p = power_supply_get_drvdata(psy);
switch (psp) {
case POWER_SUPPLY_PROP_PRESENT:
if (atomic_read(&armed)) {
pr_info("case3 get_property entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
val->intval = READ_ONCE(p->magic);
pr_info("case3 get_property read %#x after unregister\n",
val->intval);
complete(&finished);
return 0;
}
/* Case 4 reads drvdata after the battery has been unregistered. */
val->intval = (action == 4) ? READ_ONCE(p->magic) : 1;
return 0;
case POWER_SUPPLY_PROP_ONLINE:
val->intval = 1;
return 0;
case POWER_SUPPLY_PROP_SCOPE:
/* The first call is the count pass; the second is populate. */
if (atomic_inc_return(&scope_calls) == 2) {
pr_info("case4 populate scope callback entered, drvdata=%px\n", p);
complete(&entered);
msleep(500);
}
val->intval = POWER_SUPPLY_SCOPE_SYSTEM;
return 0;
default:
return -EINVAL;
}
}
static const enum power_supply_property props3[] = {
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props4[] = {
POWER_SUPPLY_PROP_SCOPE,
POWER_SUPPLY_PROP_PRESENT,
};
static const enum power_supply_property props5[] = {
POWER_SUPPLY_PROP_ONLINE,
};
static const struct power_supply_desc desc3 = {
.name = "psrace3",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props3,
.num_properties = ARRAY_SIZE(props3),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc4 = {
.name = "psrace4",
.type = POWER_SUPPLY_TYPE_BATTERY,
.properties = props4,
.num_properties = ARRAY_SIZE(props4),
.get_property = ps_get_property,
};
static const struct power_supply_desc desc5 = {
.name = "psrace5",
.type = POWER_SUPPLY_TYPE_USB,
.properties = props5,
.num_properties = ARRAY_SIZE(props5),
.get_property = ps_get_property,
};
static int consumer3(void *unused)
{
union power_supply_propval val;
struct power_supply *p;
msleep(100);
p = power_supply_get_by_name("psrace3");
if (!p) {
pr_err("case3 get_by_name failed\n");
complete(&finished);
return 0;
}
pr_info("case3 consumer got %px\n", p);
atomic_set(&armed, 1);
smp_mb();
power_supply_get_property(p, POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put(p);
pr_info("case3 consumer put, done\n");
return 0;
}
static int sysbatt_thread(void *unused)
{
sysbatt_cnt = power_supply_get_system_batteries(root, &sysbatt);
pr_info("case4 get_system_batteries -> %d\n", sysbatt_cnt);
complete(&finished);
return 0;
}
static int run_case3(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x12345678;
psy = power_supply_register(root, &desc3, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case3 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(consumer3, NULL, "psrace3c");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case3 callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
pr_info("case3 control: callback completed before free\n");
msleep(500);
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case3 control done (no KASAN expected)\n");
return 0;
}
pr_info("case3 unregister + kfree(drvdata)\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
/* callback wakes up and reads freed drvdata */
msleep(1000);
pr_info("case3 init done\n");
return 0;
}
static int run_case4(void)
{
int ret;
union power_supply_propval val;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x4b4b4b4b;
psy = power_supply_register(root, &desc4, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case4 register failed %d\n", ret);
kfree(priv);
return ret;
}
thr = kthread_run(sysbatt_thread, NULL, "psrace4s");
if (IS_ERR(thr))
return PTR_ERR(thr);
ret = wait_for_completion_timeout(&entered, 5 * HZ);
if (!ret) {
pr_err("case4 populate callback did not enter\n");
return -ETIMEDOUT;
}
if (control) {
wait_for_completion_timeout(&finished, 5 * HZ);
if (sysbatt_cnt > 0) {
pr_info("case4 control: read array[0]=%px before free\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0],
POWER_SUPPLY_PROP_PRESENT,
&val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
}
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
pr_info("case4 control done (no KASAN expected)\n");
return 0;
}
pr_info("case4 unregister + kfree(drvdata) during populate\n");
power_supply_unregister(psy);
kfree(priv);
priv = NULL;
ret = wait_for_completion_timeout(&finished, 5 * HZ);
if (!ret) {
pr_err("case4 get_system_batteries did not finish\n");
return -ETIMEDOUT;
}
if (sysbatt_cnt <= 0) {
pr_err("case4 no battery resurrected (%d)\n", sysbatt_cnt);
return -ENOENT;
}
pr_info("case4 array[0]=%px (unregistered battery), now get_property\n",
sysbatt[0]);
power_supply_get_property(sysbatt[0], POWER_SUPPLY_PROP_PRESENT, &val);
power_supply_put_system_batteries(sysbatt, sysbatt_cnt);
pr_info("case4 init done\n");
return 0;
}
static int run_case5(void)
{
int ret;
priv = kzalloc(sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->magic = 0x5a5a5a5a;
psy = power_supply_register(root, &desc5, &(struct power_supply_config){
.drv_data = priv,
.no_wakeup_source = true,
});
if (IS_ERR(psy)) {
ret = PTR_ERR(psy);
pr_err("case5 register failed %d\n", ret);
kfree(priv);
return ret;
}
pr_info("case5 online_trig=%px\n", psy->online_trig);
if (!psy->online_trig) {
pr_err("case5 no online trigger, abort\n");
return -ENOENT;
}
/*
* Emulate a consumer that keeps both the struct power_supply and a
* positive use_cnt alive. Without the use_cnt, get_property() in
* changed_work would return -ENODEV before reaching the trigger.
*/
{
struct power_supply *held = power_supply_get_by_name("psrace5");
if (!held) {
pr_err("case5 get_by_name failed\n");
return -ENOENT;
}
pr_info("case5 held=%px use_cnt now >0\n", held);
}
pr_info("case5 unregister (frees LED trigger), then changed\n");
power_supply_unregister(psy);
pr_info("case5 after unregister online_trig=%px\n", psy->online_trig);
if (control) {
msleep(1000);
power_supply_put(psy);
pr_info("case5 control done (no changed, no KASAN expected)\n");
return 0;
}
power_supply_changed(psy);
msleep(1000);
/* Drop the consumer reference only after changed_work has run. */
power_supply_put(psy);
pr_info("case5 init done\n");
return 0;
}
static int __init psrace_init(void)
{
int ret;
root = root_device_register("psrace-root");
if (IS_ERR(root))
return PTR_ERR(root);
init_completion(&entered);
init_completion(&finished);
switch (action) {
case 3:
ret = run_case3();
break;
case 4:
ret = run_case4();
break;
case 5:
ret = run_case5();
break;
default:
pr_err("unknown action %d\n", action);
ret = -EINVAL;
}
pr_info("action=%d init ret=%d\n", action, ret);
return ret;
}
module_init(psrace_init);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("power_supply v7.3 lifetime-race reproducer");
^ permalink raw reply [flat|nested] 32+ messages in thread* power: supply: bq24190: IRQ handler can call power_supply_changed after the psy is unregistered
2026-10-27 14:12 ` Greg KH
` (20 preceding siblings ...)
2026-11-10 13:21 ` power: supply: core: changed work can run after unregister and use freed LED triggers 徐晗翔
@ 2026-11-10 13:23 ` 徐晗翔
21 siblings, 0 replies; 32+ messages in thread
From: 徐晗翔 @ 2026-11-10 13:23 UTC (permalink / raw)
To: sre; +Cc: linux-pm, linux-kernel
bq24190_remove() unregisters the battery and charger power_supplies
before pm_runtime_disable(); the IRQ is requested with devm and is
therefore freed only after remove returns. A threaded IRQ handler that
is already running, or one that runs in the window between the two
unregisters and pm_runtime_disable(), calls bq24190_check_status() ->
power_supply_changed() on a psy that has already been unregistered (and
may be freed). This is a driver remove-order bug; the window is
narrower than bq25890 but real.
The detailed description:
* Test environment / affected version
Linux 7.3.0 source tree (unmodified kernel.org 7.3), x86_64. This is
a source-level report. I did not run it on bq24190 hardware: the
KASAN build used for my core tests does not enable
CONFIG_CHARGER_BQ24190 and has no I2C bq24190 device. No reproducer
is attached. Line numbers are from 7.3.
* Mechanism
Probe:
:2121 power_supply_register(bdi->battery, ...)
:2133 power_supply_register(bdi->charger, ...)
:2160 devm_request_threaded_irq(..., bq24190_irq_handler_thread, ...)
Both power_supplies are non-devm. The IRQ is devm, so it is released
by devres after bq24190_remove() returns, not before.
bq24190_remove() (:2189-2206):
:2200 power_supply_unregister(bdi->battery);
:2201 power_supply_unregister(bdi->charger);
:2202-2204 pm_runtime_put...
:2205 pm_runtime_disable...
The threaded handler (:1834-1850) calls bq24190_check_status()
(:1824-1829), which calls power_supply_changed(bdi->charger/battery).
Window A: an IRQ is delivered after :2201 and before :2205; the
handler runs while both psy are already unregistered. Window B: a
handler is already running when :2200/:2201 execute; it can reach
power_supply_changed() after unregister. After :2205,
pm_runtime_disable() makes new IRQ handling less likely to reach the
callback, so I do not claim arbitrary post-remove IRQs; the
deterministic window is :2201-:2205, plus the in-flight handler.
* Concrete corruption / impact
power_supply_changed() dereferences psy->changed_lock; if the psy has
been freed, this is a slab-use-after-free and can corrupt the
workqueue or crash. If a consumer reference keeps the psy alive, the
call is a use-after-unregister and can reach the freed LED triggers
described in the core 5B report on a 7.3 kernel without the
9f7f7a859451 get/set guard. I have not demonstrated privilege
escalation, and I have not run this on hardware.
* Trigger model
bq24190 with a charger interrupt (AC plug/unplug, fault, or charger
status change) concurrent with unbind/remove. The window is narrow
(between the two unregisters and pm_runtime_disable, or an in-flight
handler), so it is a race rather than a deterministic sequence.
Unbinding requires root; a physical charger event is the natural
trigger. No generic unprivileged userspace interface.
* Reproducer / results
No reproducer attached. This is a source-level report; I can test a
proposed fix if bq24190 hardware is available. The relevant call
chain is fully visible in 7.3 at the line numbers above.
* Suggested direction
Disable the IRQ and wait for an in-flight handler before unregistering
the power_supplies: call disable_irq()/free_irq() (or an explicit
disable_irq + synchronize_irq) before :2200, and move
pm_runtime_disable() before the unregisters. Alternatively have the
handler check a "removed" flag set under a lock before
power_supply_changed(). Report-first, no patch attached; happy to
test a proposal.
Please keep me in Cc; I am not subscribed to the lists.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
^ permalink raw reply [flat|nested] 32+ messages in thread