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.