http.sys UlAllocateFastTracker integer overflow

Every request http.sys accepts gets a UL_FAST_TRACKER — the per-request anchor that carries the parsed method, the URL cache entry pointer, the variable-header storage, and the MDL byte stream describing the request body. All of that lives in one pool allocation sized at accept time from several wire-driven quantities: UlVariableHeaderSize plus variableHeaderCount from the parsed headers, routeExtraBytes from the matched route, an auxBytes adjuster folded into the same region, the HTTP/3 header-pair counts, and mdlBytes from the Content-Length or chunked framing. UlAllocateFastTracker does the sum and the allocation, which makes its arithmetic the boundary between “any TCP peer who can reach a hosted URL” and a kernel pool overflow.

The vulnerable build folds UlVariableHeaderSize + variableHeaderCount + routeExtraBytes + auxBytes into a single 32-bit value — the variable-header region size — and guards only the first two adds:

// UlAllocateFastTracker — vulnerable build
rcx  = UlVariableHeaderSize + variableHeaderCount;
if (rcx < UlVariableHeaderSize) goto fail;              // wrap check #1

rax2 = rcx + routeExtraBytes;
if (rax2 < routeExtraBytes) goto fail;                  // wrap check #2

if (requestEligible
    && routeExtraBytes     <= 0x800
    && variableHeaderCount <= 0x200
    && h3PairBytes         <= UlH3DefaultPairSize
    && auxBytes             == 0) {
    hdr = PplAllocateFromLookasideListProcessor(...);   // fast path: mdlBytes uninspected
} else {
    // third add of auxBytes happens here with NO wrap check
    masked    = (variableHeaderCount != 0) ? rcx : 0;
    varRegion = masked + routeExtraBytes + auxBytes;    // wraps to a small u32

    varMdlSize  = (MmSizeOfMdl(0xfff, varRegion) + 7) & ~7;
    bodyMdlSize = (MmSizeOfMdl(0xfff, mdlBytes)  + 7) & ~7;
    size = bodyMdlSize + varRegion
         + (varMdlSize + 0xa0) * 3
         + (h3PairBytes + h3PairBytes2) * 2
         + masked + auxBytes;
    if (size > 0xffffffff) goto fail;
    hdr = ExAllocatePool3(POOL_FLAG_NON_BLOCKING, (uint32_t)size, tag);
    hdr->VariableHeaderSize /* +0x58 */ = varRegion;   // caller trusts the wrapped total
}

Two bugs sit in this function, both funnelling into the same primitive. The third add — +auxBytes, the last term folded into the variable-header region size — has no wrap check of its own. Pick variableHeaderCount and routeExtraBytes so the second guarded sum lands just under 2^32, then push auxBytes past the top: varRegion collapses to a small 32-bit value, the size > 0xffffffff test still passes because the wider 64-bit sum is computed from the already-wrapped varRegion, and ExAllocatePool3 returns a buffer sized for the wrapped total while the caller writes the unwrapped total into hdr->VariableHeaderSize and into the header storage beyond it.

The lookaside fast path is the second hole. Its predicate bounds routeExtraBytes, variableHeaderCount, the HTTP/3 pair bytes, and forces auxBytes == 0, so genuinely small requests take a pre-allocated per-CPU buffer — but it never looks at mdlBytes. A request with a tiny header block and an enormous advertised body slips into the lookaside path and the body MDL stream is laid out past the end of a buffer sized for small headers.

The patch

The fix adds the missing third wrap check on +auxBytes and tightens the lookaside predicate to include mdlBytes:

// UlAllocateFastTracker — patched
rcx  = UlVariableHeaderSize + variableHeaderCount;
if (rcx < UlVariableHeaderSize) goto fail;              // wrap check #1

rax2 = rcx + routeExtraBytes;
if (rax2 < routeExtraBytes) goto fail;                  // wrap check #2

if (rax2 + auxBytes < rax2) goto fail;                  // NEW wrap check #3 on +auxBytes

if (requestEligible
    && routeExtraBytes     <= 0x800
    && variableHeaderCount <= 0x200
    && h3PairBytes         <= UlH3DefaultPairSize
    && mdlBytes            <= 0x40000                   // NEW bound on body bytes
    && auxBytes             == 0) {
    hdr = PplAllocateFromLookasideListProcessor(...);
} else {
    masked    = (variableHeaderCount != 0) ? rcx : 0;
    varRegion = masked + routeExtraBytes + auxBytes;    // now known not to wrap

    varMdlSize  = (MmSizeOfMdl(0xfff, varRegion) + 7) & ~7;
    bodyMdlSize = (MmSizeOfMdl(0xfff, mdlBytes)  + 7) & ~7;
    size = bodyMdlSize + varRegion
         + (varMdlSize + 0xa0) * 3
         + (h3PairBytes + h3PairBytes2) * 2
         + masked + auxBytes;
    if (size > 0xffffffff) goto fail;
    hdr = ExAllocatePool3(POOL_FLAG_NON_BLOCKING, (uint32_t)size, tag);
    hdr->VariableHeaderSize /* +0x58 */ = varRegion;
}

Attack path

flowchart TD
    A["anonymous HTTP request; variableHeaderCount, routeExtraBytes, auxBytes, mdlBytes all peer-chosen"] --> B["UlAllocateFastTracker folds UlVariableHeaderSize + variableHeaderCount + routeExtraBytes + auxBytes in u32"]
    B --> C["third add of auxBytes wraps past 2^32, OR lookaside slip when mdlBytes is huge but headers are small"]
    C --> D["ExAllocatePool3 returns a small buffer, or the per-CPU lookaside buffer is reused, sized for the wrapped total"]
    D --> E["caller writes variable-header region and body MDL stream past the end of the buffer"]
    E --> F["kernel pool overflow; RCE under any HTTP.SYS-hosted endpoint, or DoS"]

A size computed from N attacker-controlled adds needs N overflow checks, not N-1; and a fixed-size fast path has to bound every field that contributes to the written size, not just the obvious ones. The mdlBytes <= 0x40000 term is exactly the bound that is easy to forget on a lookaside predicate, and exactly the one that lets an oversized body slip into the small path.

The trigger is unauthenticated network input. Any host that exposes http.sys — IIS, the Windows HTTP API, any http:// listener bound to the driver — is reachable from a peer that can open a TCP connection, and the request itself is the exploit; no logon or application-layer auth required.