usb: xhci: unlocked secondary-interrupter/virt-device teardown vs sideband API users -- use-after-free/double-free (remove and resume-reset paths; needs CONFIG_XHCI_SIDEBAND)
From: 徐晗翔
Date: Fri Oct 30 2026 - 07:41:36 EST
xhci frees its interrupter[] and devs[] software state on two paths
that hold no xhci->lock -- xhci_stop() -> xhci_mem_cleanup()
(xhci-mem.c:1928-2014), and the reset_registers branch of
xhci_resume() after a host reset error (STS_SRE/STS_HCE,
xhci.c:1152-1196, which runs xhci_free_virt_devices_depth_first()
synchronously on the resume path) -- while the sideband export API
consumed by offload clients (xhci_remove_secondary_interrupter(),
which unlinks under xhci->lock but FREES outside it;
xhci_stop_endpoint_sync(), which reads ep->ring / ep->vdev->out_ctx
under the lock) is only protected by that same lock. An in-tree
client with asynchronous messages (the Qualcomm QMI audio-offload
driver, qc_audio_offload.c) can call these at any time, and its
entry check (chip->shutdown, set by snd-usb-audio's asynchronous
disconnect) cannot cover the window: in the remove variant the
disconnect ordering only partially helps, and in the resume-reset
variant the xHCI-side frees happen BEFORE hub_wq has disconnected
the audio device, so the entry check passes while the objects are
already gone. I confirmed the core interleaving on a pristine
Linux 7.3 KASAN kernel by driving the exported API as a stand-in
client (create/remove secondary interrupter in a loop, matching the
in-tree client's call shape) against native sysfs unbind: 20
slab-use-after-free reports with the use stack hitting
xhci_remove_secondary_interrupter+0x2d and the free stack ending in
kfree <- xhci_pci_remove <- pci_device_remove <- unbind_store.
The detailed description:
* The two unlocked teardown paths vs the locked API surface
- Remove: xhci_stop() (under xhci->mutex) calls xhci_mem_cleanup(),
which walks interrupters[] (xhci-mem.c:1936-1942) and devs[]
(:1951-1955), frees event rings/ERST/contexts and kfrees the
arrays, NULLing them at :2003/:2011-2012 -- all without
xhci->lock.
- Resume reset: xhci_resume()'s STS_SRE/STS_HCE branch
(xhci.c:1146) does xhci_cleanup_command_queue (:1179) and
xhci_free_virt_devices_depth_first (:1185-1186) with no lock
(the spinlock was dropped at :1172), then xhci_debugfs_exit
(:1194).
- The export side: xhci_remove_secondary_interrupter()
(xhci-mem.c:1881-1906) validates and unlinks the interrupter
under xhci->lock, then calls xhci_free_interrupter() AFTER
unlocking; xhci_stop_endpoint_sync() (xhci.c:2907-2924) reads
ep->ring and ep->vdev->out_ctx under the lock. Neither takes
xhci->mutex, so the mutex serializing mem_cleanup against the
primary paths does not serialise these.
* Client reachability (the in-tree user)
qc_audio_offload.c processes QMI requests from the ADSP in work
context: the disable branch and error paths call
xhci_sideband_remove_interrupter() (:1250, :1515, via
handle_uaudio_stream_req :1545+); the disconnect callback notifies
the ADSP, waits a bounded timeout (:372), then unregisters and
kfrees the sideband (:1876-1915) without clearing dev->sb. The
entry check at :1595-1600 (!subs || !chip ||
atomic_read(&chip->shutdown)) only stops messages arriving AFTER
snd-usb-audio's disconnect completed:
- Remove variant (A): usb_remove_hcd() disconnects the root hub
before xhci_stop() frees the software state, so in-flight
messages that passed the check race the frees: the interrupter
is freed by both sides (double free of ERST/event ring/ir), or
mem_cleanup's unlocked loop writes ERST registers of an
interrupter already freed by the client.
- Endpoint variant (B): the client's stop-endpoint path reads
ep->ring under xhci->lock while mem_cleanup frees the rings and
the containing virt device without the lock.
- Resume variant (C, sharpest): on a resume reset error,
xhci_free_virt_devices_depth_first() runs synchronously BEFORE
hub_wq processes the lost-power disconnect -- chip->shutdown is
still 0, the entry check passes, and the endpoint/interrupter
accesses land on freed memory. Host reset errors on real
hardware are recurrent, so this is repeatable, not one-shot.
* Evidence (pristine 7.3 KASAN, QEMU qemu-xhci; instrument attached)
sbxrace.c is a loadable stand-in client built on the exported,
unconditionally-compiled EXPORT_SYMBOL_GPL API
(xhci_create_secondary_interrupter /
xhci_remove_secondary_interrupter), replaying the in-tree client's
call shape while the native sysfs unbind runs the real teardown:
BUG: KASAN: slab-use-after-free in
xhci_remove_secondary_interrupter+0x2d (20 reports)
Read of size 8 ... by task sbxrace
Allocated by task 1: __usb_create_hcd <- xhci_pci_common_probe
Freed by task 1: kfree <- xhci_pci_remove <- pci_device_remove
<- unbind_store
(a first run with a short client hold window had zero hits in 12
rounds; widening the hold to ~3 ms hit within 40 rounds --
consistent with the analysis: the race needs the API call to
overlap the teardown window). Fidelity note: the client side is a
stand-in because the QMI peer (ADSP firmware) is not available;
it uses the same exported entry points the real client uses, and
the teardown side is entirely native.
Regards,
Hanxiang Xu
xuhx@xxxxxxxxxxx
// SPDX-License-Identifier: GPL-2.0
/*
* sbxrace.c - BUG-2 (xhci_mem_cleanup lockless free vs sideband secondary
* interrupter teardown) driver.
*
* Audit case: drivers/usb/host manual review 2026-10-29, BUG-2 variant A/B.
*
* CPU-A (unbind -> xhci_pci_remove -> usb_remove_hcd -> usb_stop_hcd
* -> xhci_pci_stop -> xhci_stop -> xhci_mem_cleanup):
* [NO xhci->lock] ir = interrupters[i];
* xhci_remove_interrupter(ir); xhci_free_interrupter(ir)
* CPU-B (sideband client, e.g. QMI worker of qc_audio_offload):
* xhci_remove_secondary_interrupter(hcd, ir):
* spin_lock(xhci->lock); ...; interrupters[N]=NULL; spin_unlock
* xhci_free_interrupter(ir) <-- lock-drop free
*
* Neither side's lock covers the other: double free / UAF on the interrupter,
* its event ring and the ERST coherent memory.
*
* This module reproduces CPU-B using the very EXPORT_SYMBOL_GPL APIs a real
* sideband client calls (xhci_create_secondary_interrupter /
* xhci_remove_secondary_interrupter, both compiled in unconditionally in this
* tree). The hcd pointer is captured by a kprobe on xhci_run() (every
* successful bind re-probes and re-arms it). Userspace drives CPU-A with
* unbind/bind loops on /sys/bus/pci/drivers/xhci_hcd.
*/
#define pr_fmt(fmt) "SBXRACE: " fmt
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kprobes.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/slab.h>
#include <linux/types.h>
#include <linux/atomic.h>
/* opaque types: we only carry pointers */
struct usb_hcd;
struct xhci_interrupter;
extern struct xhci_interrupter *
xhci_create_secondary_interrupter(struct usb_hcd *hcd, unsigned int segs,
u32 imod_interval, unsigned int intr_num);
extern void xhci_remove_secondary_interrupter(struct usb_hcd *hcd,
struct xhci_interrupter *ir);
static int rounds = 2000;
module_param(rounds, int, 0600);
MODULE_PARM_DESC(rounds, "create/remove rounds the sideband kthread runs");
static int delay_us = 100;
module_param(delay_us, int, 0600);
MODULE_PARM_DESC(delay_us, "pause between create and remove (us)");
static struct usb_hcd *g_hcd;
static atomic_t g_hcd_seen = ATOMIC_INIT(0);
static int run_pre(struct kprobe *p, struct pt_regs *regs)
{
/* xhci_run(struct usb_hcd *hcd): RDI */
g_hcd = (struct usb_hcd *)regs->di;
atomic_set(&g_hcd_seen, 1);
pr_info("captured hcd=%px (bind #%d)\n", g_hcd,
atomic_read(&g_hcd_seen));
return 0;
}
static struct kprobe kp_run = {
.symbol_name = "xhci_run",
.pre_handler = run_pre,
};
static struct task_struct *sb_thread;
static atomic_t stop_req = ATOMIC_INIT(0);
static atomic_t n_create = ATOMIC_INIT(0);
static atomic_t n_remove = ATOMIC_INIT(0);
static atomic_t n_null = ATOMIC_INIT(0);
static int sb_loop(void *unused)
{
int i;
pr_info("sideband kthread up, waiting for hcd capture\n");
while (!atomic_read(&g_hcd_seen) && !kthread_should_stop())
msleep(20);
pr_info("hcd=%px, starting %d create/remove rounds\n", g_hcd, rounds);
for (i = 0; i < rounds && !atomic_read(&stop_req); i++) {
struct xhci_interrupter *ir;
/* re-read: rebind installs a fresh hcd */
ir = xhci_create_secondary_interrupter(g_hcd, 1, 40000, 0);
if (ir) {
atomic_inc(&n_create);
if (delay_us)
udelay(delay_us);
xhci_remove_secondary_interrupter(g_hcd, ir);
atomic_inc(&n_remove);
} else {
atomic_inc(&n_null);
}
if ((i % 200) == 0 && i)
pr_info("round %d: create=%d remove=%d null=%d\n",
i, atomic_read(&n_create),
atomic_read(&n_remove), atomic_read(&n_null));
}
pr_info("done: rounds=%d create=%d remove=%d null=%d\n",
i, atomic_read(&n_create), atomic_read(&n_remove),
atomic_read(&n_null));
atomic_set(&stop_req, 2);
return 0;
}
static int __init sbxrace_init(void)
{
int ret;
ret = register_kprobe(&kp_run);
if (ret) {
pr_err("register_kprobe(xhci_run) failed: %d\n", ret);
return ret;
}
sb_thread = kthread_run(sb_loop, NULL, "sbxrace");
if (IS_ERR(sb_thread)) {
unregister_kprobe(&kp_run);
return PTR_ERR(sb_thread);
}
pr_info("loaded (rounds=%d delay_us=%d); start unbind/bind loops now\n",
rounds, delay_us);
return 0;
}
static void __exit sbxrace_exit(void)
{
atomic_set(&stop_req, 1);
while (atomic_read(&stop_req) != 2)
msleep(50);
kthread_stop(sb_thread);
unregister_kprobe(&kp_run);
pr_info("removed\n");
}
module_init(sbxrace_init);
module_exit(sbxrace_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("BUG-2 sideband interrupter teardown race driver");