mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* 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®