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.