http.sys UlPrepareCacheMissRangeResponse integer overflow
HTTP lets a client ask for parts of a resource with Range:. One header can
list many byte ranges (Range: bytes=0-1,10-11,20-21,...); when the server
honours more than one, the response is multipart/byteranges — each part gets
a Content-Range framing, a boundary delimiter, and its bytes.
UlPrepareCacheMissRangeResponse is the cache-miss builder for that body: the
path taken when the response is assembled straight from upstream rather than
served from the http.sys URL cache. It sizes the framing buffer up front from
response->Request->RangeElementCount, the parsed element count that lives on
the embedded UL_REQUEST and is populated straight from the wire.
The builder walks the parsed chunk list once to count how many
(range, chunk) records it will have to materialize, then folds that count
into one 32-bit sizing expression. There is no overflow guard anywhere in the
fold, and every term of it is attacker-influenced:
/* UlPrepareCacheMissRangeResponse -- vulnerable build (http.sys, pre-Aug 2026) */
UL_REQUEST* request = response->Request; /* +0x18 */
ULONG ranges = request->RangeElementCount; /* +0xa74, parsed from Range: */
ULONG chunk_count = i_10; /* built by the prior chunk-walk */
ULONG base_hdrs = 2 + (response->HasAuxRange != 0); /* +0xac, 2 or 3 fixed slots */
/* accum counts the 0x50-byte records the write loop will emit later */
ULONG accum = chunk_count + 3 + ranges + base_hdrs; /* 32-bit add, no carry check */
/* aux counts the framing bytes: per-range marker plus the Content-Range text */
ULONG aux = ( (response->BoundarySize /* +0xf8 */ + 0x96) * ranges
+ (response->IsMultipart /* +0xe8 */ ? -22 : 0) + 0x96 ) & ~7u;
/* 32-bit mul, wraps past ~21M ranges */
/* total = record bytes plus framing bytes, all 32-bit */
ULONG total = (accum * 5 << 4) + aux; /* (accum * 80) + aux */
PVOID buf = ExAllocatePool3(POOL_FLAG_NON_BLOCKING /* 0x42 */,
(ULONG)total, /* wrapped value -> small alloc */
0x46596c55, &UxLowPriorityPool, 1);
/* rsi = buf + aux; the record slots are zeroed straight away */
memset((char *)buf + aux, 0, (size_t)accum * 0x50);
/* the per-range builder loop then writes one 0x50-byte record per (range, chunk)
into those slots, indexed by a counter that climbs to the true accum */
BoundarySize is the per-range framing width the builder reserves for the
delimiter that separates two parts, 0x96 is the per-range framing constant
(150), so the aux multiply costs about 200 bytes per range and crosses
2^32 once ranges reaches roughly twenty-one million — not a number any
sane client sends, but well within what an unauthenticated peer is allowed to
put on the wire. The (accum * 5) << 4 term is a second multiply
(accum * 80) layered on a sum that has already lost its carry, and the
result is then fed to ExAllocatePool3 zero-extended to 64-bit, so whatever
wrapped 32-bit value sits in total becomes the literal allocation size.
The builder loop runs for the true accum; the buffer was sized by the
wrapped product. Each record it writes is 0x50 bytes of Content-Range /
boundary / chunk descriptor, and every field in it is built from request data
the peer controls. That is the full pool-overflow primitive against the
http.sys kernel heap.
The patch
The fix does not touch the sizing arithmetic at all. It adds a single count
cap before the multiply, and if any of the three attacker-influenced
inputs exceeds it the function refuses multipart outright and falls through
to the plain 200 OK builder:
/* UlPrepareCacheMissRangeResponse -- patched build (August 2026) */
if (gRangeAuxSpaceCapEnabled != 0 && /* runtime compatibility gate */
( chunk_count > 0x2710 ||
ranges > 0x2710 ||
response->BoundarySize /* +0xf8 */ > 0x2710 ))
{
goto prepare_200; /* -> UlpPrepare200Response: plain 200 OK, no aux buffer */
}
/* The sizing math below this check is byte-for-byte identical to the
vulnerable build -- same 32-bit multiplies, same shifts, same truncation.
The cap is the only thing keeping the products in range: */
ULONG accum = chunk_count + 3 + ranges + base_hdrs;
ULONG aux = ( (response->BoundarySize + 0x96) * ranges
+ (response->IsMultipart ? -22 : 0) + 0x96 ) & ~7u;
ULONG total = (accum * 5 << 4) + aux;
PVOID buf = ExAllocatePool3(POOL_FLAG_NON_BLOCKING, (ULONG)total, ...);
0x2710 is 10000. That is roughly two thousand times smaller than the
~21M wrap point of the per-range multiply, so once the check is in place the
old arithmetic genuinely cannot overflow on a request that gets past it —
the worst-case aux is about 10000 * 200 = 2_000_000, nowhere near
2^32. The cap is the security control; the unchanged 32-bit math is fine
only because the cap is so far below the wrap. Note what the patch
deliberately does not do: it does not widen the accumulator, does not
re-check the product after the multiply, and does not bound accum itself —
it trusts that capping the three inputs caps the products, which is true
here but means any future edit that adds another multiplicative term is one
edit away from re-arming the bug.
Attack path
flowchart TD
A["anonymous HTTP request, Range: with many byte ranges"] --> B["UlPrepareCacheMissRangeResponse reads response Request RangeElementCount from the wire"]
B --> C{"patched build AND any of ranges, chunk_count, BoundarySize above 0x2710"}
C -- "yes, refuse multipart" --> R["UlpPrepare200Response, plain 200 OK, no aux buffer"]
C -- "no, or vulnerable build" --> D["32-bit sizing: total equals accum times 80 plus aux, aux equals BoundarySize plus 0x96 times ranges"]
D --> E["ExAllocatePool3 with the 32-bit total; with ranges in the millions the product wraps small"]
E --> F["per-range write loop fills 0x50-byte records past the end of the undersized pool block"]
Reachability is the open HTTP surface — any host that exposes http.sys is reachable from an unauthenticated peer, and the request is the exploit.
Every *, +, and << in a sizing expression is its own chance to drop a
carry, and (accum * 5 << 4) + aux chains three on top of a sum that
already lost its overflow bit. When a sizing expression cannot be made safe
for arbitrary input counts, the right answer is not to widen the math and
hope; it is to refuse the feature and return a plain response before the
multiply ever runs.