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.
- Published
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:
- A vulnerable
http.sysbuild with the reference-array implementation. The 1809 binary10.0.17763.8755has it;10.0.17763.8880contains the fix. An older 17763 RTM-era lab binary used a linked-list design and did not contain this exact primitive. - 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.1through ALPN to keep the connection on the vulnerable parser. MaxRequestBytesmust 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 default16384-byte value is insufficient. The DWORD is stored atHKLM\SYSTEM\CurrentControlSet\Services\HTTP\Parameters\MaxRequestBytes; changing it requires restarting the HTTP service or the system. Keeping it at or below65534follows Microsoft’s published pre-update mitigation guidance.- 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
UlpAdjustBufferstakes 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.