* usb: xhci: xhci_hc_died() traverses devs[] that mem_cleanup may already have freed -- NULL-deref/UAF in the free_irq lag window
@ 2026-10-30 11:44 徐晗翔
0 siblings, 0 replies; only message in thread
From: 徐晗翔 @ 2026-10-30 11:44 UTC (permalink / raw)
To: mathias.nyman, gregkh; +Cc: linux-kernel, linux-usb
[-- Attachment #1: Type: text/plain, Size: 3996 bytes --]
usb_remove_hcd() stops the HCD (running .stop -> xhci_stop ->
xhci_mem_cleanup, which frees the virt devices, kfrees xhci->devs and
NULLs it) BEFORE it calls free_irq() -- so during the whole teardown
window the IRQ handler is still registered, and one specific entry
into it bypasses every guard xhci_stop set up: if the MMIO status
register reads 0xffffffff (the surprise-removal/FLR/power-loss
value), xhci_irq() jumps straight to xhci_hc_died()
(xhci-ring.c:3191-3194), which walks xhci->devs[i] under xhci->lock
(:1405-1410) -- a lock that cannot help, because mem_cleanup freed
the array without holding it. The walk then either dereferences the
already-kfreed devs array (UAF) or, once mem_cleanup has stored NULL,
takes the NULL pointer path in xhci_get_virt_ep() (xhci-ring.c:614)
and oopses. I confirmed the traversal-side UAF on a pristine Linux
7.3 KASAN kernel by holding the teardown at the point where
kfree(xhci->devs) has run but the pointer is not yet NULL, and
invoking xhci_hc_died() exactly as the 0xffffffff branch would:
seven slab-use-after-free reports in xhci_hc_died+0xab with the free
stack at kfree <- xhci_mem_cleanup+0x1ff (the devs array free). The
premise is realistic: xHCI falls back to a shared legacy IRQ when
MSI/MSI-X allocation fails (xhci-pci.c:202-208), so any other device
on the line re-enters xhci_irq() throughout removal, and the kernel
itself invokes the handler directly during PCI removal
(usb_hcd_pci_remove()'s local_irq_disable/usb_hcd_irq pair,
hcd-pci.c:325-331).
The detailed description:
* The ordering and the bypass (Linux 7.3)
- drivers/usb/core/hcd.c:3077-3081: usb_stop_hcd() runs before
free_irq() in usb_remove_hcd().
- xhci_stop() sets HALTED, clears STS_EINT and disables the
interrupter -- which stops xhci_handle_events() (its entry check
is xhci-ring.c:3107-3115) -- but xhci_irq()'s
status == ~(u32)0 branch (:3191-3194) skips that check entirely
and calls xhci_hc_died() then 'goto out'.
- xhci_hc_died() under xhci->lock:
xhci_cleanup_command_queue(); then for each slot
xhci_kill_endpoint_urbs() -> xhci_get_virt_ep() ->
"if (!xhci->devs[slot_id])" (xhci-ring.c:614).
- Meanwhile xhci_mem_cleanup() -- not holding that lock -- frees
the virt devices (xhci-mem.c:1951-1955), kfrees xhci->devs
(:2003) and NULLs the pointer (:2012).
Two failure shapes follow: NULL->devs[i] read (deterministic
oops once NULL is stored) and traversal racing the per-device
frees (ep->ep_state / ep->stream_info / ring accesses as UAF).
* Why IRQ delivery in the window is expected, not exotic
- MSI/MSI-X failure falls back to legacy shared INTx
(xhci-pci.c:202-208, IRQF_SHARED): co-line devices keep the
handler running during the whole removal; the MSI-X vectors are
freed inside .stop, the legacy vector only at usb_remove_hcd()'s
tail.
- usb_hcd_pci_remove() itself deliberately fakes one interrupt
(hcd-pci.c:325-331), i.e. executing the handler during removal
is a kernel-sanctioned shape.
- The HCD_IRQ_DEAD/HW_ACCESSIBLE entry filters do not help: DEAD is
only set by usb_hc_died(), and HW_ACCESSIBLE stays set from
usb_add_hcd() through this path.
* Evidence (pristine 7.3 KASAN, QEMU qemu-xhci; instrument attached)
xh4stall.c parks the teardown thread at xhci_rh_bw_cleanup()'s
entry -- the point in xhci_mem_cleanup() after kfree(xhci->devs)
(xhci-mem.c:1954) but before the pointer is NULLed (:2012) -- and
then invokes xhci_hc_died() from a kthread, taking xhci->lock,
which is byte-for-byte the behavior of the 0xffffffff branch (and
of the kernel's own fake-IRQ precedent). Result:
BUG: KASAN: slab-use-after-free in xhci_hc_died+0xab (x7)
Freed by: kfree <- xhci_mem_cleanup+0x1ff (devs array)
Allocated by: __kmalloc_node
Usage: insmod xh4stall.ko; unbind the xhci device; the module
stalls and injects inside the window, then releases.
Regards,
Hanxiang Xu
xuhx@hust.edu.cn
[-- Attachment #2: xh4stall.c --]
[-- Type: text/plain, Size: 5933 bytes --]
// SPDX-License-Identifier: GPL-2.0
/*
* xh4stall.c - BUG-4 (xhci_hc_died() device traversal during teardown)
* controlled-window instrument.
*
* Audit case: drivers/usb/host manual review 2026-10-29, BUG-4.
*
* usb_remove_hcd(): usb_stop_hcd() -> .stop -> xhci_stop ->
* xhci_mem_cleanup() [releases devs[] locklessly]
* ... only THEN free_irq() hcd.c:3077-3081
*
* xhci_irq(): status = readl(op_regs->status); if (status == ~0u)
* xhci_hc_died() ... goto out xhci-ring.c:3191-3194
* (this path BYPASSES the HALTED check that normally guards
* xhci_handle_events(); it is what the fake irq at
* hcd-pci.c:329-331 exercises on physical removal)
*
* xhci_hc_died(): for (i..max_slots) xhci_kill_endpoint_urbs(xhci,i,j)
* -> xhci_get_virt_ep -> xhci->devs[slot_id]
* xhci-ring.c:1405-1410,614
*
* Injection: a kprobe stalls the teardown thread at a precise point inside
* xhci_mem_cleanup() where xhci->devs is in a chosen state; while stalled, a
* kthread performs the xhci_hc_died() call that the status==~0 irq path
* would have made:
*
* mode=1 stall at scratchpad_free() entry xhci-mem.c:1987
* (devs array already kfree()d at :1954, pointer NOT yet NULLed
* at :2012 -> hc_died reads a freed array: KASAN slab-UAF)
* mode=2 stall at xhci_rh_bw_cleanup() entry xhci-mem.c:1903
* (called at :1989-1990, after devs=NULL at :2012... note the
* order in this tree: kfree(devs)@1954 -> ... -> rh_bw@1989 ->
* devs=NULL@2012, so BOTH stall points sit inside the window
* between array kfree and pointer NULLing; mode 2 additionally
* has every slot entry already freed/cleared)
*
* Fidelity note: we call xhci_hc_died() directly (resolved via a transient
* kprobe registration) instead of racing a real all-Fs MMIO read; this is
* behaviour-identical to the audited branch and to the kernel's own
* fake-irq precedent (usb_hcd_pci_remove -> usb_hcd_irq(0, hcd)).
* The lock is taken around the call exactly as the IRQ path would.
*/
#define pr_fmt(fmt) "XH4STALL: " 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/spinlock.h>
#include <linux/clocksource.h>
#include <linux/atomic.h>
static int mode = 1;
module_param(mode, int, 0600);
MODULE_PARM_DESC(mode, "1=stall scratchpad_free (devs freed, ptr stale), 2=stall xhci_rh_bw_cleanup (all slots cleared)");
static int stall_ms = 4000;
module_param(stall_ms, int, 0600);
MODULE_PARM_DESC(stall_ms, "busy-wait inside the chosen teardown point");
static int hits_max = 2;
module_param(hits_max, int, 0600);
/* offsets in struct xhci_hcd for this exact build (see xbits.ko printout) */
static int lock_off = -1;
module_param(lock_off, int, 0600);
MODULE_PARM_DESC(lock_off, "offsetof(struct xhci_hcd, lock) for spin_lock in the injected call");
static atomic_t hits = ATOMIC_INIT(0);
static atomic_t inject_flag = ATOMIC_INIT(0);
static void *g_xhci;
static void (*p_hc_died)(void *);
static int stall_pre(struct kprobe *p, struct pt_regs *regs)
{
int h;
if (stall_ms <= 0)
return 0;
h = atomic_inc_return(&hits);
if (hits_max > 0 && h > hits_max)
return 0;
g_xhci = (void *)regs->di; /* both targets take (struct xhci_hcd *) */
atomic_set(&inject_flag, 1);
pr_warn("stall #%d: %d ms at %s entry, xhci=%px - injector should fire\n",
h, stall_ms,
mode == 1 ? "scratchpad_free" : "xhci_rh_bw_cleanup", g_xhci);
{
u64 target = ktime_get_mono_fast_ns() + (u64)stall_ms * 1000000;
while (ktime_get_mono_fast_ns() < target)
cpu_relax();
}
atomic_set(&inject_flag, 0);
pr_warn("stall resumed, teardown continues\n");
return 0;
}
static struct kprobe kp_stall = {
.pre_handler = stall_pre,
};
static int injector(void *unused)
{
pr_info("injector kthread up (lock_off=%d)\n", lock_off);
while (!kthread_should_stop()) {
if (atomic_read(&inject_flag) && g_xhci && p_hc_died) {
unsigned long flags;
spinlock_t *lk;
pr_warn("INJECT: calling xhci_hc_died(%px) inside the stalled teardown window\n",
g_xhci);
if (lock_off >= 0) {
lk = (spinlock_t *)((char *)g_xhci + lock_off);
spin_lock_irqsave(lk, flags);
p_hc_died(g_xhci);
spin_unlock_irqrestore(lk, flags);
} else {
p_hc_died(g_xhci);
}
pr_warn("INJECT: xhci_hc_died returned\n");
break;
}
msleep(5);
}
return 0;
}
static struct task_struct *inj_thread;
static int __init xh4stall_init(void)
{
struct kprobe resolve = { .symbol_name = "xhci_hc_died" };
int ret;
/* transient registration only to resolve the (non-exported) address */
ret = register_kprobe(&resolve);
if (ret) {
pr_err("cannot resolve xhci_hc_died: %d\n", ret);
return ret;
}
p_hc_died = (void (*)(void *))resolve.addr;
unregister_kprobe(&resolve);
pr_info("resolved xhci_hc_died=%px\n", p_hc_died);
kp_stall.symbol_name = (mode == 1) ? "scratchpad_free"
: "xhci_rh_bw_cleanup";
ret = register_kprobe(&kp_stall);
if (ret) {
pr_err("register_kprobe(%s) failed: %d\n",
kp_stall.symbol_name, ret);
return ret;
}
inj_thread = kthread_run(injector, NULL, "xh4inj");
if (IS_ERR(inj_thread)) {
unregister_kprobe(&kp_stall);
return PTR_ERR(inj_thread);
}
pr_info("armed mode=%d stall_ms=%d on %s; run unbind now\n",
mode, stall_ms, kp_stall.symbol_name);
return 0;
}
static void __exit xh4stall_exit(void)
{
kthread_stop(inj_thread);
unregister_kprobe(&kp_stall);
pr_info("removed, stall hits=%d\n", atomic_read(&hits));
}
module_init(xh4stall_init);
module_exit(xh4stall_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("BUG-4 xhci_hc_died teardown-race controlled window");
^ permalink raw reply [flat|nested] only message in thread
only message in thread, other threads:[~2026-10-30 11:45 UTC | newest]
Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-30 11:44 usb: xhci: xhci_hc_died() traverses devs[] that mem_cleanup may already have freed -- NULL-deref/UAF in the free_irq lag window 徐晗翔
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®