usbehci.sys EHCI_RH_UsbprivRootPortStatus out of bounds read write
EHCI defines a USB 2.0 host controller with up to 15 downstream ports, each
with a 32-bit status/control register (connect status, enable, suspend,
reset, over-current, plus the change bits that latch until software clears
them). Windows keeps a software mirror of those words in the miniport’s
device extension as Extension->PortStatusRegs so root-hub code can read
and consume status without round-tripping the hardware on every query.
When the hub driver asks “what is on port N?”, the request carries
ConnectionIndex — the port number — and the EHCI routine
EHCI_RH_UsbprivRootPortStatus reads the mirror at slot N, clears the
consumed status bits with v & 0xFFFFFF15, writes the value back, then
translates the bits the hub driver cares about into a USB port-status word
and stores that in the request. The invariant is that ConnectionIndex
is a 1-based port number, the mirror has Extension->NumberOfPorts slots,
and anything outside [1, NumberOfPorts] is meaningless. The hardware
will never signal such a port, but a caller can put any 16-bit value in
the field, and the function used it straight into the index:
/* EHCI_RH_UsbprivRootPortStatus -- vulnerable build
* (arg1) Extension (arg2) InputBufferLength (arg3) Request */
if (InputBufferLength < 8) /* body runs only when arg2 >= 8 */
return STATUS_INVALID_PARAMETER;
EHCI_PORT_REGS *PortStatusRegs = Extension->PortStatusRegs; /* +0xC8 */
if (PortStatusRegs == NULL)
return 0;
USHORT port = Request->ConnectionIndex; /* Request +0x04 (low word) */
/* ---- old path: 'port' is never compared to any bound before use ---- */
ULONG v = PortStatusRegs->PortStatus[port]; /* OOB READ */
RhTrace(Extension, port, v); /* data_140016440 */
ULONG bit = 1UL << ((UCHAR)port - 1); /* 1-based port -> port bit */
if (v & 0x40) /* PORTSC.PortReset active */
RhSignal(Extension, 0x32); /* data_140016460 */
PortStatusRegs->PortStatus[port] = v & 0xFFFFFF15; /* OOB WRITE */
v = PortStatusRegs->PortStatus[port]; /* re-read after clear */
Extension->SuspendChangeMap |= bit; /* +0x138 */
RhTrace(Extension, port, v);
/* translate the consumed PORTSC word into the USB hub-status the hub driver expects */
ULONG hub = ((((v >> 1) & 0x40) | (v & 0x1100)) >> 3 | (v & 0x14)) >> 1
| ((v & 0x2000) << 2) | (v & 1) | 0x400;
Request->Status = hub; /* Request +0x00 */
if (!(hub & 0x4) && !(v & 0x40)) { /* not suspended, not resetting */
ULONG pending = Extension->SuspendPendingMap;/* +0x148 */
if (bit & pending)
Extension->SuspendChangeMap |= bit; /* +0x138 */
Extension->SuspendPendingMap = pending & ~bit;/* +0x148 */
}
/* fold further change bits out of v and the extension maps into the status word */
hub ^= ((v << 15) ^ hub) & 0x10000; /* C_PORT_CONNECTION from v */
hub ^= ((v << 14) ^ hub) & 0x20000; /* C_PORT_ENABLE from v */
hub ^= ((v << 14) ^ hub) & 0x80000; /* C_PORT_OVERCURRENT from v */
if (Extension->ResetChangeMap & bit) hub |= 0x100000; /* +0x134 -> C_PORT_RESET */
if (Extension->ConnectChangeMap & bit) hub |= 0x10000; /* +0x13C -> C_PORT_CONNECTION */
if (Extension->SuspendChangeMap & bit) hub |= 0x40000; /* +0x138 -> C_PORT_SUSPEND */
Request->Status = hub;
RhTrace(Extension, port, hub, v);
return 0;
The two checks at the top only guard against a short buffer and a NULL
mirror pointer; neither is a port-number bound. With ConnectionIndex a
16-bit field, the byte offset reaches up to 0xFFFF * 4 + 0x40 = 0x4003C
(~256 KiB) past the start of the mirror, far beyond any pool allocation,
so the access walks off the mirror into whatever the host placed next to
it. The mask is the routine’s normal consume — for the right port it
clears bits 1, 3, 5, 6 and 7 (port-enabled, over-current-active and the
reserved trio) so the next query sees a clean status; applied to
out-of-range memory it performs the same clear at an attacker-chosen
offset, dropping those five bits of whatever dword it lands on.
The legitimate hub driver always passes a valid port number, but the
request is reachable from any caller that can open the EHCI root-hub
device. Two attacker models reach it: a malicious USB device wired to a
physical port (the classic evil-USB / BadUSB model — the device drives
its own descriptors and can induce the hub driver to issue a status query
whose port number is derived from those descriptors), and a local
kernel-mode caller that crafts an input buffer with an arbitrary
ConnectionIndex through the right device ACL. The “register” framing of
the array made the absence of a bound feel reasonable — it is just
ULONG[N] in kernel pool, and an untrusted index into it is no safer
than an untrusted index into any other buffer.
The primitive is a read-plus-write at an attacker-chosen kernel offset.
The read word is not handed back verbatim — the routine translates
selected bits of it into the USB port-status the hub driver expects and
stores that in Request->Status — so each call leaks fragments of
whatever sits next to the mirror rather than a clean dword, but enough to
pick out a pointer or a size for a second step. The write applies
& 0xFFFFFF15, clearing bits 1, 3, 5, 6 and 7 of the target dword: a
small reference count whose only set bit is bit 1 (a count of two) drops
to zero and the owner UAFs; a size or length field loses up to a couple
of hundred bytes and the buffer it guards overshoots on the next access;
a live function pointer or list link bugchecks the box. The layout of the
mirror is only weakly attacker-influenced (the controller miniport is
allocated at boot), but on a busy system the neighbouring pool holds
other driver extensions, registry key-control blocks, and small kernel
objects whose corruption is a standard EoP primitive.
The patch
/* EHCI_RH_UsbprivRootPortStatus -- patched
* identical prologue (InputBufferLength >= 8, PortStatusRegs non-NULL), then: */
USHORT port = Request->ConnectionIndex; /* Request +0x04 (low word) */
if (port == 0 || port > Extension->NumberOfPorts) /* +0x150 -- the added bound */
return 5; /* STATUS_INVALID_PARAMETER */
/* with 'port' now provably in [1, NumberOfPorts], the rest of the routine is
* unchanged: the read, the 0xFFFFFF15 mask write-back, the extension-map
* updates and the status pack all land inside the mirror. */
ULONG v = PortStatusRegs->PortStatus[port]; /* in-bounds READ */
RhTrace(Extension, port, hub, v);
return 0;
port == 0 is rejected because the port-status words are laid out
one-based — port * 4 + 0x40 maps port 1 to the first register, so port
0 lands on the byte just before the array, and 1 << (port - 1) would
build a nonsense bit; port > NumberOfPorts rejects anything past the
controller’s real port count. Extension->NumberOfPorts (the field at
+0x150) is the bound that was always available in the same extension;
the patch wires the one comparison to it that should have been there from
the start. The shape to remember is narrower than “USB needs bounds
checks”: an index into a register mirror array is not safer than an index
into a buffer, and any handler that does regs[port] = regs[port] & mask
through an untrusted index is a read-plus-write primitive, not just a
read.
Attack path
flowchart TD
A["malicious USB device or local caller reaches the EHCI root hub"] --> B["request ConnectionIndex = N, with N greater than NumberOfPorts"]
B --> C["EHCI_RH_UsbprivRootPortStatus runs with no bound on N"]
C --> D["PortStatusRegs PortStatus N read out of bounds, then same word written back ANDed with 0xFFFFFF15"]
D --> E["bits of adjacent kernel memory leaked into the request status, target dword corrupted by the mask for UAF or pool overlap to EoP"]
Reachability is the same as every USB-stack EoP of the last fifteen years — physical access to a port or local access to the host-controller device object — and the fix is the single bound check that closes the array.