http.sys UlCalculateFastForwardDataContextSize integer overflow
The “fast-forward” path in http.sys is the streaming data path: the in-kernel pipe between an upstream content source (a responder app, a cached body, a forwarded connection) and the response socket, used when the response is too large or too dynamic to assemble in one shot. Each stream carries a fast-forward data context — a control structure holding the per-stream state, the variable storage area for headers and content negotiated for this stream, and the tail bookkeeping (pending byte ranges, MDL chain pointers, chunk accounting) that lets the kernel push body bytes through without crossing into user mode for each chunk.
UlCalculateFastForwardDataContextSize is the sizer for that context. It
takes a count of variable-storage entries, scales it by 0x10, aligns the
result up to the kernel’s natural alignment with (scaled + 7) & ~7, and adds
a fixed 0xf0 of overhead to produce a single byte count the caller hands to
ExAllocatePool3. The variable count is request-influenced — it reflects the
negotiated header set and range depth — so a many-range or many-header request
drives count << 4 into the region where the 32-bit arithmetic wraps.
The vulnerable build does the align-up and the tail-add in 32-bit, with no per-operation wrap check:
// UlCalculateFastForwardDataContextSize — vulnerable build
uint64_t scaled = (uint64_t)count << 4; // arg1 zero-extended, scaled by 0x10 per storage entry
*outSize = 0;
if (scaled > 0xffffffff) // guard: scaled must fit in 32 bits
return STATUS_INTEGER_OVERFLOW; // 0xC0000095 — passes for every scaled up to 0xfffffff0
*outSize = (((uint32_t)scaled + 7) & ~7u) // 32-bit align-up to 8 bytes
+ 0xf0; // then +0xf0 of fixed overhead, also 32-bit
return STATUS_SUCCESS; // neither add is wrap-checked
Only one of those two adds can actually fire here. count << 4 is always
sixteen-aligned, so the inner +7 of the align-up never carries: the largest
scaled value to clear the guard is 0xfffffff0, plus 7 lands at 0xfffffff7
still inside 32 bits, and the & ~7 mask snaps it back to 0xfffffff0. The
aligned result simply equals scaled. The add that wraps is the +0xf0 tail
— with scaled at 0xfffffff0 the sum is 0x100000000, which truncates to a
small value in 32-bit, and for any scaled from 0xfffff110 up to
0xfffffff0 the wrapped total lands somewhere under 0xf0. The caller hands
that wrapped total to the allocator; the initialisation code that fills the
variable storage and the tail bookkeeping then overruns the buffer.
The mistake is to treat a rounding macro as benign bookkeeping rather than
sizing arithmetic. (n + 7) & ~7 is an add followed by a mask, and the add is
as capable of wrapping as any other — even when, as here, the call site’s
input alignment happens to mask the wrap on the +7 while leaving the next
add in the expression fully exposed. On attacker-controlled n, every add in
the size expression has to be widened and tested; the same applies to
ALIGN_UP_BY, ALIGN_UP, and every other rounding macro that compiles to
add-and-mask.
The patch
The fix adds an explicit wrap test after each add that can carry. The
align-up is recomputed in 64-bit and compared back against its input; the
+0xf0 tail-add stays in 32-bit but is now followed by a total < 0xf0
test:
// UlCalculateFastForwardDataContextSize — patched
uint64_t scaled = (uint64_t)count << 4;
*outSize = 0;
if (scaled > 0xffffffff)
return STATUS_INTEGER_OVERFLOW; // 0xC0000095
uint64_t alignedInput = (uint64_t)(uint32_t)scaled;
if (((alignedInput + 7) & ~7ull) < alignedInput) // wrap check #1: the align-up add
return STATUS_INTEGER_OVERFLOW;
uint32_t total = (((uint32_t)scaled + 7) & ~7u) + 0xf0;
if (total < 0xf0) { // wrap check #2: the +0xf0 overhead add — this is the one that fires
*outSize = 0xffffffff; // sentinel written before the error return
return STATUS_INTEGER_OVERFLOW;
}
*outSize = total;
return STATUS_SUCCESS;
A size expression with multiple operations has to be checked at every
operation that can wrap, and the right shape of check is “the result went
backwards” — ((x + 7) & ~7) < x for the align-up, total < 0xf0 for the
overhead add. A sum of two non-negative numbers can only be smaller than one
of its addends if it wrapped, so each test is a precise wrap detector rather
than a loose ceiling. Two operations mean two such tests; a single composite
test at the end would be decorative when the wrap happens upstream of it, and
the 0xf0 add stays 32-bit only because the < 0xf0 test now catches its
carry.
Attack path
flowchart TD
A["anonymous HTTP request, many-header or many-range stream"] --> B["UlCalculateFastForwardDataContextSize: count times 0x10 clears the 32-bit guard at scaled near 0xfffffff0"]
B --> C["32-bit +0xf0 tail-add wraps the total past 2^32 down to a value under 0xf0"]
C --> D["caller allocates the wrapped total"]
D --> E["variable-storage and tail writes overrun the allocation, pool overflow, RCE or DoS"]
The streaming path is the one taken for any large or many-range response — exactly the shape an attacker can force a server to emit by request choice alone.