http.sys — Feature-gated HTTP header-count resource limit (CWE-400) and 16-bit buffer-growth integer-overflow guard (CWE-190), fixed
KB5093998
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | http_unpatched.sys |
| Patched binary | http_patched.sys |
| Overall similarity | 0.9915 (very high) |
| Matched functions | 3587 |
| Changed functions (above matcher threshold) | 4 |
| Identical functions | 3583 |
| Unmatched (added/removed) | 0 / 0 (plus new leaf helpers RtlUShortAdd, Feature_4071067963__*, Feature_1643097402__*) |
Verdict: The patch delivers two feature-gated hardening changes to the kernel HTTP stack:
- A per-request header-pair count limit (
MaxHeadersCount) enforced inUlParseRequestHeaderPairs. The unpatched driver bounded only total request bytes (MaxRequestBytes) and per-field length (MaxFieldLength); it placed no cap on the number of header pairs processed per request. - A 16-bit integer-overflow guard (
RtlUShortAdd) on the request-buffer array growth path inUlpParseNextRequest. The unpatched driver grew a 16-bit capacity counter with an uncheckedadd, which can wrap at0xFFFF.
Both changes are gated behind WIL feature-staging flags surfaced as Known-Issue-Rollback (KIR) bytes (UxKirMaxHeadersCountLimit, UxKirRefBufferOverflowCheck). When a gate is off, the driver retains the original (unbounded / unchecked) code path, so this is a staged rollout rather than an unconditional fix.
The remaining function-level deltas across the two builds are WPP software-trace message-ID renumbering (a new trace message inserted for the header-count event shifts subsequent IDs), a +4/+8 struct-field offset ripple, and ordinary recompilation codegen churn. None of those carry a behavioral security change.
2. Vulnerability Summary
Finding #1 — Missing HTTP header-pair count limit (Medium / CWE-400)
- Severity: Medium
- Vulnerability class: Uncontrolled Resource Consumption (CWE-400)
- Affected function:
UlParseRequestHeaderPairs(unpatched@ 0x1C000F440, patched@ 0x1C000FAA0). - Entry point: Any TCP connection to an http.sys-backed listener (IIS 80/443, WinRM HTTP 5985, and other HTTP Server API consumers).
Root cause:
UlParseRequestHeaderPairs receives an already-tokenized array of header pairs and a count (the count lives at arg2[1], i.e. *(rdx+8)). It loops over each entry (index = 0 .. count-1), and for each header pair it:
- Validates leading/trailing whitespace on the name and value using the
HttpCharsclassification table. - Computes a hash of the header name (
ebx). - Runs a
switchthat compares the hash against a table of well-known header-name hashes (0xD2571FDB,0x22AA28CC,0x8364ED5D,0x8E947973,0x253F9D6E,0x358E428A,0x34291785, …); on a hash match it confirms with astrncmp.
In the unpatched build there is no limit on the number of header pairs this loop will process. The count is bounded upstream only by MaxRequestBytes (registry-configurable: minimum 0x100, default 0x4000 = 16 KB, maximum 0x1000000 = 16 MB). With minimal headers an attacker can fill that byte budget with a large number of header pairs, forcing the loop to perform O(count) whitespace-classification, hashing, and strncmp work in kernel context per request. Pipelined or concurrent requests raise the aggregate kernel-CPU cost.
The patch adds a count gate at the top of the loop (only when the KIR/feature gate is enabled):
- Reads
MaxHeadersCountfrom the server-configuration structure at+0x1124. - Rejects the request when
header_pair_count > MaxHeadersCount + 4, setting http.sys internal error code0x29and status field0xC0000040, then branching to error cleanup at0x1C00104C3. - Emits a WPP software-trace message (id
0x5E) viaWPP_SF_qLdwhen the limit is exceeded.
The default MaxHeadersCount is max(MaxRequestBytes >> 9, 0xC8) = max(MaxRequestBytes/512, 200); with the default 16 KB MaxRequestBytes this yields a cap of 200 (+4). It is registry-configurable in the range [0x32, 0xFFFF] (50–65535) via the MaxHeadersCount value.
Attacker-reachable call chain:
- Attacker opens a TCP connection to an http.sys-backed port.
- Network receive path dispatches to
UlHttpReceiveHeadersEvent(@ 0x1C0006050). UlHttpReceiveHeadersEventcallsUlParseRequestHeaderPairsat0x1C000634A.UlParseRequestHeaderPairsiterates the header-pair array with no count limit (unpatched).
Finding #2 — Unchecked 16-bit buffer-growth counter (Low / CWE-190)
- Severity: Low (feature-gated defense-in-depth)
- Vulnerability class: Integer Overflow (CWE-190), potentially reaching a heap out-of-bounds write (CWE-787)
- Affected function:
UlpParseNextRequest(@ 0x1C0006C30in both builds).
Root cause:
UlpParseNextRequest maintains a growable array of 8-byte pointers on the connection object: buffer pointer at +0x648, allocated capacity (a 16-bit USHORT) at +0x640, and current used count (also 16-bit) at +0x642. When the array is full it reallocates (pool tag "UlRR" = 0x52526C55, ExAllocatePool3), copies the old contents with memmove, frees the old buffer, and increases the capacity by 5.
In the unpatched build the capacity increase is an unchecked 16-bit add:
add word ptr [rsi+640h], 5 ; wraps silently at 0xFFFF
If the capacity counter is driven up to near 0xFFFF, this add wraps to a small value, after which the recorded capacity no longer reflects the real allocation and subsequent index writes (mov [rax+rcx*8], r15) can fall outside the allocated array.
The patch (when its gate is enabled) replaces the unchecked increment with the new helper RtlUShortAdd (@ 0x1C001FBB8), which performs capacity + 5 with a 16-bit overflow check and returns STATUS_INTEGER_OVERFLOW (0xC0000095) on wrap; the caller bails out on the negative status instead of reallocating.
Reaching the wrap requires on the order of 0xFFFF / 5 ≈ 13107 growth events (that many entries accumulated in this per-connection array), so this is a hard-to-reach hardening rather than a readily triggerable primitive. It is delivered feature-gated, with the old unchecked path retained when the gate is off.
3. Pseudocode Diff
UlParseRequestHeaderPairs — header-pair loop (before vs. after)
// ─── UNPATCHED @ 0x1C000F440 ─────────────────────────────────────
int UlParseRequestHeaderPairs(void *req, int64_t *args, uint32_t flags, uint8_t *status)
{
// args[1] == *(args+8) == number of header pairs already tokenized
// No count gate — the loop runs over all args[1] entries.
for (index = 0; index < (uint32_t)args[1]; index++) {
// whitespace-trim name/value via HttpChars table
// hash(name) → switch(hash) { case 0xD2571FDB: ... strncmp ... }
}
}
// ─── PATCHED @ 0x1C000FAA0 ───────────────────────────────────────
int UlParseRequestHeaderPairs(void *req, int64_t *args, uint32_t flags, uint8_t *status)
{
// ── NEW: feature/KIR-gated header-count gate ──
if (UxKirMaxHeadersCountLimit != 0) { // gate byte (0 = old path)
void *cfg = *(void **)(conn_state + 0x3F8);
uint32_t limit = *(uint32_t *)(cfg + 0x1124); // MaxHeadersCount (new field)
if ((uint32_t)args[1] > limit + 4) {
WPP_SF_qLd(0x5E, ...); // WPP trace, msg id 0x5E
error_code = 0x29; // http.sys internal error
status_field = 0xC0000040; // stored NTSTATUS value
*status = 0;
goto error_cleanup; // 0x1C00104C3
}
}
for (index = 0; index < (uint32_t)args[1]; index++) {
// identical per-header work (whitespace-trim, hash, switch/strncmp)
}
}
Note: args[1] is an input count, not a value incremented inside this loop. The per-header pool allocation / list insertion happens in the upstream tokenizer, not in this function.
UlpParseNextRequest — buffer-growth guard (before vs. after)
// ─── UNPATCHED @ 0x1C0006C30 (buffer full → grow) ────────────────
if (used_count >= capacity /* [conn+0x640], USHORT */) {
new = ExAllocatePool3(..., capacity*8 + 0x28, "UlRR");
memmove(new, old /* [conn+0x648] */, used_count*8);
if (capacity > 1) ExFreePoolWithTag(old, 0);
*(uint16_t *)(conn+0x640) += 5; // ← unchecked 16-bit add (wraps at 0xFFFF)
*(void **)(conn+0x648) = new;
}
// ─── PATCHED @ 0x1C0006C30 ───────────────────────────────────────
if (used_count >= capacity) {
if (UxKirRefBufferOverflowCheck != 0) { // gate on → checked path
USHORT newcap;
if ((int)RtlUShortAdd(capacity, /*5*/, &newcap) < 0) // STATUS_INTEGER_OVERFLOW
goto error; // bail, no realloc
new = ExAllocatePool3(..., newcap*8, "UlRR");
memmove(new, old, used_count*8);
if (capacity > 1) ExFreePoolWithTag(old, 0);
*(uint16_t *)(conn+0x640) = newcap; // checked capacity
*(void **)(conn+0x648) = new;
} else {
// old unchecked path retained (capacity*8 + 0x28, then += 5)
}
}
UxReadHttp11ParserSettings — configuration read (patched only path)
// UxReadHttp11ParserSettings (unpatched @ 0x1C0127168, patched @ 0x1C0127178)
// MaxRequestBytes: min 0x100, default 0x4000, max 0x1000000 (unchanged)
// MaxFieldLength : moved from cfg+0x1124 (unpatched) to cfg+0x1128 (patched)
if (UxKirMaxHeadersCountLimit != 0) { // NEW, gated read
uint32_t def = MaxRequestBytes >> 9; // /512
if (def < 0xC8) def = 0xC8; // floor 200
UxReadBoundedULongSetting(regKey, L"MaxHeadersCount",
def, // default
0x32, // min = 50
0xFFFF, // max = 65535
3, // DWORD
cfg + 0x1124 /* MaxHeadersCount */);
}
Adding MaxHeadersCount at cfg+0x1124 pushed MaxFieldLength from cfg+0x1124 to cfg+0x1128; this is the source of the struct-offset churn observed in several other functions.
4. Assembly Analysis
Patched UlParseRequestHeaderPairs — the new count gate (0x1C000FB13–0x1C000FB71)
00000001C000FB13 cmp cs:UxKirMaxHeadersCountLimit, r12b ; r12b = 0; gate byte
00000001C000FB1D jz short loc_1C000FB76 ; gate off → old path
00000001C000FB1F mov rax, [rbx+3F8h] ; rax = server config
00000001C000FB26 mov r9d, [rdi] ; r9d = header-pair count (*(args+8))
00000001C000FB29 mov edx, [rax+1124h] ; edx = MaxHeadersCount (new field)
00000001C000FB2F lea eax, [rdx+4] ; eax = MaxHeadersCount + 4
00000001C000FB32 cmp r9d, eax
00000001C000FB35 jbe short loc_1C000FB7A ; count <= limit+4 → continue
00000001C000FB37 movzx eax, byte ptr cs:WPP_MAIN_CB.Queue+1Ch
00000001C000FB3E test al, al
00000001C000FB40 jns short loc_1C000FB5A
00000001C000FB46 mov ecx, 5Eh ; WPP trace message id 0x5E
00000001C000FB55 call WPP_SF_qLd
00000001C000FB5A mov r14d, 29h ; http.sys internal error code
00000001C000FB60 mov [rbp+4Fh+var_C0], 0C0000040h ; stored NTSTATUS value
00000001C000FB6E mov [r15], r12b ; *status = 0
00000001C000FB71 jmp loc_1C00104C3 ; error cleanup
Unpatched UlParseRequestHeaderPairs reaches its header loop directly (0x1C000F4CD xor r15d,r15d / 0x1C000F4D0 cmp [r12+8], ebx / 0x1C000F4D5 jbe loc_1C000F6E6) with no gate byte, no server-config read, and no MaxHeadersCount comparison.
Loop-termination check — present in BOTH builds
Patched 0x1C000FBAF cmp [rdi], ecx / 0x1C000FBB1 jbe loc_1C001024A (with ecx = 0) is the loop-entry/termination guard on the header-pair count. It corresponds exactly to unpatched 0x1C000F4D0 cmp [r12+8], ebx / jbe loc_1C000F6E6. It is not an added count check.
Host / cookie occurrence counters — saturation present in BOTH builds
The per-name occurrence counters are already saturated in the unpatched build; the patch does not add this. Unpatched:
; 'host' counter (var_A0) ; 'cookie' counter (var_A4)
00000001C0029F62 mov eax, [rbp+4Fh+var_A0] 00000001C0029FAC mov eax, [rbp+4Fh+var_A4]
00000001C0029F65 cmp eax, 0FFFFFFFFh 00000001C0029FAF cmp eax, 0FFFFFFFFh
00000001C0029F68 jnb short loc_1C0029F6F 00000001C0029FB2 jnb loc_1C000F6CC
00000001C0029F6A inc eax 00000001C0029FB8 inc eax
00000001C0029F6C mov [rbp+4Fh+var_A0], eax 00000001C0029FBA mov [rbp+4Fh+var_A4], eax
Patched uses the same cmp X, 0FFFFFFFFh / jnb / inc idiom on the reallocated registers/slots (host in r12d @ 0x1C000FF4B, cookie in var_B0 @ 0x1C000FF9D). This is register/slot reallocation from recompilation, not a new guard.
Patched UlpParseNextRequest — integer-overflow guard (0x1C0006FE8)
00000001C0006FE8 cmp cs:UxKirRefBufferOverflowCheck, r12b ; r12b = 0; gate byte
00000001C0006FEF jz loc_1C0007087 ; gate off → old unchecked path
00000001C0006FF5 lea r8, [rbp+57h+pusResult]
00000001C0006FF9 call RtlUShortAdd ; NEW: checked capacity+5
00000001C0006FFE test eax, eax
00000001C0007000 js loc_1C0007127 ; STATUS_INTEGER_OVERFLOW → bail
...
00000001C0007028 call cs:__imp_ExAllocatePool3
00000001C0007056 call memmove
00000001C000707E mov [rdi+640h], ax ; store CHECKED new capacity
RtlUShortAdd @ 0x1C001FBB8 (new, absent in unpatched):
00000001C001FBB8 lea eax, [rcx+5] ; capacity + 5
00000001C001FBBB cmp ax, cx ; 16-bit overflow?
00000001C001FBBE jb short loc_1C001FBC5
00000001C001FBC0 movzx edx, ax ; no overflow → result
00000001C001FBC5 mov edx, 0FFFFh ; overflow → saturate
00000001C001FBCA sbb eax, eax
00000001C001FBCC mov [r8], dx ; *result
00000001C001FBD0 and eax, 0C0000095h ; STATUS_INTEGER_OVERFLOW on overflow, else 0
00000001C001FBD5 retn
Unpatched UlpParseNextRequest grows the counter with an unchecked add at 0x1C000704E:
00000001C000704E add word ptr [rsi+640h], 5 ; wraps silently at 0xFFFF
00000001C0007056 mov [rsi+648h], r13
Feature-gate initialization — UxStartEnvironmentModule @ 0x1C01767B0 (patched)
00000001C0176898 call Feature_4071067963__private_IsEnabledDeviceUsage
00000001C017689F ...
00000001C01768AA setnz cs:UxKirMaxHeadersCountLimit ; gate for Finding #1
00000001C017688A call Feature_1643097402__private_IsEnabledDeviceUsage
00000001C0176891 setnz cs:UxKirRefBufferOverflowCheck ; gate for Finding #2
Both gate bytes are new in the patched build; the unpatched build defines neither.
5. Trigger Conditions
Finding #1 (header-pair count)
- Open a TCP connection to any port served by http.sys.
- Send a valid request line and
Host:header, followed by a large number of minimal headers (Xi: a\r\n), staying withinMaxRequestBytes(default 16 KB; a higher configured value permits more headers). - No authentication is required (pre-auth, anonymous).
- On the unpatched driver the header-pair loop processes every header pair (whitespace-classify + hash +
strncmp) in kernel context; pipelined/concurrent requests raise aggregate kernel-CPU cost. - On a patched driver with the gate enabled, a request whose header-pair count exceeds
MaxHeadersCount + 4is rejected (error code0x29, status0xC0000040) and a WPP trace message (id0x5E) is emitted.
Finding #2 (16-bit buffer-growth counter)
Reaching the wrap requires driving the per-connection UlRR array capacity counter (conn+0x640) up toward 0xFFFF — on the order of ~13107 growth events accumulated on one connection. This is a high-effort path and is delivered feature-gated.
6. Exploit Primitive & Development Notes
Finding #1 primitive: Remote, pre-authentication kernel-CPU consumption bounded by MaxRequestBytes. Each byte of attacker input maps to a bounded amount of per-header kernel work (whitespace classification + hash + strncmp). This is a resource-consumption / availability issue; the diff shows no memory-corruption primitive for this finding.
Finding #2 primitive: A 16-bit capacity counter that wraps under extreme, high-effort input volume could cause the recorded capacity to understate the real allocation, potentially leading to an out-of-bounds index write into the UlRR array. The diff demonstrates only the arithmetic and its guard; it does not demonstrate a working corruption chain, and the required iteration count makes this hard to reach.
No KASLR/info-leak, SMEP/SMAP/CFG/HVCI-bypass, pool-spray, or code-execution primitive is demonstrated by this diff. Any such escalation would require independent research beyond what these two builds show.
| Mitigation | Relevance to these findings |
|---|---|
MaxRequestBytes |
Bounds Finding #1's per-request header count via the total byte budget. |
| Feature staging / KIR | Both fixes are gated; effectiveness depends on the feature being enabled in the field. |
| KASLR / SMEP / CFG / HVCI | Not relevant to the demonstrated availability / arithmetic issues. |
7. Debugger Notes
Setup: kernel debugger attached; symbols matching the specific build. Function addresses differ between the two builds (the image is relocated), so resolve by symbol name.
Finding #1 — UlParseRequestHeaderPairs
- Break on
http!UlParseRequestHeaderPairs. - Register convention at entry (unpatched
@ 0x1C000F440):rcx= request object ([rcx+0x18]= connection state),rdx= header-pair array descriptor (*(rdx+8)= header-pair count),r8= flags,r9= status output byte. - Watch
*(rdx+8)(the header-pair count) at entry — this is the value the patched gate compares againstMaxHeadersCount+4.
Key offsets:
| Offset | Meaning |
|---|---|
req+0x18 |
connection-state pointer |
conn_state+0x3F8 |
server-config pointer (patched gate reads MaxHeadersCount from it) |
cfg+0x1124 |
MaxHeadersCount in patched; MaxFieldLength in unpatched (MaxFieldLength moves to cfg+0x1128 in patched) |
UxKirMaxHeadersCountLimit |
feature/KIR gate byte (patched only); must be non-zero for enforcement |
Finding #2 — UlpParseNextRequest
| Offset | Meaning |
|---|---|
conn+0x640 |
16-bit allocated capacity of the UlRR array |
conn+0x642 |
16-bit used count |
conn+0x648 |
array buffer pointer (8-byte entries) |
UxKirRefBufferOverflowCheck |
feature/KIR gate byte (patched only); when set, RtlUShortAdd guards the capacity growth |
Trigger setup (Finding #1, user-mode)
import socket
HOST, PORT = "target.example.com", 80 # or 443/TLS, 5985 for WinRM
NHEADERS = 4000 # bounded by MaxRequestBytes (default ~16 KB)
payload = b"GET / HTTP/1.1\r\nHost: " + HOST.encode() + b"\r\n"
payload += b"".join(b"X%d: a\r\n" % i for i in range(NHEADERS))
payload += b"\r\n"
s = socket.socket(); s.connect((HOST, PORT))
s.sendall(payload)
print(s.recv(4096)); s.close()
Tune NHEADERS to the target's configured MaxRequestBytes. On a patched target with the gate enabled the request is rejected once the header-pair count exceeds MaxHeadersCount + 4.
Expected observations (Finding #1)
| Item | Unpatched | Patched (gate enabled) |
|---|---|---|
| Header-pair count processed | Up to the MaxRequestBytes-bounded count |
Capped at MaxHeadersCount+4 |
| Per-request kernel-CPU work | O(count) whitespace/hash/strncmp |
Bounded |
| WPP trace | None | Message id 0x5E via WPP_SF_qLd |
8. Changed Functions — Full Triage
Security-relevant
-
UlParseRequestHeaderPairs(unpatched@ 0x1C000F440, patched@ 0x1C000FAA0) — similarity 0.753. Adds the feature/KIR-gatedMaxHeadersCountcount gate at0x1C000FB13–0x1C000FB71(rejects whenheader_pair_count > MaxHeadersCount + 4). Thecmp [rdi], ecx / jbeat0x1C000FBAFis the loop-termination guard (present in both builds), and the host/cookie occurrence-counter saturation is present in both builds; neither is a new check. Finding #1. -
UlpParseNextRequest(@ 0x1C0006C30, both builds) — similarity 0.9747. Adds a feature/KIR-gated 16-bit integer-overflow guard: the uncheckedadd word ptr [conn+0x640], 5is replaced (whenUxKirRefBufferOverflowCheckis set) with a checkedRtlUShortAddthat returnsSTATUS_INTEGER_OVERFLOWand bails before reallocating. Finding #2. (The remaining delta in this function is WPP trace-message-ID renumbering, e.g.101→102, and stack-frame reshuffle — non-behavioral.)
Fix-supporting (configuration / feature-gate wiring)
-
UxReadHttp11ParserSettings(unpatched@ 0x1C0127168, patched@ 0x1C0127178) — similarity 0.9165. Adds the gated read of theMaxHeadersCountregistry value viaUxReadBoundedULongSetting(defaultmax(MaxRequestBytes/512, 200), range[0x32, 0xFFFF], stored atcfg+0x1124). Also shiftsMaxFieldLengthfromcfg+0x1124tocfg+0x1128. -
UxStartEnvironmentModule(@ 0x1C01767B0, both builds) — similarity 0.9284. Initializes the two new gate bytes from WIL feature-staging:UxKirMaxHeadersCountLimitfromFeature_4071067963__private_IsEnabledDeviceUsageandUxKirRefBufferOverflowCheckfromFeature_1643097402__private_IsEnabledDeviceUsage. This is the feature-detection plumbing for Findings #1 and #2, not cosmetic.
Non-behavioral churn (not security-relevant)
Beyond the four functions above, many functions differ only in WPP software-trace message-ID renumbering (the newly inserted header-count trace message shifts subsequent IDs, and one trace provider GUID was reorganized), in the MaxHeadersCount struct-offset ripple, or in ordinary recompilation register/variable reallocation. Spot-checked examples confirmed as churn: UlHttpIngressDisconnectEvent (trace GUID/id renumber), UlpCacheMdlReadCompleteWorker (variable reallocation), UxStartEnvironmentFacet (reordered, pre-existing HTTP/2 config reads with +8 offset shift). New leaf helpers RtlUShortAdd, Feature_4071067963__*, and Feature_1643097402__* are the only genuinely added functions.
9. Unmatched Functions
| Removed | Added |
|---|---|
| 0 | RtlUShortAdd, Feature_4071067963__private_IsEnabled{DeviceUsage,Fallback}, Feature_1643097402__private_IsEnabled{DeviceUsage,Fallback} |
No functions were removed. The added items are the overflow-guard helper and the WIL feature-staging stubs for the two gates.
10. Confidence & Caveats
Confidence: High for the mechanism; Medium/Low for real-world impact.
- Both changes are directly visible in the disassembly: a
MaxHeadersCountcount gate and aRtlUShortAddoverflow guard, each behind a WIL/KIR feature byte, with the old path retained in the else branch. - The call chain for Finding #1 (network receive →
UlHttpReceiveHeadersEvent @ 0x1C0006050→UlParseRequestHeaderPairs) is confirmed in both builds.
Things to verify before relying on either fix or publishing any PoC:
- Feature-gate state.
UxKirMaxHeadersCountLimitandUxKirRefBufferOverflowCheckare set from WIL feature-staging (Feature_4071067963,Feature_1643097402). Whether either fix is active on a given install depends on the feature rollout state, not on the presence of the code alone. MaxRequestByteson the target determines both the pre-patch header-count ceiling and the defaultMaxHeadersCount(max(MaxRequestBytes/512, 200)).- Finding #2 reachability is high-effort (~13107 growth events on one connection); no working corruption chain is demonstrated by the diff.
- No DoS-to-RCE escalation is shown by these two builds; treat Finding #1 as an availability issue and Finding #2 as an arithmetic-hardening change unless independent analysis proves more.
- Struct-offset and WPP-ID churn ripple through many functions; do not read those as additional behavioral fixes.