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.