storport.sys PortPassThroughExValidateNormalizedRequest integer overflow

IOCTL_SCSI_PASS_THROUGH* lets a user-mode caller send an arbitrary SCSI command buffer down to a storage device — firmware updaters, management tools, diagnostics, backup products all sit on top of it. The cost of that flexibility is that the user-supplied descriptor names offsets and lengths into a data buffer the kernel will read and write on the caller’s behalf, so kernel safety rests on validating those offset/length pairs against the real buffer size before any data moves. After the pass-through layer normalises a request into a single descriptor with a handful of (offset, length) ranges, PortPassThroughExValidateNormalizedRequest is the function that runs that validation: it computes the one-past-the-end byte each range describes and compares it against the real data-buffer size, the user-supplied length, and the SRB extension offset. It is the choke point between an attacker-controlled descriptor and kernel buffer arithmetic, and when its own arithmetic wraps, every downstream consumer that trusts its verdict inherits the hole.

The descriptor carries several 32-bit offset/length pairs — DataOffset (/* +0x18 */) paired with DataLength (/* +0xc */), the SRB-extension pair SrbExtOffset (/* +0x1c */) and SrbExtLength (/* +0x11 */), plus the OUT-data pair OutDataLength (/* +0x20 */) / OutDataOffset (/* +0x28 */) and the IN-data pair InDataLength (/* +0x24 */) / InDataOffset (/* +0x30 */). On a 64-bit kernel the buffer these describe lives in 64-bit address space, so the end-pointer the validator builds has to be a 64-bit quantity. The fields it adds are 32-bit, and the order of operations in the vulnerable build is the wrong order:

// PortPassThroughExValidateNormalizedRequest — vulnerable build
// The primary data range is summed in 32-bit and the result is what every
// downstream bound check compares. Shown: the primary add (DataOffset +
// DataLength) and the SRB-extension watermark. OutDataLength/OutDataOffset
// and InDataLength/InDataOffset are gated on the same end watermark.

uint32_t end = (uint32_t)(Request->DataOffset    /* +0x18 */
                        + Request->DataLength);  /* +0xc  */ // 32-bit add, carry past bit 31 dropped, no wrap check on this sum

uint32_t srbOff = Request->SrbExtOffset;         /* +0x1c */
if (srbOff >= end)
    end = (uint32_t)(Request->SrbExtLength       /* +0x11 */
                   + srbOff);                              // 32-bit watermark update

if (end <= BufLimitHi   /* *(arg2->BufDesc + 0x10) */
 && end <= BufLimitLo   /* *(arg2->BufDesc + 0x08) */
 && end >= srbOff)                                        // wrap guard only catches the SRB add, not the data add
    goto accept;                                          // every compare runs on the wrapped, small end
return STATUS_INVALID_PARAMETER;

DataOffset and DataLength are both 32-bit, so the primary sum is a 32-bit quantity: any carry out of bit 31 is discarded before the validator ever reads the result, and unlike the SRB-extension add that follows it, this sum has no end >= addend guard of its own. Pick the pair so they add to just over 0xffffffff0xfffffff0 for DataOffset plus 0x20 for DataLength wraps the sum to 0x10 — and the bound checks compare that small, wrapped value against the real buffer sizes and pass. The SRB add itself is guarded by end >= srbOff, but that guard runs against a watermark the primary add has already poisoned; the OUT/IN pairs at OutDataOffset / InDataOffset are then entered or skipped by comparing their offsets to the same wrapped watermark, so a request that should be rejected is accepted with the original attacker-chosen DataOffset and DataLength still live in the descriptor.

The trigger is direct, no race required. The caller opens a volume or storage adapter handle (ACL-dependent; non-admins sometimes can, admins always can) and sends IOCTL_SCSI_PASS_THROUGH* with a crafted descriptor: pick DataOffset = 0xfffffff0, DataLength = 0x20, and the 32-bit sum wraps to 0x10. The bound checks pass, the validator returns success, and the data path runs the data-in or data-out copy using the original DataOffset and DataLength — both of which describe a region far outside the buffer. The kernel reads or writes past the allocation. Depending on the direction, DATA_OUT gives a pool overflow with attacker-controlled content into adjacent pool, and DATA_IN gives a kernel information leak back into the caller’s buffer. The storport/NVMe pool buckets are relatively narrow, so even on a kernel with pool guard pages and type isolation the overflow stays a strong EoP primitive, and an admin turning a wrapped end-pointer into an arbitrary kernel read/write is exactly the elevation shape.

The patch

The fix inverts the order: zero-extend each operand to 64-bit first, add in 64-bit, then check for wrap explicitly. The primary data range and the SRB-extension range both get the treatment, and the OUT/IN pairs (OutDataLength / OutDataOffset, InDataLength / InDataOffset) inherit the same 64-bit add and end >= addend guard:

// PortPassThroughExValidateNormalizedRequest — patched
// Same pairs, but every sum is 64-bit and is followed by an explicit wrap
// check: an honest sum of two non-negative numbers cannot be smaller than
// either addend.

uint64_t end = (uint64_t)Request->DataOffset    /* +0x18 */
             + (uint64_t)Request->DataLength;   /* +0xc  */ // 64-bit add, carry kept

uint64_t srbOff = Request->SrbExtOffset;        /* +0x1c */
if (srbOff >= end)
    end = (uint64_t)Request->SrbExtLength       /* +0x11 */
        + srbOff;                                         // 64-bit watermark update

if (end >= srbOff       // wrap detector: a sum below its addend wrapped
 && end <= BufLimitHi
 && end <= BufLimitLo)
    goto accept;
return STATUS_INVALID_PARAMETER;

The end >= addend test that follows every sum is the wrap detector. A 64-bit sum of two non-negative numbers cannot be smaller than either addend; if it is, the sum wrapped and the request is rejected with STATUS_INVALID_PARAMETER. The SRB-extension range is still processed as its own range — extend the watermark when its offset is at or past the current end — not as a fallback; it just gets the same 64-bit add and the same end >= addend guard, and so do OutDataLength / OutDataOffset and InDataLength / InDataOffset.

The whole class collapses to one rule: on a 64-bit kernel, zero-extend before you add, never after. zx(a) + zx(b) preserves the carry; zx(a + b) drops it. The cost is one extra instruction per addend, the benefit is that the bound check downstream actually means what it says. When you find one wrapping add in a struct-walking validator, assume the sibling fields were copy-pasted from the same template — they were, and the patch had better fix them all at once or the validator stays a hole for the pairs you did not touch.

Attack path

flowchart TD
    A["user opens a volume or adapter handle, sends IOCTL_SCSI_PASS_THROUGH"] --> B["PortPassThroughExValidateNormalizedRequest"]
    B --> C["end is the 32-bit sum of DataOffset plus DataLength, carry past bit 31 is dropped, end wraps small"]
    C --> D["wrapped end is at most buffer_size, every bound check passes, validator returns success"]
    D --> E["data path copies through the original DataOffset and DataLength, kernel pool OOB read or write to EoP"]