http.sys UlPrepareParsedHeaderRangeResponse integer overflow
HTTP range responses come in two flavours inside http.sys depending on where
the body bytes come from. The cache-miss path assembles the body straight from
upstream as it arrives; the parsed-header path is taken when the response
object already carries its parsed Range: set and the body content is locally
available — served from the URL cache, generated by a kernel-mode responder,
or produced by an app that hands http.sys a fully formed response.
UlPrepareParsedHeaderRangeResponse is the builder for the second path. Its
sizing math is the same shape as its cache-miss twin because both builders
do the same job on the same kind of input: walk the parsed Range list, build
the multipart/byteranges framing, and size the buffer that holds the
per-range chunk records plus the boundary framing text.
The arithmetic is the same too, in 32-bit, with no input bound: a per-record
term (slots * 5) << 4 (that is, slots * 0x50) added to a per-range
framing term, all computed as uint32_t and handed straight to
ExAllocatePool3.
// UlPrepareParsedHeaderRangeResponse — vulnerable build
range_spec_t *ranges = request->RangeSpec; /* request+0xa78 */
uint32_t range_count = request->RangeCount; /* request+0xa74 */
chunk_desc_t *chunks = response->ChunkDescriptors; /* response+0x380 */
uint32_t chunk_count = response->ChunkCount; /* response+0x370 */
// copies: total number of 0x50-byte chunk records the multipart body needs.
// The nested loop walks every (captured-chunk, range) pair and adds one or
// two per overlap, so copies scales with chunk_count * range_count and is
// driven by the attacker's Range header against a cached resource.
uint32_t copies = 0, walk = 0;
for (uint32_t c = 0; c < chunk_count; c++) {
for (uint32_t r = 0; r < range_count; r++) {
if (walk <= ranges[r].start) {
if (chunk_len(&chunks[c]) + walk > ranges[r].start) copies++;
if (walk < ranges[r].start + ranges[r].length) copies++;
}
}
walk += chunk_len(&chunks[c]);
}
// Size the output buffer — 32-bit throughout, no overflow guard anywhere.
// (range_count == 1 takes a fixed-size short form with framing = 0x45; the
// multi-range path below is the one that wraps.)
UL_PARSED_HEADER *bh = &response->ParsedHeaders[response->BoundaryHeaderIndex];
/* response+0x2b8, response+0x2d4 */
uint32_t framing = range_count * 0x43 + 0x32
+ bh->HeaderLength + 0x53; /* bh+0x14 */
uint32_t slots = copies + range_count * 2 + 1;
uint32_t total = ((slots * 5) << 4) + framing; /* = slots*0x50 + framing, as uint32 */
PVOID buf = ExAllocatePool3(POOL_FLAG_NON_BLOCKING, total, 'UlYF', NULL);
// The body builder then writes one 0x50-byte record per slot into buf
// (UlpDuplicateChunkRange per overlap; UlpGenerateRangeResponse* for framing).
The wrap fires on the slots term. slots is copies + range_count * 2 + 1,
where copies is one or two per (range, captured-chunk) overlap, so it
scales with range_count * chunk_count. Past roughly 53 million slots the
(slots * 0x50) product wraps uint32_t, the framing term lands in the
vacated low bits, and total comes out small. ExAllocatePool3 returns a
buffer a fraction of the needed size; the body builder then walks the same
un-truncated slots count and writes one 0x50-byte record per iteration —
built from the peer’s Range spec — past the end of the allocation into the
next kernel pool object. Reaching 53 million slots needs both axes pulled
wide: a large cached resource (many captured chunks) and a
many-thousand-element Range: header against it. Both are within what an
HTTP client can put on the wire.
What makes this twin worth its own post is reachability. The parsed-header
path is taken when a kernel-mode responder or the URL cache hands http.sys a
response with a non-empty parsed Range list — the cache-hit case, which is
the common path for any cacheable resource. An attacker who can warm the
cache with a single request and then replay a many-element Range: header
against the cached copy drives the wrap through this builder rather than the
cache-miss one. If only the cache-miss twin were fixed, this path would
remain a working exploit.
The patch
The fix mirrors the cache-miss fix in shape but not in detail. The cache-miss twin can bail out of the multipart builder as soon as the counts look hostile, because nothing has been committed to the response yet. The parsed-header path is different: it can be entered after the response status has already been set to 206 Partial Content, so the patch has to rewrite that status as well as refuse the work. A single guard runs before the size math:
// UlPrepareParsedHeaderRangeResponse — patched
// ... same overlap-counting loop; copies / range_count / bh computed as before ...
// NEW guard (gated on a servicing flag), checked before the size math runs.
// Same 0x2710 (10000) cap as the cache-miss twin.
if (copies > 0x2710
|| range_count > 0x2710
|| (bh != NULL && bh->HeaderLength > 0x2710)) { /* bh+0x14 */
if (response->StatusCode != 0xce) /* response+0x98, not 206 */
goto done; // leave the response untouched
// Already 206 — rewrite every place the status lives to 200 OK, so the
// client gets a complete plain response instead of a headerless 206.
UlUpdateParsedHeader(response->ParsedHeaders, /* response+0x2b8 */
response->ParsedHeaderCount, /* response+0x2d0 */
"200 OK", 3);
response->Connection->AssocStatus = 0xc8; /* conn+0x69c */
response->StatusCode = 0xc8; /* response+0x98 */
if (response->InternalResp != NULL) /* response+0x200 */
response->InternalResp->Status = 0xc8; /* internalResp+0x78 */
if (response->Flags != 0) /* response+0x32: HKE responder */
UlpHkeBuilderModifyStatusCode(response->HkeBuilder, /* response+0x220 */
&(hke_status_t){"OK", 0xc8}); /* stack desc 0x200c8 */
return;
}
// Below the guard the size math is byte-for-byte identical to the vulnerable
// build — still 32-bit, still unguarded. The cap is what makes it safe.
uint32_t framing = range_count * 0x43 + 0x32 + bh->HeaderLength + 0x53;
uint32_t slots = copies + range_count * 2 + 1;
uint32_t total = ((slots * 5) << 4) + framing;
PVOID buf = ExAllocatePool3(POOL_FLAG_NON_BLOCKING, total, 'UlYF', NULL);
The cap is 0x2710 (10000) on each of three attacker-relevant quantities:
copies (the computed record count), range_count (elements in the Range
header), and the boundary header’s value length. None of those exceeds four
digits in any legitimate HTTP exchange, and with all three bounded the
slots * 0x50 product stays under three megabytes — orders of magnitude
below the wrap. When the response had already been promoted to 206, the
guard downgrades it back to 200 OK across the status code field, the
connection state, the internal response mirror, and the HKE builder, so the
peer receives a syntactically complete response rather than a 206 with no
multipart body.
Attack path
flowchart TD
A["warm URL cache with a range-able resource"] --> B["replay Range header with many elements, hits UlPrepareParsedHeaderRangeResponse"]
B --> C["32-bit size math wraps to a small total: slots times 0x50 plus framing"]
C --> D["ExAllocatePool3 returns a small buffer"]
D --> E["body builder writes one 0x50-byte chunk record per slot, overruns the pool"]
Same wide-open HTTP surface as the cache-miss twin, with the extra wrinkle that this is the cache-hit path — the common case for cacheable resources. The structural lesson writes itself: two functions that are conceptually a copy-paste of each other — same job, same operands, same sizing math — share the bug, and a fix on only one routes an attacker to the other. Warm the cache, replay with a fat Range header, and the half-patched twin does the rest.