perf/uncore: CPU-hotplug box migration racing PCI box removal operates on a freed intel_uncore_box (no common lock, no reference on the hotplug side)

From: 徐晗翔

Date: Thu Oct 22 2026 - 10:07:40 EST


When a CPU goes offline (or online) on a multi-die host, the uncore
hotplug callback walks every type's boxes through
uncore_change_type_ctx(), which reads pmu->boxes[die], writes box
fields, cancels the box hrtimer and calls perf_pmu_migrate_context()
-- all without taking a reference on the box and without any lock in
common with the PCI remove path, which on the "last box" transition
NULLs the slot and kfrees the box. The hotplug side can therefore
hold and keep using a box the PCI side just freed: hrtimer_cancel()
on a freed timer (its base pointer read leads to a spinlock on
arbitrary memory), writes into freed slab, and a migration chain that
may also dereference an unregistered PMU's freed per-CPU contexts.
7.3's recent box-refcounting refactor covered the PCI probe/remove
pair but left the hotplug side unreferenced, so the race is fully
present in current mainline; the perf-core half of the transmission
chain, in contrast, I verified dynamically to be properly protected,
which pins the defect squarely in the uncore layer.

The two paths share pmu->boxes[die] and the box object with nothing
to serialize them (arch/x86/events/intel/uncore.c): the CPU hotplug
callbacks take the cpu hotplug locks, the PCI remove callback takes
its device's lock -- neither takes the other's, and unlike the probe
path, PCI remove has no cpu_hotplug_disable() wrapper. The box
reference count that 7.3 introduced is taken by the PCI probe
(atomic_inc(&box->refcnt), uncore.c:1215) and dropped by PCI remove
(uncore.c:1293-1296), but the hotplug side's uncore_box_ref()/
uncore_box_unref() iterate only the MSR and MMIO uncore arrays
(uncore.c:1590-1591 on offline, :1692-1693 on online) -- the PCI
types reach uncore_change_type_ctx() via
uncore_change_context(uncore_pci_uncores, ...) (uncore.c:1608/1689)
with no reference at all. Triggering needs a multi-die host with
id-table uncore PCI drivers and concurrent CPU offline/online plus
PCI unbind/remove (administrator actions, or a hotplug event racing
an admin operation).

The detailed description:

* Test environment / affected version

Linux version 7.3.0 (gcc 13.3.0, GNU ld 2.42) #1 SMP PREEMPT_DYNAMIC
-- vanilla mainline 7.3 release tarball (kernel.org, unmodified),
rebuilt with CONFIG_PERF_EVENTS=y, CONFIG_PERF_EVENTS_INTEL_UNCORE=y,
CONFIG_HOTPLUG_CPU=y, KASAN enabled, QEMU/KVM x86_64 guest. Line
numbers below are from 7.3 (the refactor that added box refcounting
and PMU_BROKEN is the current shape analyzed here).

* Mechanism

H side -- CPU offline for this die (uncore_event_cpu_offline(),
uncore.c:1584):

uncore_box_unref(uncore_msr_uncores, die); /* :1590 msr/mmio
uncore_box_unref(uncore_mmio_uncores, die); /* :1591 only! */
...
uncore_change_context(uncore_pci_uncores, cpu, target); /* :1608 */
-> uncore_change_type_ctx() /* :1514 */
box = pmu->boxes[die]; /* :1523 no ref */
... box->cpu / cpumask writes ... /* :1531/:1538 */
uncore_pmu_cancel_hrtimer(box); /* :1547 */
perf_pmu_migrate_context(&pmu->pmu, old_cpu, new_cpu);
/* :1548, sleeps */
box->cpu = new_cpu; /* :1549 use of
box AFTER the
sleepable call */
cpumask_set_cpu(new_cpu, &pmu->cpu_mask);/* :1550 */

P side -- PCI remove of that die's box (uncore_pci_pmu_unregister(),
uncore.c:1283):

pmu->boxes[die] = NULL; /* :1289 */
if (atomic_dec_return(&pmu->activeboxes) == 0)
uncore_pmu_unregister(pmu); /* :1291-1292 (the
register/unreg
race reported
separately) */
if (atomic_dec_return(&box->refcnt) == 0) { /* :1293: probe's
ref is the only
one -> 0 */
uncore_box_exit(box);
kfree(box); /* :1295 */
}

Interleaving: H reads box at :1523, P NULLs the slot and drops the
refcount to zero and kfrees; H continues on freed memory:
- uncore_pmu_cancel_hrtimer() (uncore.c:356) -> hrtimer_cancel() ->
lock_hrtimer_base() reads the freed timer->base and then
raw_spin_locks whatever address that freed word now holds (GPF or
a lockup on reuse; KASAN would report the UAF read);
- perf_pmu_migrate_context() (kernel/events/core.c:14467) runs
while the PMU itself may be getting unregistered by the same
remove (its __perf_pmu_install_event() -> find_get_pmu_context()
dereferences pmu->cpu_pmu_context -- converging with the consumer
side of my previous report);
- :1549-1550 write box->cpu/cpumask into freed slab.

Online variant: allocate_boxes() (:1612) checking and installing
pmu->boxes[die] races the same read-out/NULL/kfree on removal,
yielding a double box in one slot or a dangling slot; note that the
WARN_ON_ONCE against an occupied slot added by the 7.3 refactor
(:1206) guards the probe path, not this one.

* Evidence

- Source-level: the analysis above is on current 7.3, function by
function; the lock inventory of uncore.c is unchanged by the
refactor (no serialization exists or was added between the hotplug
callbacks and the PCI callbacks), and the hotplug side demonstrably
takes no box reference for PCI types (its ref/unref helpers are
only invoked on the msr/mmio arrays).
- Dynamic, NEGATIVE (this is the half I could run without uncore
hardware): the perf-core transmission chain the migration funnels
into -- perf_pmu_migrate_context() vs concurrent
perf_pmu_unregister()/register() with in-flight event creation and
CPU hotplug -- was stress-tested on the KASAN kernel with the
attached module's migunreg/migopen actions (300 rounds each,
sequential unregister/register + migrate hammer + open/close
hammer + CPU offline/online loops): ZERO KASAN reports in both
configurations. The existing SRCU + IDR/list removal ordering +
context locks protect the perf-core side; the unprotected part is
precisely the uncore-layer box lifetime this report describes.
(The third configuration with concurrent register churn reaches
the register/unregister race of my previous report first, as
expected.)
- Real-hardware trigger (root, multi-die, e.g. SKX):

# terminal A: offline/online a collecting CPU of the die
while :; do
echo 0 > /sys/devices/system/cpu/cpuN/online
echo 1 > /sys/devices/system/cpu/cpuN/online
done
# terminal B: unbind/rebind the same die's uncore box
while :; do
echo <bdf> > /sys/bus/pci/drivers/skx_uncore/unbind
echo <bdf> > /sys/bus/pci/drivers/skx_uncore/bind
done
# expected with KASAN: use-after-free in
# uncore_change_type_ctx / uncore_pmu_cancel_hrtimer /
# lock_hrtimer_base; or a double box in pmu->boxes[die]

My test environment provides no Intel uncore PCI devices, so the
H x P interleaving itself is source-proven, not dynamically
reproduced.


Thanks for taking a look.


Regards,
Hanxiang Xu
xuhx@xxxxxxxxxxx

// SPDX-License-Identifier: GPL-2.0
/*
* pmu_race.c - verification driver for perf/PMU race-condition audit cases
* (R4/R6/R8/R9) against the KASAN verification kernel.
*
* Exposes an "action" module parameter that drives perf_pmu_register /
* perf_pmu_unregister / perf_pmu_migrate_context / kernel-counter creation
* on a fake PMU named "racepmu":
*
* echo register > action : one perf_pmu_register
* echo unregister > action : one perf_pmu_unregister
* echo regloop N > action : N sequential register+unregister rounds
* (natural-window hammer, R4-A / baseline)
* echo race N > action : 2 kthreads: A=unregister loop,
* B=register loop on the same struct pmu
* (R8: activeboxes TOCTOU core, uncore-style
* template reset before each register)
* echo churn N > action : 3 kthreads: reg/unreg churn + migrate loop
* + open/close churn (R9 perf-core overlap)
* echo open N > action : N kernel counter create/release attempts
* (poisoned-state consumption)
* echo status > action : log registered state and stats
*
* The kernel source is NOT modified: everything is driven through exported
* perf core APIs (perf_pmu_register/perf_pmu_unregister are EXPORT_SYMBOL_GPL,
* perf_pmu_migrate_context and perf_event_create_kernel_counter likewise).
*/
#define pr_fmt(fmt) "PMURACE: " fmt

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kthread.h>
#include <linux/sched.h>
#include <linux/delay.h>
#include <linux/workqueue.h>
#include <linux/perf_event.h>
#include <linux/err.h>
#include <linux/string.h>

static struct pmu test_pmu;
static int my_registered;
static int test_type = -1;

/* statistics */
static unsigned long stat_reg_ok, stat_reg_fail;
static unsigned long stat_unreg_ok, stat_unreg_fail;
static unsigned long stat_open_ok, stat_open_fail;
static int open_err0, open_err1;

static int race_event_init(struct perf_event *event);
static void race_event_read(struct perf_event *event);
static int race_event_add(struct perf_event *event, int flags);
static void race_event_del(struct perf_event *event, int flags);
static void race_event_start(struct perf_event *event, int flags);
static void race_event_stop(struct perf_event *event, int flags);
static int open_thread(void *data);

/*
* Template reset mirrors uncore_pmu_register()'s
* pmu->pmu = (struct pmu){ ... };
* which clears ->dev / ->cpu_pmu_context / ->type etc. before re-registering.
*/
static void pmu_reset(void)
{
static const struct pmu tmpl = {
.task_ctx_nr = perf_invalid_context,
.module = THIS_MODULE,
.capabilities = PERF_PMU_CAP_NO_INTERRUPT |
PERF_PMU_CAP_NO_EXCLUDE,
.event_init = race_event_init,
.add = race_event_add,
.del = race_event_del,
.start = race_event_start,
.stop = race_event_stop,
.read = race_event_read,
};

test_pmu = tmpl;
}

static int race_event_init(struct perf_event *event)
{
if (event->attr.type != test_type)
return -ENOENT;
return 0;
}
static void race_event_read(struct perf_event *event) { }
static int race_event_add(struct perf_event *event, int flags) { return 0; }
static void race_event_del(struct perf_event *event, int flags) { }
static void race_event_start(struct perf_event *event, int flags) { }
static void race_event_stop(struct perf_event *event, int flags) { }

static int do_register(void)
{
int ret;

pmu_reset();
ret = perf_pmu_register(&test_pmu, "racepmu", -1);
if (ret) {
stat_reg_fail++;
my_registered = 0;
} else {
stat_reg_ok++;
my_registered = 1;
test_type = test_pmu.type;
}
return ret;
}

static int do_unregister(void)
{
int ret;

if (!my_registered) {
stat_unreg_fail++;
return -EINVAL;
}
ret = perf_pmu_unregister(&test_pmu);
if (ret) {
stat_unreg_fail++;
} else {
stat_unreg_ok++;
my_registered = 0;
}
return ret;
}

static int do_open_one(int cpu)
{
struct perf_event_attr attr = {};
struct perf_event *ev;

if (!my_registered || test_type < 0)
return -ENODEV;

attr.size = sizeof(attr);
attr.type = test_type;
attr.config = 0;
attr.disabled = 1;

ev = perf_event_create_kernel_counter(&attr, cpu % nr_cpu_ids, NULL,
NULL, NULL);
if (IS_ERR(ev)) {
stat_open_fail++;
if (open_err0 == 0) open_err0 = (int)PTR_ERR(ev);
else if (open_err1 == 0 && (int)PTR_ERR(ev) != open_err0) open_err1 = (int)PTR_ERR(ev);
return PTR_ERR(ev);
}
stat_open_ok++;
perf_event_release_kernel(ev);
return 0;
}

/*
* Ungated open attempt: like a real in-flight perf_event_open() that
* already passed perf_init_event() under SRCU while the PMU is being
* torn down. No my_registered gate: failures are expected and ignored;
* the goal is to reach find_get_pmu_context() while/after
* pmu->cpu_pmu_context is freed.
*/
static void do_open_raw(int cpu)
{
struct perf_event_attr attr = {};
struct perf_event *ev;

if (test_type < 0)
return;

attr.size = sizeof(attr);
attr.type = test_type;
attr.config = 0;
attr.disabled = 1;

ev = perf_event_create_kernel_counter(&attr, cpu % nr_cpu_ids, NULL,
NULL, NULL);
if (!IS_ERR(ev))
perf_event_release_kernel(ev);
}

struct loop_ctx {
long n;
struct completion done;
};

/* R8: thread A - unregister hammer (mirrors uncore_pci_pmu_unregister) */
static int race_unreg_thread(void *data)
{
struct loop_ctx *c = data;
long i;

for (i = 0; i < c->n && !kthread_should_stop(); i++) {
if (my_registered)
do_unregister();
usleep_range(0, 300);
}
complete(&c->done);
return 0;
}

/* R8: thread B - register hammer with template reset (mirrors uncore_pmu_register) */
static int race_reg_thread(void *data)
{
struct loop_ctx *c = data;
long i;

for (i = 0; i < c->n && !kthread_should_stop(); i++) {
do_register();
usleep_range(0, 300);
}
complete(&c->done);
return 0;
}

/* R9 core: migrate hammer (mirrors uncore_change_type_ctx) */
static int migrate_thread(void *data)
{
struct loop_ctx *c = data;
long i;

for (i = 0; i < c->n && !kthread_should_stop(); i++) {
if (my_registered)
perf_pmu_migrate_context(&test_pmu, 0, 1 % nr_cpu_ids);
usleep_range(0, 200);
}
complete(&c->done);
return 0;
}

/*
* R9-specific: migrate hammer kthread racing a SEQUENTIAL
* unregister/register loop (no concurrent register||unregister, so the
* R8 tear at perf_pmu_unregister+0x1d3 cannot fire first). The migrate
* thread keeps calling perf_pmu_migrate_context() while the loop frees
* and re-creates pmu->cpu_pmu_context.
*/
static int mig_hammer(void *unused)
{
while (!kthread_should_stop()) {
perf_pmu_migrate_context(&test_pmu, 0, 1 % nr_cpu_ids);
cond_resched();
}
return 0;
}

static void run_migunreg(long n)
{
struct task_struct *mig;
long i;

if (!my_registered)
do_register();

mig = kthread_run(mig_hammer, NULL, "pmu_mig_hammer");
if (IS_ERR(mig)) {
pr_err("kthread_run failed\n");
return;
}

for (i = 0; i < n; i++) {
if (my_registered)
do_unregister();
do_register();
cond_resched();
}

kthread_stop(mig);
/* consume any poisoned end state */
do_open_one(0);
do_open_one(1 % nr_cpu_ids);
pr_info("MIGUNREG_DONE n=%ld reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu open_ok=%lu open_fail=%lu\n",
n, stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail,
stat_open_ok, stat_open_fail);
}

/*
* R9 with live events: a migrate hammer and an open/close hammer run
* continuously while the main thread does sequential unregister/register
* rounds. Opened events live long enough for perf_pmu_migrate_context()
* to take the __perf_pmu_install_event() path, which dereferences
* pmu->cpu_pmu_context through find_get_pmu_context().
*/
static void run_migopen(long n)
{
struct loop_ctx o = { .n = 4 * n + 64 };
struct task_struct *mig, *op;
long i;

init_completion(&o.done);
if (!my_registered)
do_register();

mig = kthread_run(mig_hammer, NULL, "pmu_mig_hammer");
op = kthread_run(open_thread, &o, "pmu_open_hammer");
if (IS_ERR(mig) || IS_ERR(op)) {
pr_err("kthread_run failed\n");
return;
}

for (i = 0; i < n; i++) {
if (my_registered)
do_unregister();
do_register();
/* give the open/migrate hammers a stable registered window */
usleep_range(4000, 8000);
cond_resched();
}

kthread_stop(mig);
if (!IS_ERR(op)) {
/* let the open hammer drain, then stop */
wait_for_completion(&o.done);
}
pr_info("MIGOPEN_DONE n=%ld reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu open_ok=%lu open_fail=%lu err0=%d err1=%d\n",
n, stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail,
stat_open_ok, stat_open_fail, open_err0, open_err1);
}

/* R9 core: open/close churn (exercises find_get_pmu_context) */

static int open_thread(void *data)
{
struct loop_ctx *c = data;
long i;

for (i = 0; i < c->n && !kthread_should_stop(); i++) {
do_open_raw((int)(i % nr_cpu_ids));
usleep_range(0, 200);
}
complete(&c->done);
return 0;
}

static void run_race(long n)
{
struct loop_ctx a = { .n = n }, b = { .n = n };
struct task_struct *ta, *tb;

init_completion(&a.done);
init_completion(&b.done);

/* start from registered state like the real scenario */
if (!my_registered)
do_register();

ta = kthread_run(race_unreg_thread, &a, "pmu_race_unreg");
tb = kthread_run(race_reg_thread, &b, "pmu_race_reg");
if (!IS_ERR(ta) && !IS_ERR(tb)) {
wait_for_completion(&a.done);
wait_for_completion(&b.done);
} else
pr_err("kthread_run failed\n");
if (!IS_ERR(ta))
kthread_stop(ta);
if (!IS_ERR(tb))
kthread_stop(tb);

pr_info("RACE_DONE n=%ld reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu\n",
n, stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail);
}

static void run_churn(long n)
{
struct loop_ctx a = { .n = n }, b = { .n = n }, c = { .n = n };
struct loop_ctx d = { .n = n };
struct task_struct *ta, *tb, *tc, *td;

init_completion(&a.done);
init_completion(&b.done);
init_completion(&c.done);
init_completion(&d.done);

if (!my_registered)
do_register();

ta = kthread_run(race_unreg_thread, &a, "pmu_churn_unreg");
tb = kthread_run(race_reg_thread, &b, "pmu_churn_reg");
tc = kthread_run(migrate_thread, &c, "pmu_churn_mig");
td = kthread_run(open_thread, &d, "pmu_churn_open");
if (!IS_ERR(ta) && !IS_ERR(tb) && !IS_ERR(tc) && !IS_ERR(td)) {
wait_for_completion(&a.done);
wait_for_completion(&b.done);
wait_for_completion(&c.done);
/* consume any poisoned end state */
do_open_one(0);
do_open_one(1 % nr_cpu_ids);
} else
pr_err("kthread_run failed\n");

pr_info("CHURN_DONE n=%ld reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu open_ok=%lu open_fail=%lu\n",
n, stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail,
stat_open_ok, stat_open_fail);
}

static void run_regloop(long n)
{
long i;

for (i = 0; i < n; i++) {
do_register();
do_unregister();
usleep_range(0, 50);
}
pr_info("REGLOOP_DONE n=%ld reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu\n",
n, stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail);
}

static void run_open(long n)
{
long i;

for (i = 0; i < n; i++)
do_open_raw((int)(i % nr_cpu_ids));
pr_info("OPEN_DONE open_ok=%lu open_fail=%lu\n",
stat_open_ok, stat_open_fail);
}

static char action_cmd[64];

static void action_workfn(struct work_struct *w)
{
char cmd[64];
long n;
char *p, *cp;

strscpy(cmd, strstrip(action_cmd), sizeof(cmd));
cp = cmd;
p = strsep(&cp, " ");
n = 0;
if (cp && cp[0])
if (kstrtol(cp, 0, &n))
n = 0;

if (!strcmp(p, "register"))
pr_info("register rc=%d type=%d\n", do_register(), test_type);
else if (!strcmp(p, "unregister"))
pr_info("unregister rc=%d\n", do_unregister());
else if (!strcmp(p, "regloop"))
run_regloop(n);
else if (!strcmp(p, "race"))
run_race(n);
else if (!strcmp(p, "churn"))
run_churn(n);
else if (!strcmp(p, "migunreg"))
run_migunreg(n);
else if (!strcmp(p, "migopen"))
run_migopen(n);
else if (!strcmp(p, "open"))
run_open(n);
else if (!strcmp(p, "status"))
pr_info("status registered=%d type=%d reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu open_ok=%lu open_fail=%lu\n",
my_registered, test_type, stat_reg_ok, stat_reg_fail,
stat_unreg_ok, stat_unreg_fail, stat_open_ok,
stat_open_fail);
else
pr_info("unknown action '%s'\n", p);

pr_info("ACTION_COMPLETE\n");
}

static DECLARE_WORK(action_work, action_workfn);

static char action_cmd[64];

static int action_set(const char *val, const struct kernel_param *kp)
{
int ret = param_set_copystring(val, kp);

if (ret == 0)
schedule_work(&action_work);
return ret;
}

static const struct kernel_param_ops action_ops = {
.set = action_set,
.get = param_get_string,
};
static struct kparam_string action_param = {
.maxlen = sizeof(action_cmd),
.string = action_cmd,
};
module_param_cb(action, &action_ops, &action_param, 0200);
MODULE_PARM_DESC(action, "register|unregister|regloop N|race N|churn N|open N|status");

static int __init pmu_race_init(void)
{
pmu_reset();
pr_info("loaded, nr_cpu_ids=%d\n", nr_cpu_ids);
return 0;
}

static void __exit pmu_race_exit(void)
{
cancel_work_sync(&action_work);
if (my_registered)
do_unregister();
pr_info("unloaded reg_ok=%lu reg_fail=%lu unreg_ok=%lu unreg_fail=%lu\n",
stat_reg_ok, stat_reg_fail, stat_unreg_ok, stat_unreg_fail);
}

module_init(pmu_race_init);
module_exit(pmu_race_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("perf/PMU race-condition verification driver (R4/R6/R8/R9)");