http.sys UlpCreateInternalResponse integer overflow
Every HTTP response http.sys builds in kernel is anchored by an internal
response object: the structure that carries the status code, the captured
chunk descriptors, the parsed-header arrays, the variable-header staging
area and the byte counts of the response’s content and auxiliary regions.
UlpCreateInternalResponse is the constructor. It sizes one pool allocation
that must hold the object header plus every one of those regions laid out
end to end, then runs the initialisers that plant each region’s pointer
into the freshly allocated buffer.
Part of that sizing is the trailing region, whose byte count the constructor
builds from four addends: a fixed baseline of 0xb1, the
UlVariableHeaderSize global, and two response region byte-counts the
caller passes in — arg5, which becomes resp->ContentRegionBytes at
+0xa8, and arg7, which becomes resp->AuxRegionBytes at +0xac. The two
caller-derived counts are the ones a large enough request can swell. The
vulnerable build sums all four in 32-bit and tests only the final
composite:
/* UlpCreateInternalResponse -- vulnerable build (also the patched binary's
runtime-disabled branch). rsi_3 sizes the trailing region; rcx_10 is the
byte size handed to the allocator. arg5 -> resp->ContentRegionBytes (+0xa8),
arg7 -> resp->AuxRegionBytes (+0xac), arg10 -> resp->TailRegionBytes (+0x108). */
uint32_t sum = 0xb1 /* fixed baseline */
+ UlVariableHeaderSize /* global */
+ (uint32_t)arg5 /* content-region byte-count, -> +0xa8 */
+ (uint32_t)arg7; /* aux-region byte-count, -> +0xac */
uint64_t rsi_3 = (uint64_t)sum; /* zero-extend: a wrap past bit 31 is kept */
uint64_t entries = (uint64_t)(chunkCount + flagAdjust); /* captured-chunk entries */
uint64_t inner = (uint64_t)arg10 /* -> resp->TailRegionBytes */
+ ((entries + 0x38 + (entries << 2)) << 3) /* chunk array, 0x50 each */
+ (uint64_t)parsedHeaderCount2 * 0x18
+ (uint64_t)parsedHeaderCount1 * 0x18
+ rsi_3;
int64_t rcx_10 = (int64_t)hkeBlockSize /* (arg8 + 0x23) * 0x18 */
+ (int64_t)(inner << 1) /* double the whole inner sum */
+ (int64_t)fixedHeaderAdjustA /* 0..7 */
+ (int64_t)fixedHeaderAdjustB; /* 0 or 0x17 */
if ((uint64_t)rcx_10 > 0x7fffffff) /* the ONLY overflow test */
return 0xC0000095; /* STATUS_INTEGER_OVERFLOW */
result = UlpAllocateInternalResponse((uint32_t)rcx_10, &resp);
/* the initialisers then advance each region cursor into resp by the true
arg values, not the wrapped rsi_3 -- every cursor runs off the end of
the undersized tail */
resp->ContentRegionBytes = arg5; /* +0xa8 */
resp->ContentRegion = cursor; /* +0xc0 */
cursor += arg5;
resp->AuxRegionBytes = arg7; /* +0xac */
resp->AuxRegion = cursor; /* +0xc8 */
cursor += arg7;
resp->VariableHeaderSize = UlVariableHeaderSize; /* +0xb0 */
resp->VariableHeader = cursor; /* +0xd0 */
cursor += UlVariableHeaderSize;
/* ...RangeRegion/ExtraRegion/TailRegion when their flags are set... */
resp->HeaderStrings = cursor; /* +0x2a0 */
resp->TrailingRegionSize = (uint32_t)rsi_3; /* +0x2b0 -- the wrapped value */
resp->HeaderStringsEnd = cursor + rsi_3; /* +0x2a8 */
The mistake is the placement of the test. rsi_3 is a 32-bit sum, so a
carry out of bit 31 is dropped silently before the value is ever used. The
wrapped rsi_3 is folded into a larger inner sum alongside the
captured-chunk array, the two parsed-header arrays and the tail region,
and that whole inner sum is then doubled (<< 1) and added to the HKE
block and the fixed-header adjustments to form rcx_10. The single
rcx_10 > 0x7fffffff test runs on the composite, so a request whose true
trailing-region size pushes the sum past 2^31 produces a wrapped
rsi_3, a small composite and a passing test — the carry disappeared one
step upstream of the comparison. The allocation comes back undersized,
and the initialisers then walk the content, aux, variable-header and
header-strings cursors forward by the true arg5, arg7,
UlVariableHeaderSize values into a buffer that no longer has room for
all of them.
The patch
The fix widens the trailing-region accumulator to 64-bit before the add can drop a carry, tests the intermediate ahead of the shift that compounds it, then keeps the existing composite test after:
/* UlpCreateInternalResponse -- patched build (runtime-enabled branch). */
uint64_t rsi_3 = (uint64_t)0xb1 /* fixed baseline */
+ (uint64_t)arg5 /* content-region byte-count, -> +0xa8 */
+ (uint64_t)arg7 /* aux-region byte-count, -> +0xac */
+ (uint64_t)UlVariableHeaderSize; /* global */
if (rsi_3 > 0x7fffffff) /* NEW: test the intermediate itself */
return 0xC0000095; /* STATUS_INTEGER_OVERFLOW */
uint64_t entries = (uint64_t)(chunkCount + flagAdjust);
uint64_t inner = (uint64_t)arg10
+ ((entries + 0x38 + (entries << 2)) << 3)
+ (uint64_t)parsedHeaderCount2 * 0x18
+ (uint64_t)parsedHeaderCount1 * 0x18
+ rsi_3;
int64_t rcx_10 = (int64_t)hkeBlockSize /* (arg8 + 0x23) * 0x18 */
+ (int64_t)(inner << 1)
+ (int64_t)fixedHeaderAdjustA
+ (int64_t)fixedHeaderAdjustB;
if ((uint64_t)rcx_10 > 0x7fffffff) /* existing composite test, kept */
return 0xC0000095;
result = UlpAllocateInternalResponse((uint32_t)rcx_10, &resp);
Attack path
flowchart TD
A["request that drives a large kernel response, swollen content and auxiliary regions"] --> B["UlpCreateInternalResponse sizes the trailing region as a 32-bit sum, 0xb1 plus UlVariableHeaderSize plus two region byte-counts, and the add drops any carry past bit 31"]
B --> C["the wrapped trailing-region size is folded into the inner sum, which is doubled into rcx_10"]
C --> D["rcx_10 greater than 0x7fffffff test passes on the wrapped composite, allocation undersized"]
D --> E["region initialisers walk past the end of the buffer, pool overflow, RCE or DoS"]
This is the mirror image of the usual “check after the wrapping add” bug:
here it is “check the composite after the wrapping intermediate”. Both
have the same failure mode — the value being tested no longer reflects
the true sum. The canonical shape for any composite sizing test is to
widen the operands before the add drops the carry, test each intermediate
in the width it is computed in, then test the composite; a 32-bit add
followed by a 64-bit compare of the already-wrapped result is decorative.
Reachable from any host that exposes http.sys — IIS, the Windows HTTP
API, any http:// listener bound to the driver. The patched function
still carries the vulnerable 32-bit add inline behind its runtime gate,
so the fix is contingent on that gate staying enabled.