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.