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 0xffffffff — 0xfffffff0 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"]