QEMU xhci_complete leftover of CVE-2026-17588
QEMU v11.1.2 guards the xHCI timers. xhci_complete still DMA-writes the event ring into the doorbell BAR. Async usb-uas complete frees the live XHCITransfer. ASan heap-use-after-free.
- Name
- QEMU xhci_complete leftover of CVE-2026-17588
- Series
- QEMU 11.1.2 leftovers
- Phase
- 0
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- QEMU
- Affected
- QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb). q35 qemu-xhci.
- Published
- 16 Oct 2026
- Updated
- 16 Oct 2026
- Tags
- 0-day, qemu, xhci, uaf, local, cwe-416
The timers got the guard. Complete did not.
I am @abraxas_null. This is leftover #1 of the QEMU 11.1.2 leftovers series. Same pin as NVMe SGL BAR and COLO L4. The proof of concept is on GitHub: abraxas/qemu-xhci-complete-uaf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback. AGPLv3. Witness-only. No shells.
This is QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb). q35, qemu-xhci. No CVE yet. CWE-416. 8.2 High (CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H). Local (guest). Heap use-after-free of XHCITransfer in the QEMU process.
CVE-2026-17588 taught xHCI a lesson about writing the event ring while the TYPE_XHCI mem_reentrancy_guard is clear. Aim ERST at the doorbell BAR. DMA a 16-byte TRB. First dword 0 hits doorbell 0. xhci_process_commands runs a prepared CR_DISABLE_SLOT and frees the live transfer while the outer frame still holds it. The 11.1.2 patch engaged the guard in xhci_mfwrap_timer and xhci_ep_kick_timer. It did not engage it in xhci_complete.
EHCI already deferred this class onto a guarded bottom half in 2021. xHCI still writes DMA from the USB complete callback. That is the leftover.
Why QEMU
I wanted a hypervisor whose security model is written down and still has DMA that talks to itself. docs/system/security.rst is honest: the guest is untrusted, VNC/SPICE is untrusted, NBD and live migration are untrusted, user-supplied images are untrusted, PCI and USB passthrough are untrusted. TCG as a non-virtualization emulator is out. Guest-root asserts are hardening. QMP is operator control.
A leftover on this hunt had to be unpublished Critical or High on that model. Guest-to-host heap UAF qualifies. Guest-root assert does not. Unbounded host alloc does not. Dest-only migration abort without memory corruption does not.
I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. vhost-user backends are not a security boundary versus QEMU. Nested L2-to-L0 via PCI assignment is someone else's tree.
Pinning v11.1.2
Latest GA. Tag v11.1.2. VERSION says 11.1.2. HEAD of the tag is 4fc49f46dc95d4a27de2509e7fceb2931e91faeb, "Update version for 11.1.2 release". Debian sid was already 1:11.1.2+ds-3. That is the pin. Master is not the pin. An RC is not the pin. The clone is github.com/qemu/QEMU. GitLab qemu-project/qemu is the same tree.
v11.1.2 is also the tag that closed CVE-2026-17588. If you pin the release that shipped the timer guard, you get to ask which writers of the event ring the patch missed. That is a better question than "does xHCI still have reentrancy somewhere."
The skip list is half the hunt
Before you look for leftovers you write down what is already public so you do not rediscover a CVE with a press release. On 11.1.2 the interesting 2026 rows were already closed:
- CVE-2026-17588 xHCI timer reentrancy. Guarded in the two timer callbacks. This post is the other writer.
- CVE-2021-3750 EHCI DMA reentrancy. Closed:
ehci_async_complete_packetonly marksFINISHEDandqemu_bh_schedule;ehci_work_bhisqemu_bh_new_guarded. - CVE-2021-3713 UAS-object UAF. Closed: status is deferred to
usb_uas_send_status_bh, created guarded on the UASDeviceState. - CVE-2026-15705 / CVE-2026-63319 usb-redir bulk UAF and
max_packet_size==0. Closed on this pin. - CVE-2026-3890 OHCI infinite loop.
- CVE-2024-8354 USB assert.
- CVE-2026-93834 9pfs FID path race.
- CVE-2026-66020 / CVE-2026-3886 virtio-gpu blob / 2d.
- CVE-2026-77913 VGA text-mode OOB.
- CVE-2026-16457 packed vring empty.
- CVE-2026-16271 qxl stride.
- CVE-2026-81627 VAPIC. CVE-2026-12080 qemu-ga.
Older postponed NULLs and dest-abort-only rows stay on the skip list. I did not re-file any of that.
Guest-root asserts on UAS invalid tags, unbounded RNDIS rndis_queue_response, and the combined-packet 1 MiB cap are hardening. They are not this class.
Twenty-four surfaces, three leftovers
I split the tree. USB. NVMe. virtio core, net, blk/scsi, gpu, misc. 9pfs. VGA. VNC/SPICE. e1000/igb. COLO / TAP / socket. NBD. migration / postcopy. scsi HBA. SD/IDE/floppy. slirp glue. qemu-ga. ACPI/SMBIOS. CXL/UEFI/TPM. audio/chardev. fw_cfg/VAPIC. PCI/IOMMU. Chains across two devices.
Most of that is QEMU doing what QEMU said it would do.
Virtio packed vring / queue-size / inverted IOVA stayed Medium, dest abort, or already public. Virtio-net RSC/RSS/VLAN is closed. 9p FID/xattr/Treaddir is closed. Balloon BH is closed. e1000/igb migrated rx_desc_len is recomputed in post_load. Slirp is out of tree. VNC/SPICE without a second sink did not give me a High. qemu-ga is CVE-2026-12080. ACPI ERST, SD/IDE, CXL mailbox, UEFI size helpers, TPM TIS FIFO: empty at this bar.
Three leftovers labbed SUCCESS on the pin. This one is USB. Sibling NVMe SGL BAR is the incomplete CVE-2021-3929 iomem check: start-in-BAR0 is blocked, a spanning SGL is not. Sibling COLO L4 is parse_packet_early never requiring an L4 header; not default, needs the rewriter. Five other nominated leftovers failed the lab (VGA skipped-resize, NBD cookie did not abort, virtio-scsi TMF ran after emulate, postcopy needs userfaultfd, virtio-gpu restore never came up for QMP). I do not file FAIL leftovers. This post is the one that ASan named.
The leftover is the complete callback
CVE-2026-17588 is doorbell re-entry from an event-ring DMA. The 11.1.2 fix looks like this in the timers:
static void xhci_mfwrap_timer(void *opaque)
{
XHCIState *xhci = opaque;
XHCIEvent wrap = { ER_MFINDEX_WRAP, CC_SUCCESS };
MemReentrancyGuard *guard = &xhci->parent.mem_reentrancy_guard;
assert(!guard->engaged_in_io);
guard->engaged_in_io = true;
xhci_event(xhci, &wrap, 0);
xhci_mfwrap_update(xhci);
guard->engaged_in_io = false;
}xhci_ep_kick_timer does the same around xhci_kick_epctx. HID NAK retry is that timer. It is closed.
xhci_complete is the USB port .complete op. It is not a timer. It is not MMIO. After a bulk transfer returns USB_RET_ASYNC, doorbell MMIO returns and the TYPE_XHCI guard drops. Later the device calls usb_packet_complete. Then:
static void xhci_complete(USBPort *port, USBPacket *packet)
{
XHCITransfer *xfer = container_of(packet, XHCITransfer, packet);
if (packet->status == USB_RET_REMOVE_FROM_QUEUE) {
xhci_ep_nuke_one_xfer(xfer, 0);
return;
}
xhci_try_complete_packet(xfer);
xhci_kick_epctx(xfer->epctx, xfer->streamid);
if (xfer->complete) {
xhci_ep_free_xfer(xfer);
}
}No guard. Success unmaps, then xhci_xfer_report -> xhci_event -> xhci_write_event -> dma_memory_write of a 16-byte TRB. The doorbell MemoryRegion max_access is 4, so that write is four dword hits. xhci_doorbell_write on reg==0 && val==0 calls xhci_process_commands.
An Event Data TRB with parameter==0 and IOC makes xhci_xfer_report copy that zero into event.ptr. First dword of the event TRB is 0. Doorbell 0. Command ring already holds CR_DISABLE_SLOT. xhci_disable_ep -> xhci_ep_nuke_xfers -> xhci_ep_free_xfer g_free(xfer) while running_async is already 0, so nuke does not even need the async flag. Then xhci_complete continues: xhci_kick_epctx(xfer->epctx, ...) and maybe xhci_ep_free_xfer(xfer) again.
kick_active only skips a nested kick of the same endpoint. It does not stop xhci_disable_ep from freeing the live xfer.
EHCI does not write back from the USB complete callback. It marks the packet finished and schedules a TYPE_EHCI-guarded BH. xHCI did not copy that pattern. It copied the DMA write and left the guard on the timers.
UAS is the clean bulk partner. usb_uas_queue_status schedules status_bh so completion is after xHCI MMIO. That BH is guarded with the UAS DeviceState, not TYPE_XHCI. UAS has no MMIO BAR. usb_packet_complete therefore enters xhci_complete with the xHCI guard clear. Same for usb_uas_scsi_command_complete from block AIO. Same sink from usb-storage SCSI complete, usb-redir chardev, and usb-host libusb. q35 ships qemu-xhci. You do not need a weird machine type.
The 2021 UAS-object UAF is closed. Status BH plus usb_packet_map only mapping RAM stops that n-day. The leftover is the xHCI object via event-ring DMA, not a 2021 bug of the UAS object. Treating the UAS BH as enough is how you miss it.
How the eight steps actually run
- Guest points the event-ring segment table at the doorbell BAR.
xhci_er_resettakes whatever GPA you put in ERST. - Guest puts an Event Data TRB with
parameter==0and IOC, or a transfer TRB at GPA 0.xhci_xfer_reportcopies that intoevent.ptr. - Guest starts a bulk IN/OUT that the device returns
USB_RET_ASYNC.xhci_try_complete_packetsetsrunning_async. Doorbell MMIO returns. TYPE_XHCI guard drops. - Later the device calls
usb_packet_complete(hw/usb/core.c-> port.complete). xhci_completeathw/usb/hcd-xhci.c:3282-3295runs unguarded.dma_memory_writeof the event TRB lands on doorbell.CR_DISABLE_SLOTfrees the liveXHCITransfer/XHCIEPContext.xhci_completecontinues. Use-after-free. Double-free if it then frees again.
That is the whole primitive. No guest userspace. No HID. No timer.
The lab is qtest, not a kernel
I wanted a real guest kernel for about an hour. Then I remembered the leftover is MMIO plus a USB complete callback, and qtest already speaks both. A Linux xHCI driver would aim ERST at RAM because that is what the spec says. A hostile guest kernel aims ERST at the doorbell because that is what the leftover says. qtest is the hostile guest. It is also the thing that does not take twenty minutes to boot under ASan.
The image is debian sid, ASan qemu-system-x86_64, --target-list=x86_64-softmmu. Pin cloned from github.com/qemu/QEMU tag v11.1.2. Machine is q35, -accel qtest, -nodefaults, qemu-xhci at PCI 04.0, usb-uas plus scsi-hd on null-co://. qtest on 127.0.0.1:18700. QMP on 127.0.0.1:18701. BAR GPA in the lab is 0xE0000000. Traces on doorbell write, xfer async/success, fetch TRB, slot disable, UAS command.
The client enumerates Red Hat xHCI on PCI, maps BAR0, reset, address device, SET_CONFIGURATION, configure bulk command-OUT and status-IN. Queue a status-IN with a Normal TRB plus Event Data IOC, confirm it stays USB_RET_ASYNC (no transfer event). Plant ERST at the doorbell GPA. Queue CR_DISABLE_SLOT on the command ring without ringing doorbell 0. Kick a UAS TEST UNIT READY on command-OUT. UAS status BH completes the outstanding status-IN. xhci_complete DMA-writes the event onto doorbell 0. Command ring runs. ASan reports heap-use-after-free of XHCITransfer. Trace shows CR_DISABLE_SLOT fetched from that re-entry.
SUCCESS qemu-xhci-complete-uaf how=ERROR: AddressSanitizer: heap-use-after-free erst=doorbell async=1 success=1 disable-slot-fetch=1 QEMU-XHCI-COMPLETE-UAF-WITNESSReplay: cd lab && ./run.sh. Loopback. Witness-only. No RIP payload. No guest-to-host shell.
Wrong turns, in the order I made them
Treating the UAS status BH as enough. CVE-2021-3713 is closed. The status bottom half is qemu_bh_new_guarded on the UAS object. That stops nested MMIO of UAS. UAS has no BAR. The DMA write is xHCI writing its own doorbell. Different DeviceState. Different guard.
HID NAK. NAK retry is xhci_ep_kick_timer. That is exactly the callback 11.1.2 already wrapped. Sitting on a keyboard and waiting for the kick timer is sitting on the patched path.
Wanting a real guest kernel. A stock Linux xHCI driver will not aim ERST at the doorbell. The leftover is not "does the in-tree driver do this." The leftover is "can a guest that controls TRB GPAs do this." qtest is that guest. Booting Debian under ASan to watch the driver do the honest thing would have been a long way to a FAIL.
Waiting on the kick after complete. xhci_complete already reports before xhci_kick_epctx. The UAF is the report. The later kick is just the use. You do not need a second transfer.
Calling this a 2021 n-day. usb-redir and usb-host still reach xhci_complete. Their own memory-corruption CVEs are closed on this pin. Remaining usb_packet_complete into xHCI is this leftover, not a second redir/host primitive.
What an attacker can do
Who: untrusted guest kernel (or USB passthrough peer) on qemu-xhci plus any bulk device that completes USB_RET_ASYNC later. Guest-to-host. QEMU userspace heap. q35 default has the controller. Recon is enumerating qemu-xhci. Nothing else.
Integrity (lab-proven)
| Method | Sink | Privilege |
|---|---|---|
| Guest ERST / ERDP at doorbell BAR, then async UAS complete | xhci_complete hw/usb/hcd-xhci.c:3282-3295 -> xhci_write_event DMA |
ASan heap-use-after-free of XHCITransfer while the outer complete still holds it. CR_DISABLE_SLOT fetched from the doorbell re-entry. |
Same path, xhci_ep_nuke_xfers / xhci_ep_free_xfer |
g_free(xfer) |
Free of the live transfer and endpoint context. Double-free if complete then frees again. |
Availability (lab-proven)
QEMU process abort under ASan. Hypervisor gone. Guests on that process go with it.
Integrity (inferred)
Reuse of the freed XHCITransfer / XHCIEPContext in the QEMU process. Guest-to-host. The lab does not demonstrate RIP control or a guest-to-host shell. I am not going to pretend ASan abort is a shell.
Recon
None required beyond enumerating qemu-xhci. q35 default has it. No extra device, no VNC, no migration stream.
Adjacent surface that stays closed
- CVE-2026-17588 timer path:
xhci_mfwrap_timerandxhci_ep_kick_timersetxhci->parent.mem_reentrancy_guard. Closed on this pin. - EHCI async complete: marks FINISHED and schedules a guarded BH. Does not write the event ring from the USB complete callback.
- UAS-object UAF (CVE-2021-3713 class): status BH is guarded on the UAS
DeviceState. The leftover is the xHCI object via event-ring DMA, not a 2021 n-day of the UAS object. - HID NAK retry: that is the kick timer, now guarded.
- Guest-root asserts and unbounded RNDIS alloc: hardening, not this class.
- OHCI/UHCI: UHCI BH is guarded. OHCI inf-loop is CVE-2026-3890. No unpublished Crit/High leftover found on those HCDs on this pin.
The fix
Engage xhci->parent.mem_reentrancy_guard for the whole of xhci_complete, the same way the two timers already do. Or defer xhci_try_complete_packet / xhci_kick_epctx onto a TYPE_XHCI-guarded BH, matching EHCI. Either way, DMA-writing the event ring must not run with the guard clear. Nested doorbell then sees engaged_in_io and the 16-byte TRB does not become xhci_process_commands on the live transfer.
Until then, a guest that controls xHCI TRB GPAs and has an async bulk partner can UAF the hypervisor process. q35 ships the controller. usb-uas, usb-storage, usb-redir, and usb-host all complete later.
References
- Proof of concept: abraxas/qemu-xhci-complete-uaf · qemu-xhci-complete-uaf-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - Series: QEMU 11.1.2 leftovers · same pin qemu-nvme-sgl-bar · qemu-colo-l4-oob
- Tree: qemu/QEMU v11.1.2 (
4fc49f46dc95d4a27de2509e7fceb2931e91faeb) ·xhci_complete - Nearby patched (not this path): CVE-2026-17588 xHCI timer reentrancy · CVE-2021-3750 EHCI · CVE-2021-3713 UAS
- CWE-416 · CVSS 8.2
- GNU Affero GPL v3.0. Loopback lab only. No warranty.
github.com/abraxas · @abraxas_null · abraxaslabs.tech · abraxas.null@proton.me