VirtualBox USB and OHCI overview

This is the story of a bug I found in VirtualBox USB. It is a double free in the OHCI USB path. Oracle fixed it in the October 2026 CPU. The CVE is CVE-2026-101599. Oracle credited me, Ziad Salah.

You do not need to know VirtualBox USB to read this. We will start from zero and build the picture together.

The GIFs in this post are short diagrams, not screen recordings. They follow the text.

What is this about

VirtualBox lets a guest use USB devices. The guest sees a fake USB controller and fake USB devices. The host sees normal processes. The guest should not be able to break the host.

I was reading the USB code and found a place where a cancel operation can free a USB request twice. The second free is the bug. A double free in the VM process can crash the VM. With heap grooming it can become worse. That is the class of a VM escape.

This post explains the USB path, the state machine, the exact code, the bug, and why I did not publish a working exploit.

A diagram of guest, controller, VUSB, device

What is OHCI

OHCI is the Open Host Controller Interface. It is a standard for USB 1.1 host controllers. A host controller is the hardware that talks to USB devices.

A USB device has endpoints. An endpoint is an addressable channel on the device. The guest driver builds transfers for endpoints.

OHCI uses three simple ideas.

  • MMIO registers. The guest writes to memory addresses that are really device registers. Writing a register can start the controller, stop it, reset it, or change a port.
  • ED and TD lists. An ED is an Endpoint Descriptor. It describes an endpoint. A TD is a Transfer Descriptor. It describes one small transfer. The guest puts EDs and TDs in memory and links them in lists.
  • HCCA. The Host Controller Communications Area is a block of memory the controller uses for frame numbers and the done queue.

The controller walks the lists every frame. A frame is about one millisecond. When a TD is done, the controller writes the status back and adds the TD to the done queue. Then it raises an interrupt.

The root hub is part of the controller. It has ports. A device connects to a port. The guest resets the port and enumerates the device.

OHCI frame loop

An ED points to an endpoint and its TD list. A TD carries a buffer address, a length, and a status field. The controller processes the list each frame, writes completion status into the TD, and raises an interrupt.

How OHCI works in VirtualBox

VirtualBox does not have real OHCI hardware. It emulates the controller in src/VBox/Devices/USB/DevOHCI.cpp.

The path looks like this.

  1. The guest driver writes OHCI registers and builds ED and TD lists in guest memory.
  2. The OHCI emulation walks the lists. For each TD it creates a VUSB URB.
  3. VUSB is the middle layer. It is in VUSBDevice.cpp, VUSBUrb.cpp, and DrvVUSBRootHub.cpp.
  4. VUSB gives the URB to a device model or to a host USB proxy.
  5. The device finishes the URB. VUSB marks it REAPED and tells the controller.
  6. The controller writes the TD status back to guest memory and raises an interrupt.
  7. VUSB frees the URB.

The guest can also cancel a transfer. It can unlink a TD, disable an endpoint, reset a port, or reset the controller. The controller then asks VUSB to cancel the affected URBs.

VUSB keeps a list of in flight URBs. The list is called LstAsyncUrbs. Cancellation and completion both touch this list.

A URB moves through states.

  • ALLOCATED. The URB is in the pool and not in use.
  • IN_FLIGHT. The device is working on it.
  • REAPED. The device finished it. The completion path owns it now.
  • CANCELLED. The guest cancelled it. It waits to be reaped.
  • FREE. It is back in the pool.

URB states

Most of the work happens on one thread. VirtualBox calls it the USB device I/O thread. The controller may run on the emulation thread, but VUSB marshals completion and cancellation work to the I/O thread.

That thread detail is the key to the bug.

The code and the states

Here are the files that matter.

  • src/VBox/Devices/USB/DevOHCI.cpp is the OHCI emulation. It has ohciMmioWrite, ohciR3DoReset, and calls to pfnCancelAllUrbs.
  • src/VBox/Devices/USB/VUSBDevice.cpp has the device I/O thread and vusbDevCancelAllUrbsWorker.
  • src/VBox/Devices/USB/VUSBUrb.cpp has the URB state machine, vusbUrbRipe, vusbUrbCompletion, and vusbUrbCancelWorker.
  • src/VBox/Devices/USB/DrvVUSBRootHub.cpp has the root hub workers, vusbRhCancelAllUrbs and vusbRhCancelAllUrbsWorker.
  • src/VBox/Devices/USB/VUSBInternal.h has vusbUrbUnlink, which removes a URB from the list.

The relevant part of the completion path is short.

pUrb->enmState = VUSBURBSTATE_REAPED;
vusbUrbCompletion(pUrb);

vusbUrbRipe marks the URB REAPED and calls vusbUrbCompletion.

vusbUrbCompletion removes the URB from the list and calls the root hub completion.

static void vusbUrbCompletion(PVUSBURB pUrb)
{
    ASMAtomicDecU32(&pUrb->pVUsb->pDev->aPipes[pUrb->EndPt].async);

    if (pUrb->enmState == VUSBURBSTATE_REAPED)
        vusbUrbUnlink(pUrb);

    vusbUrbCompletionRh(pUrb);
}

The root hub completion calls pfnXferCompletion, which is the controller callback. After that, if the URB is still REAPED, it frees the URB.

The cancellation path is the other side.

DECLHIDDEN(int) vusbUrbCancelWorker(PVUSBURB pUrb, CANCELMODE enmMode)
{
    if (pUrb->enmState == VUSBURBSTATE_IN_FLIGHT)
    {
        pUrb->enmState = VUSBURBSTATE_CANCELLED;
        pUrb->pVUsb->pDev->pUsbIns->pReg->pfnUrbCancel(...);
        pUrb->enmStatus = VUSBSTATUS_CRC;
        vusbUrbCompletion(pUrb);
    }
    else if (pUrb->enmState == VUSBURBSTATE_REAPED)
    {
        pUrb->enmStatus = VUSBSTATUS_CRC;
        vusbUrbCompletion(pUrb);
    }
    else
    {
        /* CANCELLED. Just set the status. */
    }

    return VINF_SUCCESS;
}

The cancel all worker is the dangerous part.

static DECLCALLBACK(int) vusbDevCancelAllUrbsWorker(PVUSBDEV pDev, bool fDetaching)
{
    PVUSBURBVUSB pVUsbUrb, pVUsbUrbNext;

    RTListForEachSafe(&pDev->LstAsyncUrbs, pVUsbUrb, pVUsbUrbNext, VUSBURBVUSBINT, NdLst)
    {
        PVUSBURB pUrb = pVUsbUrb->pUrb;
        int rc = vusbUrbCancelWorker(pUrb, CANCELMODE_FAIL);
        AssertRC(rc);
    }
    ...
}

RTListForEachSafe saves the next pointer before the loop body. That is safe if the body removes the current URB. It is not safe if the body removes the next URB. That is the whole bug.

The cached next pointer

The I/O thread dispatch is the other half.

DECLHIDDEN(int) vusbDevIoThreadExecSync(PVUSBDEV pDev, PFNRT pfnFunction, unsigned cArgs, ...)
{
    /* Queue the request. */
    rc = RTReqQueueCallV(pDev->hReqQueueSync, ...);

    /* If we are already on the I/O thread, process it inline. */
    if (pDev->hUrbIoThread == RTThreadSelf() && (fFlags & SYNC))
        RTReqQueueProcess(pDev->hReqQueueSync, 0);
    else
        vusbDevUrbIoThreadWakeup(pDev);

    /* If queued, wait for the I/O thread. */
    if (rc == VERR_TIMEOUT && (fFlags & SYNC))
        rc = RTReqWait(hReq, RT_INDEFINITE_WAIT);
}

If the caller is the I/O thread, the request does not wait. It runs immediately, on the same stack. That is the reentrancy.

The bug

The bug is a DMA write that becomes an MMIO write. In this post I call it the DMA to MMIO bug.

A guest controls the buffer and status addresses in a TD. If the guest points those addresses at the OHCI MMIO page, then the controller completion write is not a normal memory write. It lands on an OHCI register.

The reset register is the dangerous one. A write there runs ohciMmioWrite. That calls ohciR3DoReset. That calls pfnCancelAllUrbs.

Here is the full picture.

Guest submits URB A and URB B.
B is REAPED but still in LstAsyncUrbs.
A cancel-all worker starts on the USB I/O thread.
It saves pVUsbUrbNext = B.
It calls vusbUrbCancelWorker(A).
A completion writes DMA to the OHCI MMIO reset register.
ohciMmioWrite runs on the I/O thread.
ohciR3DoReset runs.
pfnCancelAllUrbs runs.
vusbDevIoThreadExecSync sees the I/O thread.
It runs RTReqQueueProcess inline.
The nested cancel-all walks the same list.
It reaches B.
B is REAPED, so the nested call frees B and unlinks B.
The nested call returns.
The outer walk resumes with pVUsbUrbNext = B.
B is already freed.
The outer call completes or frees B again.
Double free.

The sequence above has one condition that decides everything. B must be REAPED when the nested cancel reaches it. Other states are possible, and most of them do not double free.

B state at nested cancel Nested cancel does Outer walk does Result
IN_FLIGHT Marks B CANCELLED, calls the device cancel, no free Sees CANCELLED, sets status, no completion No double free. The reaper frees B once.
REAPED Unlinks B and frees B Uses the cached B. If the memory still reads REAPED, it frees B again Double free. This is the bug.
CANCELLED Sets status, no completion Sees CANCELLED, sets status, no completion No double free. The reaper frees B once.
Already freed or reused The cached pointer is already stale Reads freed or reused memory Use after free. A crash is possible. The result depends on the reused data.

So the reentrancy alone is not enough. The attacker must also land B in the REAPED state at that moment. If B is in flight or already cancelled, the nested cancel does not free it, and the outer walk does nothing. The double free needs the REAPED state. That is why the window and the state both matter.

DMA write to MMIO reset

The nested cancel-all

Why can I not just write the MMIO register myself?

I can. The guest can write the reset register directly. But that write comes from the guest CPU. In VirtualBox the guest CPU is the emulation thread, not the USB I/O thread. vusbDevIoThreadExecSync sees a different thread. It queues the cancel and waits. The cancel runs later, after the outer walk is gone. There is no nesting and no stale next pointer.

The DMA write is different because the completion path runs on the USB I/O thread. The same vusbDevIoThreadExecSync sees the I/O thread. It processes the cancel inline. That is the reentrancy.

This is also why the bug is local and why it needs timing. The guest has to make the reset happen at the exact moment a cancel-all worker is walking the list, with the next URB in the REAPED state.

The list walk reentrancy is the bug Oracle confirmed and fixed.

The fix in VirtualBox 7.2.22 changed the design. It removed vusbUrbCancel and vusbUrbCancelAsync. It removed vusbRhCancelUrbsEp and pfnCancelUrbsEp. It changed ohciR3CancelOrphanedURBs to collect endpoints and abort each endpoint once. It unlocked the OHCI critical section before calling pfnAbortEpByAddr, so the abort can send a synchronous request to the I/O thread without holding the lock.

The patch removed the path that could reenter the walk.

References

  • Oracle Critical Patch Update, October 2026. https://www.oracle.com/security-alerts/cpuoct2026.html
  • VirtualBox changelog, 7.2.22. https://www.virtualbox.org/wiki/Changelog
  • VirtualBox USB source. https://www.virtualbox.org/svn/vbox/trunk/src/VBox/Devices/USB/
  • VUSBDevice.cpp, VUSBUrb.cpp, VUSBInternal.h, DrvVUSBRootHub.cpp, and DevOHCI.cpp in the source tree above.

Thanks for reading. If you find a better trigger, I would like to hear about it.