windowshttp.syskernelpatch-diffingreverse-engineeringAI-generated

CVE-2026-47291: the HTTP.sys reference-buffer capacity wrap

A patch diff of http.sys exposes a 16-bit capacity wrap that turns a reference-array resize into a kernel pool overflow.

Microsoft fixed CVE-2026-47291 in the June 2026 security updates. The advisory describes an integer overflow in Windows HTTP.sys that allows an unauthenticated remote attacker to execute code. Microsoft rates it Critical, assigns a CVSS 3.1 base score of 9.8, and maps it to CWE-190 and CWE-122.

The patch matches that description exactly. A request owns a growable array of referenced request-buffer pointers. Both its capacity and count are 16-bit values. The vulnerable resize path adds five to the capacity without checking for wraparound, then uses the wrapped value for the next allocation while retaining the large count for a memmove. The result is a roughly 524 KB copy into a tiny kernel pool buffer.

The integer overflow and resulting out-of-bounds copy are proven statically across two Windows build trains. The vulnerable 1809 binary was also confirmed on a live target. My initial experiments used plaintext HTTP and did not produce a crash. Subsequent public analysis identified the missing transport primitive: over HTTPS, SChannel delivers separately encrypted TLS records to HTTP.sys as distinct plaintext buffers. One complete header line per record can therefore accumulate the references that plaintext TCP traffic does not.

Advisory facts

Field Value
CVE CVE-2026-47291
Component Windows HTTP Protocol Stack, http.sys
Impact Remote code execution
Weaknesses CWE-190 and CWE-122
CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Microsoft severity Critical
Microsoft release status Publicly disclosed: No; exploited: No
Release June 9, 2026

Binary pairs

I used two independent build trains to separate the security fix from unrelated cumulative-update noise:

Train Vulnerable Fixed
Windows 10 1809 / Server 2019 10.0.17763.8755, KB5087538, indexed SHA-256 e49e11724b4478ae94b1ff67ce24c9253b76f61e7c55e44d99dbbdc84fc48f14 10.0.17763.8880, KB5094123, indexed SHA-256 8767d8a530c42920d3a6bc3943d05e38e7b04181c0a2f9d905f22669d63e4184
Windows 11 24H2 10.0.26100.8521, KB5089573, indexed SHA-256 808491dc0a89fb5f7a9bade3d7ba680bd685e184fc3de610e047eba9c366a64f 10.0.26100.8655, KB5094126, indexed SHA-256 1a34d70a649f461745c782f1f27bcea6f6603aafd59842b3ed412181ac5c2c9a

The locally analyzed 1809 vulnerable copy hashes to e735e9598300efbda36c6f07e505abe5a928f1727389644317737435d09ed3ed; the table retains the Winbindex-indexed hash to identify the source artifact.

The fix remains inlined in UlpParseNextRequest on the 1809 train. On 24H2, Microsoft split the same logic into UlpReferenceBuffers. The new UxKirRefBufferOverflowCheck symbol and the wrap guard are present in both fixed binaries and absent from both vulnerable binaries.

Prerequisites

The prerequisites are narrower than “an HTTP.sys server is reachable.” The relevant conditions are:

  1. A vulnerable http.sys build with the reference-array implementation. The 1809 binary 10.0.17763.8755 has it; 10.0.17763.8880 contains the fix. An older 17763 RTM-era lab binary used a linked-list design and did not contain this exact primitive.
  2. The endpoint must accept HTTP/1.x over TLS. This is an HTTPS trigger. HTTP/2 and HTTP/3 use different parser paths, while plaintext HTTP is subject to TCP and HTTP.sys buffer coalescing. A client can offer only http/1.1 through ALPN to keep the connection on the vulnerable parser.
  3. MaxRequestBytes must admit the record-per-header request. At the theoretical minimum of about four bytes per header line, approximately 65,536 records require roughly 262,144 bytes. A practical request using distinct header names is larger. The default 16384-byte value is insufficient. The DWORD is stored at HKLM\SYSTEM\CurrentControlSet\Services\HTTP\Parameters\MaxRequestBytes; changing it requires restarting the HTTP service or the system. Keeping it at or below 65534 follows Microsoft’s published pre-update mitigation guidance.
  4. One request must accumulate enough distinct retained buffer references. The capacity/count pair is reset for every request, so pipelining many requests does not help. The documented trigger leaves one HTTP/1.x header block unfinished and sends each short, CRLF-terminated header line in its own TLS application-data record. Distinct header names avoid duplicate-value concatenation limits. SChannel decrypts and delivers every record independently. Because each buffer contains a complete line, the parser consumes it without setting the partial-parse flag and UlpAdjustBuffers takes the non-merge path. This gives approximately one retained reference per TLS record until the capacity wraps. The parsing occurs before authentication.

The fixed binary also reads a new MaxHeadersCount value when UxKirMaxHeadersCountLimit is active. Its default is derived from max(MaxRequestBytes >> 9, 200), with an accepted range of 50 through 65,535. This defense-in-depth bound reduces header fan-out, but the arithmetic fix below is the direct correction for the pool overflow.

Reference-buffer layout

The 24H2 request object contains the following fields:

Field Offset Type Meaning
Capacity request + 0x640 USHORT Allocated pointer slots
Count request + 0x642 USHORT Used pointer slots
Buffers request + 0x648 PVOID * Reference-array pointer

The 1809 live target uses the same structure at offsets 0x630, 0x632, and 0x638. The ten-byte shift is an object-layout difference, not a different bug. Each array element is an eight-byte pointer. The array uses pool tag UlRR and grows in increments of five slots.

Exact vulnerable code

The following instructions are transcribed from the vulnerable 24H2 UlpParseNextRequest at 0x14002a167. Register aliases replace only the request base register to make the three fields readable; this is disassembly, not Microsoft source code.

movzx eax, word ptr [request + 0x640] ; Capacity
cmp   word ptr [request + 0x642], ax  ; Count >= Capacity?
lea   rdx, [0x28 + rax*0x8]          ; (Capacity + 5) * 8
call  ExAllocatePool3
; ...
; memmove(new_array, [request + 0x648], Count * 8)
; ...
add   word ptr [request + 0x640], 0x5 ; unchecked 16-bit add

In decompiler form, the vulnerable branch recovered from the fixed binary’s KIR rollback path is:

capacity = *(ushort *)(request + 0x640);
if (capacity <= *(ushort *)(request + 0x642)) {
    new_array = ExAllocatePool3(
        0x42,
        (ulonglong)capacity * 8 + 0x28,
        0x52526c55,
        &UxLowPriorityPool,
        1);
    if (new_array == NULL)
        goto fail;

    memmove(
        new_array,
        *(void **)(request + 0x648),
        (ulonglong)*(ushort *)(request + 0x642) << 3);

    if (1 < *(ushort *)(request + 0x640))
        ExFreePoolWithTag(*(void **)(request + 0x648), 0);

    *(short *)(request + 0x640) += 5;
    *(void **)(request + 0x648) = new_array;
}

Capacity starts at one. After 13,106 growth events it is 0xFFFB (65531). The 13,107th growth evaluates the allocation expression in 64 bits and therefore still allocates 524,288 bytes, but its final 16-bit add computes 0xFFFB + 5 = 0x10000 and stores zero. After that append, count is 0xFFFC (65532). On the subsequent resize, the code allocates 0x28 + 0 * 8 = 40 bytes but copies exactly 524,256 bytes (65532 * 8). This produces the CWE-190 to CWE-122 chain assigned by MSRC.

Exact fix

The fixed path computes the new capacity first and compares its 16-bit result with the old value. Unsigned carry means the addition wrapped, so the function fails before it allocates or copies:

cmp  byte ptr [UxKirRefBufferOverflowCheck], al
jz   <old_unguarded_rollback_path>
lea  ebp, [rax + 5]                  ; newCapacity = Capacity + 5
cmp  bp, ax                          ; 16-bit unsigned comparison
jc   <fail>                          ; wrapped: refuse the resize

The fixed 24H2 UlpReferenceBuffers decompiles to the following security-relevant branch:

capacity = *(ushort *)(request + 0x640);
if (capacity <= *(ushort *)(request + 0x642)) {
    new_capacity = capacity + 5;
    if (new_capacity < capacity)
        goto fail;

    new_array = ExAllocatePool3(
        0x42,
        (ulonglong)new_capacity << 3,
        0x52526c55,
        &UxLowPriorityPool,
        1);
    if (new_array == NULL)
        goto fail;

    memmove(
        new_array,
        *(void **)(request + 0x648),
        (ulonglong)*(ushort *)(request + 0x642) << 3);

    if (1 < *(ushort *)(request + 0x640))
        ExFreePoolWithTag(*(void **)(request + 0x648), 0);

    *(ushort *)(request + 0x640) = new_capacity;
    *(void **)(request + 0x648) = new_array;
}

The important change is not a larger allocation. It is refusing a resize whose new capacity cannot be represented by the USHORT field.

Eliminating the false lead

The first automated ranking favored an Accept-Encoding parser change because it was a visually large diff. Follow-up analysis falsified it as the CVE root cause. That path allocates a constant 32-byte node, caps the node count at 100, and performs no attacker-controlled allocation arithmetic. It cannot produce the CWE-190/CWE-122 pair from the advisory.

The genuine fix was easy to miss because it was a near-identical function match. The new UxKirRefBufferOverflowCheck name, the cmp bp, ax; jc guard, and the same delta across two build trains provide much stronger evidence than the larger parser refactor.

Reachability and lab results

The vulnerable append site is on the HTTP/1.x request parsing path and is reachable before authentication. The transport distinction is decisive. With plaintext HTTP, the Windows TCP stack can coalesce segments before HTTP.sys sees them, and HTTP.sys can merge buffers again. With HTTPS, SChannel decrypts each TLS record independently and delivers its plaintext through UlHttpBufferReceiveEvent and UlpCopyIndicatedData.

If a record contains exactly one complete, CRLF-terminated header line, the parser fully consumes that buffer without marking it as a partial parse. UlpAdjustBuffers then advances through its non-merge path, preserving a distinct reference for the record. Repeating this within one unfinished HTTP/1.x header block provides the missing one-record-to-one-reference relationship. Body data does not grow this array, so the records must carry request-line or header data rather than chunked-body fragments.

My live tests against 10.0.17763.8755 predated the public TLS analysis and exercised plaintext segmentation and HTTP.sys body/header variations. They did not crash the target because in-order buffers were merged and out-of-order data remained in TCP’s reassembly queue. Those results describe the plaintext path only; they do not test or falsify the HTTPS trigger. I have not independently reproduced the TLS-triggered bugcheck. The ZDI technical analysis documents the record-per-header behavior, and a public proof of concept implements that request pattern.

Conclusion

CVE-2026-47291 is a 16-bit capacity wrap in HTTP.sys’s per-request buffer-reference array. An unchecked grow-by-five operation can reduce a nearly full capacity to zero. The next resize trusts that wrapped capacity for its allocation but trusts the large count for its copy, producing a kernel pool overflow.

The patch closes the primitive directly with an unsigned wrap check and adds a companion header-count limit. On unpatched systems, concrete exposure requires an HTTPS listener accepting HTTP/1.x and a MaxRequestBytes value large enough for roughly 65,536 short, separately encrypted header records. The theoretical minimum is about 262,144 bytes; practical encodings can require more. The default 16384 configuration is insufficient, and Microsoft’s published pre-update mitigation value is 65534. Installing the June 2026 update remains the proper fix.