http.sys — KIR/feature-staged HTTP/2 MaxHeadersCount limit
KB5094126
1. Overview
| Item | Value |
|---|---|
| Unpatched binary | http_unpatched.sys |
| Patched binary | http_patched.sys |
| Overall similarity | 0.9915 (effectively identical except for a targeted, feature-gated change) |
| Matched functions | 4402 |
| Changed functions | 8 |
| Identical functions | 4394 |
| Unmatched (either direction) | 0 / 0 |
Verdict: The patch adds a new, configurable MaxHeadersCount limit to the HTTP/2 request header-processing function (UlParseRequestHeaderPairs @ 0x1400C7BD0 unpatched → 0x1400C7E20 patched). The entire new code path is gated behind the Known-Issue-Rollback / feature-staging flag UxKirMaxHeadersCountLimit, which is set at driver init from the WIL feature gate Feature_3936850235. The pre-patch code has no exploitable resource-exhaustion primitive: per-header work is constant, total work is linear in the header count, and the header count is already bounded by the existing MaxRequestBytes cap. This is feature-staged hardening / serviceability plumbing, not a fix for an exploitable vulnerability.
2. Change Summary
| Field | Value |
|---|---|
| Severity | None (no security-relevant change) |
| Class | Feature-gated resource-limit hardening (new configurable HTTP/2 header-count cap) |
| Affected function (unpatched) | UlParseRequestHeaderPairs @ 0x1400C7BD0 (patched: 0x1400C7E20) |
| Reachability | HTTP/2 header processing, reached via UlHttpReceiveHeadersEvent @ 0x1400C6CB0 (call at 0x1400C6FDE) |
What the code actually does
UlParseRequestHeaderPairs receives a decoded array of HPACK header entries (arg2, with the 32-bit count at arg2+0x8) and walks it. For each entry it trims whitespace, identifies the four HTTP/2 pseudo-headers (:method, :scheme, :path, :authority) by length + strncmp, computes a name hash via HashCountedStringNoCaseA @ 0x14006D5A0, and dispatches on that hash to a small fixed set of known/sensitive header handlers with duplicate tracking held in fixed stack slots. All per-entry work is constant time; the loop is O(n) in the number of entries.
The number of entries is already bounded by the request-size cap MaxRequestBytes: each HPACK entry costs at least a few bytes, so a default 16 KB request yields on the order of a few thousand entries, and the per-entry work is cheap and constant. There is no super-linear behavior (name matching is hash-dispatched, duplicate tracking uses fixed slots), so there is no algorithmic-complexity amplification and no attacker-controlled unbounded work.
The patch
The patched build adds, before the loop:
- A feature-flag gate on
UxKirMaxHeadersCountLimit: the new logic runs only when the flag is non-zero. - A read of
MaxHeadersCountfrom the server config at*(*(obj+0x18)+0x448)+0x1128, populated byUxReadHttp11ParserSettings(0x1400BB8B0unpatched /0x1400BBA58patched). - A count check:
if (header_count > MaxHeadersCount + 4) { fault code = 0x14; return 0xC0000040 (STATUS_FAIL_CHECK); }.
MaxHeadersCount is read from the MaxHeadersCount registry value with default max(MaxRequestBytes >> 9, 0xC8) (default 200) and bounds min 0x32 / max 0xFFFF. The +4 tolerance covers the four mandatory HTTP/2 pseudo-headers.
Why this is not a security-relevant fix
- The gate is a
UxKir*Known-Issue-Rollback / feature-staging flag set at init via the WIL featureFeature_3936850235(setnz cs:UxKirMaxHeadersCountLimitat0x1400BC95E, immediately aftercall Feature_3936850235__private_IsEnabledDeviceUsageNoInlineat0x1400BC947). This is the standard hallmark of staged servicing behavior that Microsoft can roll back, not an unconditional security fix. - The pre-patch path is linear and already bounded by
MaxRequestBytes; there is no exploitable resource-exhaustion primitive that the check closes. - No memory-safety difference exists in the diff (no OOB, no overflow, no UAF). The count check only short-circuits into an early error return.
3. Pseudocode Diff
// ============================================================
// UlParseRequestHeaderPairs 0x1400C7BD0 (unpatched) / 0x1400C7E20 (patched)
// arg1 (rcx) : request/connection object; obj = *(arg1+0x18)
// arg2 (rdx) : header array; arg2[0] = entries ptr, *(arg2+0x8) = entry count
// arg3 (r8d) : flags (bit 0x1000 captured and stored)
// ============================================================
// ---------- UNPATCHED (0x1400C7BD0) ----------
NTSTATUS ProcessH2Headers(void *reqCtx, H2HeaderArr *arg2, uint32_t flags) {
obj = *(reqCtx + 0x18);
flag1000 = flags & 0x1000; // captured, stored locally
// No header-count cap. Loop bound is the natural entry count.
for (uint32_t i = 0; i < *(uint32_t*)(arg2 + 8); i++) {
trim name/value whitespace;
match :method / :scheme / :path / :authority by length + strncmp;
hash = HashCountedStringNoCaseA(name); // 0x14006D5A0
dispatch on hash to known/sensitive header handlers; // constant set
track duplicates in fixed stack slots;
}
return STATUS_SUCCESS;
}
// ---------- PATCHED (0x1400C7E20) ----------
NTSTATUS ProcessH2Headers(void *reqCtx, H2HeaderArr *arg2, uint32_t flags) {
obj = *(reqCtx + 0x18);
flag1000 = flags & 0x1000;
if (UxKirMaxHeadersCountLimit != 0) { // +NEW feature gate
uint32_t count = *(uint32_t*)(arg2 + 8); // +NEW
uint32_t maxHdr = *(uint32_t*)(*(void**)(obj + 0x448) + 0x1128); // +NEW
if (count > maxHdr + 4) { // +NEW
faultCode = 0x14; // +NEW
return 0xC0000040; // STATUS_FAIL_CHECK // +NEW
}
}
for (uint32_t i = 0; i < *(uint32_t*)(arg2 + 8); i++) { /* same as before */ }
return STATUS_SUCCESS;
}
The pre-loop block is the entire functional delta, and it is inert unless UxKirMaxHeadersCountLimit is non-zero.
4. Assembly Analysis
Patched build — added feature-gated count check (inside UlParseRequestHeaderPairs @ 0x1400C7E20)
00000001400C7ED1 mov rcx, [rbp+4Fh+var_88] ; rcx = obj (= *(arg1+0x18))
00000001400C7EDE and ebx, 1000h ; capture arg3 & 0x1000
00000001400C7EE4 cmp cs:UxKirMaxHeadersCountLimit, r10b ; r10b=0; feature gate
00000001400C7EEB mov [rbp+4Fh+arg_10], ebx
00000001400C7EEE mov [rax], r10d
00000001400C7EF1 jz loc_1400C8068 ; flag==0 -> skip check, enter loop
00000001400C7EF7 mov rax, [rcx+448h] ; rax = server config
00000001400C7EFE mov r9d, [r12+8] ; r9d = header count (arg2[1])
00000001400C7F03 mov r8d, [rax+1128h] ; r8d = MaxHeadersCount
00000001400C7F0A lea eax, [r8+4] ; eax = MaxHeadersCount + 4
00000001400C7F0E cmp r9d, eax ; count vs limit+4
00000001400C7F11 jbe loc_1400C8068 ; within limit -> enter loop
; fall-through: set fault code and error status, then return
00000001400C7F48 mov [rbp+4Fh+var_AC], 14h ; fault code 0x14
00000001400C7F4F mov [rbp+4Fh+var_B0], 0C0000040h ; STATUS_FAIL_CHECK
...
00000001400C8049 mov eax, [rbp+4Fh+var_B0] ; return value
00000001400C8066 retn
loc_1400C8068 is the loop-entry target (mov r15d, 1 at 0x1400C8068), i.e. both the "gate disabled" and "within limit" branches fall through to the same unchanged processing loop.
Unpatched build (UlParseRequestHeaderPairs @ 0x1400C7BD0)
The prologue captures the flag and goes straight to the loop bound — there is no feature flag read, no config read, and no count comparison:
00000001400C7C7B and ebx, 1000h ; capture arg3 & 0x1000
00000001400C7C81 mov [rbp+4Fh+arg_10], ebx ; store flag
00000001400C7C84 mov [rax], r15d
00000001400C7C87 mov eax, r15d ; loop counter = 0
00000001400C7C8A mov r15d, 1
...
00000001400C7C9C cmp eax, [r12+8] ; i < header count (loop bound only)
00000001400C7CA1 jnb loc_1400C8336 ; exit when done
Consequences of the absent sequence:
- No read of
[obj+0x448](server config) in this path. - No read of
[config+0x1128](MaxHeadersCount). - No
cmp count, MaxHeadersCount+4— the only loop terminator is the natural entry count, which is itself bounded byMaxRequestBytes.
Supporting config change (UxReadHttp11ParserSettings, patched @ 0x1400BBA58)
00000001400BBB06 ...
00000001400BBB11 cmp cs:UxKirMaxHeadersCountLimit, r12b ; gate
00000001400BBB1E jz short loc_1400BBB61 ; feature off -> skip
00000001400BBB20 shr r8d, 9 ; MaxRequestBytes >> 9
00000001400BBB24 lea rdx, aMaxheaderscoun ; "MaxHeadersCount"
00000001400BBB2B mov eax, 0C8h ; floor 200
00000001400BBB36 cmp r8d, eax
00000001400BBB3C cmovb r8d, eax ; default = max(MaxReqBytes>>9, 200)
00000001400BBB40 lea rax, [rsi+1128h] ; write target = config+0x1128
00000001400BBB5C call UxReadBoundedULongSetting ; read "MaxHeadersCount" reg value
Prior config fields are shifted (e.g. the old field is now written to [rsi+0x112C]) to make room for the new MaxHeadersCount at 0x1128.
5. Reachability
- HTTP/2 header processing reaches
UlParseRequestHeaderPairsviaUlHttpReceiveHeadersEvent@0x1400C6CB0(call UlParseRequestHeaderPairsat0x1400C6FDE). The pseudo-header set (:method,:scheme,:path,:authority) confirms this is the HTTP/2 header path. - The function walks the decoded HPACK entry array; the entry count lives at
arg2+0x8. - In the patched build, when
UxKirMaxHeadersCountLimitis non-zero, a request whose entry count exceedsMaxHeadersCount + 4is rejected withSTATUS_FAIL_CHECK(0xC0000040) before the loop runs. When the flag is zero, or in the unpatched build, the loop runs over all entries. - The entry count is bounded by
MaxRequestBytes, and the per-entry cost is constant, so neither build exposes an attacker-controlled unbounded workload.
6. Impact Assessment
- Type: New configurable limit (defense-in-depth cap on HTTP/2 header count), feature-staged behind a KIR gate.
- Attacker control before patch: The number of loop iterations equals the HPACK entry count, but that count is bounded by
MaxRequestBytesand each iteration is constant time. The result is ordinary linear parsing work proportional to request size, with no amplification. - No memory-safety change: The diff adds only an early error return; there is no write/OOB/UAF difference between the builds.
- Net effect: The patch lets an administrator (or the default
max(MaxRequestBytes>>9, 200)) cap the HTTP/2 header count and reject over-count requests early. This is a robustness/serviceability improvement, not the closure of an exploitable flaw.
Factors that make this a non-issue as a vulnerability
| Factor | Effect |
|---|---|
MaxRequestBytes |
Already bounds the encoded request, and therefore the entry count, before this function runs. |
| Per-entry cost | Constant: hash-dispatched name matching + fixed-slot duplicate tracking. No O(n^2) behavior. |
| KIR / WIL gate | Whole check is inert unless UxKirMaxHeadersCountLimit is set; classic staged-servicing pattern. |
| Memory safety | Unchanged; the added code only short-circuits to an error return. |
7. Verification Anchors
Addresses and data confirmed directly in both builds.
Function entries
| Item | Unpatched | Patched |
|---|---|---|
UlParseRequestHeaderPairs |
0x1400C7BD0 |
0x1400C7E20 |
UlHttpReceiveHeadersEvent (caller) |
0x1400C6CB0 |
(calls parser at 0x1400C6FDE unpatched) |
HashCountedStringNoCaseA (per-entry name hash) |
0x14006D5A0 |
0x14006D5A0 |
UxReadHttp11ParserSettings (config loader) |
0x1400BB8B0 |
0x1400BBA58 |
Key instructions in the patched delta
| Address (patched) | Instruction | Meaning |
|---|---|---|
0x1400C7EE4 |
cmp cs:UxKirMaxHeadersCountLimit, r10b |
Feature gate (r10b = 0). |
0x1400C7EF1 |
jz loc_1400C8068 |
Flag off -> skip check, enter loop. |
0x1400C7EF7 |
mov rax, [rcx+448h] |
Load server config. |
0x1400C7EFE |
mov r9d, [r12+8] |
Header count. |
0x1400C7F03 |
mov r8d, [rax+1128h] |
MaxHeadersCount. |
0x1400C7F0A |
lea eax, [r8+4] |
Limit = MaxHeadersCount + 4. |
0x1400C7F0E |
cmp r9d, eax |
Count vs limit. |
0x1400C7F11 |
jbe loc_1400C8068 |
Within limit -> enter loop. |
0x1400C7F4F |
mov [rbp+var_B0], 0C0000040h |
STATUS_FAIL_CHECK on over-count. |
Feature-flag origin
| Address | Instruction | Meaning |
|---|---|---|
0x1400BC947 |
call Feature_3936850235__private_IsEnabledDeviceUsageNoInline |
WIL feature-staging query. |
0x1400BC95E |
setnz cs:UxKirMaxHeadersCountLimit |
Flag set from the WIL result at driver init. |
The symbol UxKirMaxHeadersCountLimit does not exist in the unpatched binary (0 occurrences); it is introduced only in the patched build.
Struct / offset notes
arg1 (rcx) : request/connection object
+0x18 : obj (sub-object saved by both builds at prologue)
obj
+0x448 : ServerConfig*
ServerConfig
+0x1128 : uint32_t MaxHeadersCount (patched only; written by UxReadHttp11ParserSettings)
default max(MaxRequestBytes >> 9, 0xC8),
reg value "MaxHeadersCount", bounds 0x32..0xFFFF
+0x112C : field shifted +4 in the patched build to make room
arg2 (rdx)
+0x00 : HeaderEntry* (decoded HPACK entries)
+0x08 : uint32_t entry count
Feature flag
UxKirMaxHeadersCountLimit (byte) : 0 = check disabled, non-zero = check enabled
set in UxStartEnvironmentModule @ 0x1400BC95E
from WIL feature Feature_3936850235
8. Changed Functions — Full Triage
| Function | Sim. | Change type | Note |
|---|---|---|---|
UlParseRequestHeaderPairs (0x1400C7BD0) |
0.917 | feature-gated behavioral | Adds the MaxHeadersCount + 4 count check gated on UxKirMaxHeadersCountLimit, returning STATUS_FAIL_CHECK. Inert unless the flag is set; loop body unchanged. Not an exploitable-vulnerability fix (see sections 2 and 6). |
UxReadHttp11ParserSettings (0x1400BB8B0) |
0.9114 | behavioral | Config loader. When the flag is set, reads the MaxHeadersCount registry value (default max(MaxRequestBytes>>9, 0xC8), bounds 0x32..0xFFFF) into ServerConfig+0x1128; prior fields shift +4. Supporting infrastructure for the new limit. |
UxDuoFaultCodeFromHttpReceiveFault (0x1400B9BB8) |
0.819 | behavioral | Lookup-table update: adds fault-code case 0x14 (the new over-count fault), reads UxKirMaxHeadersCountLimit; remaining cases shift by one index. Telemetry/plumbing. |
UxQuicErrorCodeFromHttpReceiveFault (0x1400FE03C) |
0.849 | behavioral | Lookup-table update: adds case 0x14; case 4 changed from constant 0x4b to arg1+0x47; case 9 also arg1+0x47. HTTP/3 (QUIC) fault mapping plumbing. |
UxpPktMonFaultToDropStatus (0x14014C4F8) |
0.857 | behavioral | Lookup-table update: adds case 0x14 returning 0xD71 when the flag is set; case reorder. PacketMon drop-reason plumbing. |
UxStartEnvironmentModule (0x1400BC5E0) |
0.955 | behavioral | Driver init. Sets UxKirMaxHeadersCountLimit (and other UxKir* flags) from WIL feature gates; reordered init; recompiled call targets. This is where the flag is established. |
UlpParseNextRequest (0x140029C20) |
0.940 | cosmetic | Stack/variable renames only. No logic change. |
UxPktMonTraceDropHttpReceiveWithFault (0x14014C1E0) |
0.985 | register_allocation | Instruction reordering (lookup-table call hoisted). No semantic change. |
The three lookup-table functions (UxDuoFaultCodeFromHttpReceiveFault, UxQuicErrorCodeFromHttpReceiveFault, UxpPktMonFaultToDropStatus) share one pattern: a new fault case 0x14 for the over-count outcome, gated on UxKirMaxHeadersCountLimit. These are telemetry / error-mapping plumbing for the new feature and are not independently security-relevant.
9. Unmatched Functions
None added, none removed. The patch is a pure in-place modification of existing functions.
- No removed sanitizers or checkers.
- No added mitigation helpers — the new logic lives inline in
UlParseRequestHeaderPairsand its supporting config/telemetry functions.
10. Confidence & Caveats
Overall confidence: High (that this is not a security-relevant vulnerability fix).
- The assembly delta is unambiguous: a feature-gated count comparison (
count > MaxHeadersCount + 4) that is entirely absent from the unpatched build, plus supporting config parsing and fault/telemetry plumbing. - The whole path is gated behind the Known-Issue-Rollback / WIL feature-staging flag
UxKirMaxHeadersCountLimit, set at init fromFeature_3936850235. This is the standard staged-servicing pattern, not an unconditional security fix. - The pre-patch code has no exploitable resource-exhaustion primitive: per-entry work is constant, total work is linear in the entry count, and the entry count is already bounded by
MaxRequestBytes. There is no super-linear amplification and no memory-safety difference.
Notes:
- Flag polarity. The check is active only when
UxKirMaxHeadersCountLimit != 0. Its runtime default is determined by the WIL featureFeature_3936850235, which is not statically decidable from the binary alone. - New limit semantics. With the flag enabled, over-count HTTP/2 requests are rejected early with
STATUS_FAIL_CHECK; the default cap ismax(MaxRequestBytes>>9, 200). This is a configurable robustness cap, not the closure of a demonstrable flaw. - No corruption path. Nothing in the diff indicates memory corruption; the added code only short-circuits to an error return.