http.sys UlComputeMultipleKnownHeaderSize integer overflow
http.sys recognises a fixed set of HTTP response headers — Content-Type,
Content-Length, Cache-Control, Expires, ETag, Last-Modified, and a handful of
others — and stores them in a dedicated “known header” array on the internal
response object rather than in the generic variable-header blob. The point is
speed: known headers are looked up by index, serialised from a cached
literal, and never re-parsed. UlComputeMultipleKnownHeaderSize is the
helper that walks the list of multiple-known-header entries (headers that may
repeat, such as WWW-Authenticate) and sums the per-entry byte costs. It is
called by the internal response constructor and by the cache-lookup path to
produce one term of the overall response buffer size, and its size outputs
feed the upstream response-buffer allocation.
Both inputs are attacker-controlled in transformation-style responders: the count of known headers present on the response (driven by what the responder chooses to emit, which reflects the request) and the per-slot byte total (which includes value lengths derived from request data). The vulnerable build sums them in a 32-bit loop with no per-iteration wrap check:
// UlComputeMultipleKnownHeaderSize — vulnerable build
// response = arg3 (UL_RESPONSE_INTERNAL*),
// entryCount = arg5,
// entries = arg6 (UL_MULTIPLE_KNOWN_HEADER_ENTRY*, stride 0x18)
uint16_t authIndicator = response->AuthHeaderIndicator; /* +0x208 */
ULONG headerSizeSum = 0; // accumulates each entry's per-header byte cost
ULONG auxSum = 0; // accumulates each entry's secondary cost
for (USHORT i = 0; i < entryCount; i++) {
if (entries[i].HeaderToken == 0x1d) { /* HttpHeaderAuthorization */
/* UlpCalculateAuthHeaderSize sizing pre-pass, elided */
continue;
}
ULONG singleSize; /* out: var_54 */
ULONG singleAux; /* out: var_4c */
UlpCalculateSizeOfSingleMultipleHeader(request, compressFlag,
entries[i].HeaderNameLength, /* +0x08 */
entries[i].HeaderValueLength, /* +0x10 */
tokenCtx, &singleSize, &singleAux);
headerSizeSum += singleSize; /* 32-bit add, no wrap check <-- overflow */
auxSum += singleAux; /* 32-bit add, no wrap check */
}
/* fold a response-header-map contribution into the accumulated total */
ULONG total = responseHeaderMapBytes + headerSizeSum; /* no wrap check */
*outTotalSize = total; /* *arg11 */
*outAuxSize = auxSum; /* *arg12 */
return 0; /* STATUS_SUCCESS */
The unguarded running sum is the bug. Sums that grow in a loop are the
easiest place to wrap because the running total compounds across iterations
— a single iteration cannot wrap but a thousand of them can, and a responder
that lets the client steer many long known-header values turns this
accumulator into an attacker-driven sum. With enough long-value slots the
running total passes 2^32, the carry out of bit 31 is dropped silently,
and the computed total handed back through the output parameter is a small
value. The caller sizes the response buffer against that small value,
allocates a small buffer, and the serialisation loop writes the full
known-header set past the end.
The patch
The fix adds the standard wrap test inside the loop, mirrors it on the final
response-header-map combine, and propagates either failure back to the
caller as STATUS_INTEGER_OVERFLOW:
// UlComputeMultipleKnownHeaderSize — patched
ULONG headerSizeSum = 0;
ULONG auxSum = 0;
for (USHORT i = 0; i < entryCount; i++) {
if (entries[i].HeaderToken == 0x1d) { /* auth-header pre-pass, elided */ continue; }
ULONG singleSize;
ULONG singleAux;
UlpCalculateSizeOfSingleMultipleHeader(request, compressFlag,
entries[i].HeaderNameLength, entries[i].HeaderValueLength,
tokenCtx, &singleSize, &singleAux);
ULONG next = singleSize + headerSizeSum; /* candidate new total */
if (next < headerSizeSum) { /* wrapped past 2^32 */
if (authScratch != NULL)
ExFreePoolWithTag(authScratch, 0);
return STATUS_INTEGER_OVERFLOW; /* 0xC0000095 */
}
headerSizeSum = next;
auxSum += singleAux; /* fixed overhead, still unguarded */
}
ULONG total = responseHeaderMapBytes + headerSizeSum; /* final combine */
if (total < headerSizeSum) { /* wrapped */
if (authScratch != NULL)
ExFreePoolWithTag(authScratch, 0);
return STATUS_INTEGER_OVERFLOW;
}
*outTotalSize = total;
*outAuxSize = auxSum;
return 0;
next < headerSizeSum after the add is the canonical detector — a sum of two
non-negative numbers can only be smaller than its left operand if the add
wrapped. The check is one line, catches every wrap regardless of addend
size, and lets the caller refuse the response rather than recover. The same
guard is repeated on the final combine that folds the response-header-map
contribution in, because that add is just as able to wrap as any
per-iteration one. The two guarded adds are applied as a pair, so a wrap at
the per-iteration site or at the final combine is caught either way. The
secondary auxSum accumulator is left unguarded; it collects a small fixed
per-entry overhead, so it is not where this overflow lives.
Attack path
flowchart TD
A["request drives many large known-header values onto the response"] --> B["UlComputeMultipleKnownHeaderSize sums per-entry byte costs in a 32-bit accumulator with no wrap check"]
B --> C["accumulator wraps past 2^32; a small total is written to the size output parameter"]
C --> D["caller sizes the response buffer against the wrapped total"]
D --> E["header serialisation overruns the allocation, pool corruption"]
Reachable from any host that exposes http.sys and drives a transformation- style responder — the request supplies the headers that the responder echoes into the known-header slots.