1. Overview

Field Value
Unpatched binary http_unpatched.sys
Patched binary http_patched.sys
Overall similarity 98.95%
Matched functions 3 911
Changed functions 31
Identical functions 3 880
Unmatched (either direction) 0

Verdict: The patch removes two Known Issue Rollback (KIR) feature flags, UxKirHttpBugFix25Q2 and UxKirSeEndConnectionStats, and hardcodes one code path in each affected function. The security-relevant change is in the QPACK dynamic-table ordinal validator QkIsOrdinalUseable (0x14013CA40): UxKirHttpBugFix25Q2 selected between an older validation routine that omits a bounds check on the non-arg2 lookup path and a corrected routine that always applies it. The patched binary makes the corrected routine unconditional. Removal of UxKirSeEndConnectionStats is dead-code cleanup: it dropped redundant, flag-gated re-zeroing of fields that are already cleared by the allocation-time memset in both builds.


2. Vulnerability Summary

Finding 1 — QPACK Dynamic Table Ordinal Validation Bypass (HIGH)

Attribute Detail
Severity High
Class Out-of-bounds access — CWE-787 (OOB write) / CWE-125 (OOB read)
Function QkIsOrdinalUseable (0x14013ca40)
Entry point HTTP/3 (QUIC) request with QPACK-encoded headers

Root cause. In the unpatched binary, QkIsOrdinalUseable begins by testing the global flag UxKirHttpBugFix25Q2. When this flag is 0, execution falls into the older validation logic; when non-zero it tail-calls QkIsOrdinalUseableNew (0x14013CAD0). The older logic merges the arg2 and non-arg2 lookup handling into one block: after an initial reject filter, it returns 1 (usable) whenever the dynamic table is not full (insert_count at +0x2038 < max_entries at +0x2030) or a cached "result" entry (pointer at +0x2020) has a non-zero field at result+0x18, and only otherwise compares the attacker-influenced ordinal (arg3 / r8) against a bound (+0x2010 for the arg2 branch, +0x2018 for the non-arg2 branch). For the non-arg2 path (arg2 == 0) this means an ordinal greater than the valid bound +0x2018 is accepted as usable as long as the table still has free capacity or the result marker is set — the mandatory arg3 ≤ *(arg1 + 0x2018) comparison is skipped.

The corrected logic (QkIsOrdinalUseableNew, made unconditional in the patched build) splits on arg2 first. For the non-arg2 case it always evaluates arg3 ≤ *(arg1 + 0x2018) (after a +0x48 / +0x2008 pre-filter) and never returns usable without that comparison. The "not-full → usable" and "result-marker → usable" shortcuts remain only on the arg2 path. The arg2 path bound check (arg3 ≤ *(arg1 + 0x2010)) is identical in both routines; the fix is confined to the non-arg2 path.

Call chain (attacker-reachable path):

  1. Attacker opens an HTTP/3 connection over QUIC to a server backed by http.sys (Windows HTTP Server API).
  2. The QUIC stack delivers QPACK-encoded request headers to http.sys.
  3. During QPACK header encoding/decoding, QkEncodePairControl (0x14013b020) or QkEncodePairData (0x14013b260) is invoked.
  4. These functions call QkIsOrdinalUseable to validate each dynamic-table ordinal referenced in the header block.
  5. The flawed validation permits an out-of-bounds ordinal to pass.
  6. The ordinal is subsequently used as an array index into the QPACK dynamic table in QkSearchTableByNameValue / QkUpdateLargestReference, producing the OOB access.

Finding 2 — sub_1400D2029 (No Security-Relevant Change)

Attribute Detail
Severity Informational
Class None
Function sub_1400D2029

Address 0x1400D2029 is an instruction offset inside UlCaptureWideString (0x1400D1F580x1400D20D4), not a distinct function. The captured-string capture logic there is behaviorally identical between the two builds: it allocates with ExAllocatePool3, probes and copies on success, and sets an error code on allocation failure, with the buffer handed to the caller via the output pointer on success. No pool free is added or removed and no leak is introduced or fixed. The only differences at this location are an updated WPP trace GUID and register/stack codegen. See Finding 3.


Finding 3 — UlCaptureWideString (No Security-Relevant Change)

Attribute Detail
Severity Informational
Class None
Function UlCaptureWideString

The two builds of UlCaptureWideString are functionally equivalent. Both zero the two output parameters, and when the length is non-zero, both allocate via ExAllocatePool3(66, len, ...), then UlProbeWideString / memmove / store-to-output on success, or set status 0xC0000096 on allocation failure. There is no deferred-free variable, no ExFreePoolWithTag, and no cleanup-on-exit path in either build. The differences are a WPP trace GUID update and codegen (the constant 1 passed to ExAllocatePool3 is materialized through a stack slot in the patched build). This function makes no security-relevant change.


Finding 4 — UxpTpAllocateTransmitPacket (No Security-Relevant Change)

Attribute Detail
Severity Informational
Class None
Function UxpTpAllocateTransmitPacket

Both builds clear the whole transmit packet with memset(pkt, 0, 0x150) immediately after allocation, which already zeroes the fields at +0x120, +0x130, and +0x140. The unpatched build additionally re-zeroes those three fields inside an if (UxKirSeEndConnectionStats != 0) block; the patched build drops that flag test and re-zeroes them unconditionally. Because the earlier memset covers the same offsets in both builds, the fields are zero on every path regardless of the flag — the removed block was redundant, and no uninitialized memory is exposed. The refcount debug check reads if (UxDebugCheckRefcount != 0 && v <= -1) UlBugCheckEx(...) in both builds; its logical operator is unchanged.


Finding 5 — UlHttpAttachConnection (No Security-Relevant Change)

Attribute Detail
Severity Informational
Class None
Function UlHttpAttachConnection

The connection object is cleared with memset(conn, 0, 0x4A0) right after allocation in both builds, which zeroes the fields at +0x100, +0x110, +0x120, and +0x130. The unpatched build re-zeroes those fields inside an if (UxKirSeEndConnectionStats != 0) block; the patched build removes the flag test and zeroes them inline unconditionally. Since the memset already covers those offsets in both builds, the fields are initialized on every path and no uninitialized data reaches UlpEventRequestConnectionStats or ETW. This is a dead-code cleanup, not a memory-initialization fix.


3. Pseudocode Diff

QkIsOrdinalUseable — the primary vulnerability

Offsets: +0x1FE8/+0x1FEC = state counters, +0x48/+0x2008 = pre-filter limits, +0x2010 = arg2 bound, +0x2018 = non-arg2 bound, +0x2020 = cached result pointer, +0x2030 = max_entries, +0x2038 = insert_count.

// ============================================================
// OLD routine, selected when UxKirHttpBugFix25Q2 == 0
// (this is the body of QkIsOrdinalUseable at 0x14013CA40)
// ============================================================
BOOL QkIsOrdinalUseable_OLD(QpackCtx *ctx, BOOL isArg2Path, uint64_t ordinal)
{
    // Reject filter: only these two cases return FALSE outright.
    if ((ctx->f_1FE8 == ctx->f_1FEC && ordinal > 0x62)
        || (!isArg2Path && ordinal > 0x62
            && ordinal <= ctx->f_48 && ordinal <= ctx->f_2008))
        return FALSE;

    // Shared table path for BOTH arg2 and non-arg2:
    if (ctx->insert_count < ctx->max_entries)   // [+0x2038] < [+0x2030]
        return TRUE;                            // table not full -> usable, NO bound check

    ResultEntry *result = ctx->result_ptr;      // [+0x2020]
    if (result && result->f_18 != 0)
        return TRUE;                            // result marker set -> usable, NO bound check

    // Only when table is full AND no result marker:
    if (isArg2Path)  return (ordinal <= ctx->bound_2010);   // [+0x2010]
    else             return (ordinal <= ctx->bound_2018);   // [+0x2018]
}

// ============================================================
// Corrected routine (QkIsOrdinalUseableNew at 0x14013CAD0;
// made unconditional in the patched build)
// ============================================================
BOOL QkIsOrdinalUseable_NEW(QpackCtx *ctx, BOOL isArg2Path, uint64_t ordinal)
{
    if (ordinal <= 0x62)                 return TRUE;   // static table
    if (ctx->f_1FE8 == ctx->f_1FEC)      return FALSE;

    if (!isArg2Path) {                                  // NON-ARG2: always bound-checked
        if (ordinal > ctx->f_48 || ordinal > ctx->f_2008)
            return (ordinal <= ctx->bound_2018);        // [+0x2018] ALWAYS checked
        return FALSE;
    }

    // ARG2 path: unchanged relative to OLD
    if (ctx->insert_count < ctx->max_entries)  return TRUE;
    ResultEntry *result = ctx->result_ptr;
    if (result && result->f_18 != 0)           return TRUE;
    return (ordinal <= ctx->bound_2010);                // [+0x2010]
}

The difference is confined to the non-arg2 path. In the OLD routine a non-arg2 ordinal falls into the shared table path and is returned "usable" whenever insert_count < max_entries or the result marker is set, so an ordinal exceeding +0x2018 is accepted while the table has spare capacity. The NEW routine gives the non-arg2 case its own branch that always evaluates ordinal <= *(ctx+0x2018). The arg2 path (+0x2010, plus the not-full / result-marker shortcuts) is byte-for-byte identical between the two routines.


4. Assembly Analysis

QkIsOrdinalUseable — Unpatched (0x14013CA40)

000000014013CA40  sub     rsp, 28h
000000014013CA44  cmp     cs:UxKirHttpBugFix25Q2, 0
000000014013CA4B  jz      short loc_14013CA54      ; flag == 0: run OLD routine below
000000014013CA4D  call    QkIsOrdinalUseableNew    ; flag != 0: run corrected routine
000000014013CA52  jmp     short loc_14013CAC2
; ---- OLD routine ----
loc_14013CA54:
000000014013CA54  mov     eax, [rcx+1FECh]
000000014013CA5A  cmp     [rcx+1FE8h], eax
000000014013CA60  jnz     short loc_14013CA68
000000014013CA62  cmp     r8, 62h                  ; f_1FE8 == f_1FEC path
000000014013CA66  ja      short loc_14013CA81      ; ordinal > 0x62 -> return 0
loc_14013CA68:
000000014013CA68  test    dl, dl                   ; isArg2Path
000000014013CA6A  jnz     short loc_14013CA85      ; arg2 -> shared table path
000000014013CA6C  cmp     r8, 62h
000000014013CA70  jbe     short loc_14013CA85      ; non-arg2, ordinal <= 0x62 -> table path
000000014013CA72  cmp     r8, [rcx+48h]
000000014013CA76  ja      short loc_14013CA85      ; ordinal > f_48 -> table path
000000014013CA78  cmp     r8, [rcx+2008h]
000000014013CA7F  ja      short loc_14013CA85      ; ordinal > f_2008 -> table path
loc_14013CA81:
000000014013CA81  xor     al, al                   ; return 0
000000014013CA83  jmp     short loc_14013CAC2
; ---- shared table path (BOTH arg2 and non-arg2) ----
loc_14013CA85:
000000014013CA85  mov     rax, [rcx+2030h]         ; max_entries
000000014013CA8C  cmp     [rcx+2038h], rax         ; insert_count < max_entries?
000000014013CA93  jb      short loc_14013CAC0      ; table NOT full -> mov al,1 (NO bound check)
000000014013CA95  mov     rax, [rcx+2020h]         ; result pointer
000000014013CA9C  test    rax, rax
000000014013CA9F  jz      short loc_14013CAA7
000000014013CAA1  cmp     dword ptr [rax+18h], 0   ; result->0x18
000000014013CAA5  jnz     short loc_14013CAC0      ; marker set -> mov al,1 (NO bound check)
loc_14013CAA7:                                     ; full AND no marker: bound check happens here
000000014013CAA7  test    dl, dl
000000014013CAA9  jz      short loc_14013CAB7
000000014013CAAB  cmp     r8, [rcx+2010h]          ; arg2 bound
000000014013CAB2  setbe   al
000000014013CAB5  jmp     short loc_14013CAC2
loc_14013CAB7:
000000014013CAB7  cmp     r8, [rcx+2018h]          ; non-arg2 bound
000000014013CABE  jmp     short loc_14013CAB2
loc_14013CAC0:
000000014013CAC0  mov     al, 1                    ; return "usable"
loc_14013CAC2:
000000014013CAC2  add     rsp, 28h
000000014013CAC6  retn

Key observations:

  • mov al, 1 at 0x14013CAC0 is reached from two edges: jb at 0x14013CA93 (table not full) and jnz at 0x14013CAA5 (result marker set). On both, the ordinal in r8 is returned "usable" without any cmp against +0x2010/+0x2018.
  • Because arg2 and non-arg2 share this block, the +0x2018 bound check at 0x14013CAB7 is only reached when the table is full and the result marker is clear. For the non-arg2 path this is the missing check.
  • The +0x2010 (arg2) comparison at 0x14013CAAB and the not-full / result-marker shortcuts also exist verbatim in the corrected routine, so the arg2 behavior is unchanged by the patch.

Corrected routine (QkIsOrdinalUseableNew, 0x14013CAD0 → made unconditional in patched)

The corrected routine tests ordinal <= 0x62 and f_1FE8 == f_1FEC first, then splits on arg2. The non-arg2 branch performs the +0x48 / +0x2008 pre-filter and then always executes cmp r8, [rcx+2018h]; setbe al — there is no path that returns "usable" for a non-arg2 ordinal without this comparison. The arg2 branch keeps the not-full / result-marker shortcuts and the +0x2010 comparison. The patched QkIsOrdinalUseable (0x14013C4E0) drops the UxKirHttpBugFix25Q2 test and is this corrected routine inline.


5. Trigger Conditions

This path is only reachable when the KIR flag UxKirHttpBugFix25Q2 is 0.

  1. Open an HTTP/3 (QUIC) connection to a target running the Windows HTTP Server API backed by http.sys (e.g., IIS with HTTP/3 enabled, or any application using HttpReceiveHttpRequest over HTTP/3).
  2. Keep the QPACK dynamic table non-full, or prime a cached result marker. The bypass fires on the non-arg2 lookup path when insert_count (ctx+0x2038) < max_entries (ctx+0x2030), i.e. the table still has free capacity, or alternatively when the cached result pointer at ctx+0x2020 is non-null with result->0x18 != 0. Either condition makes the routine skip the +0x2018 bound check. (Filling the table to capacity with the marker clear is the one state that does enforce the check, so it is not the trigger.)
  3. Craft a header block referencing an out-of-bounds ordinal on the non-arg2 lookup. Reference a dynamic-table entry with an ordinal value N where:
  4. N > 0x62 (above the static-table range)
  5. N > *(ctx + 0x48) or N > *(ctx + 0x2008) (so the routine reaches the shared table path rather than the outright-reject filter)
  6. N > *(ctx + 0x2018) (exceeds the valid non-arg2 dynamic-table bound that the corrected routine would enforce)
  7. No ordering constraint. The insertions and the OOB reference can be in the same or different QUIC streams within the connection.
  8. Observable effect: the OOB ordinal is returned "usable" and used as an index into the QPACK dynamic table entry array. Depending on the table's backing allocation, this produces either:
  9. A kernel pool OOB read (information disclosure or fault if unmapped).
  10. A kernel pool OOB write if the ordinal flows into a table-update path.
  11. A BSOD with PAGE_FAULT_IN_NONPAGED_AREA or INVALID_DATA_ACCESS if the indexed address is unmapped.

6. Exploit Primitive & Development Notes

Primitive. The vulnerability provides a controlled out-of-bounds access on a kernel-pool (NonPagedPool) array of QPACK dynamic table entries. The attacker controls: - The ordinal value (the array index displacement). - The QPACK context state (by controlling insertions to the dynamic table).

The displacement is ordinal - valid_max, multiplied by the size of a dynamic-table entry struct. Since the ordinal is a 64-bit value and only bounded by the attacker, the displacement is essentially arbitrary (limited only by kernel address space and pool allocation layout).

Turning it into a full exploit:

Step Requirement
Heap Feng Shui Arrange kernel pool objects around the QPACK dynamic table allocation so that a controlled OOB index lands on a known object type (e.g., pipe attributes, named-pipe buffers, or token objects).
Info leak / KASLR bypass An OOB read primitive can leak kernel pointers from adjacent pool objects. Send a QPACK request that triggers the OOB read and observe the response or ETW data for leaked kernel addresses.
Privilege escalation Combine with an OOB write to corrupt an adjacent object's function pointer or token pointer. The classic approach: overwrite a PIPE_ATTRIBUTE's ValueLength or a worker-item function pointer to redirect execution.
Code execution If SMEP is active, redirect to a ROP chain or a signed kernel function. Use a stack pivot gadget. If HVCI is active, avoid writable+executable memory; use data-only attacks (token stealing via SE_TOKEN_PRIVILEGES manipulation).

Mitigation impact:

Mitigation Impact
KASLR Must be bypassed first via info leak. The OOB read primitive is suitable for this.
SMEP / SMAP Affects code-execution strategies but not the OOB primitive itself. Data-only attacks bypass both.
CFG (Control Flow Guard) Limits indirect call targets. Use non-CFG-protected call sites or data-only corruption.
HVCI Prevents writable+executable kernel pages. Token-stealing data-only attacks are unaffected.
Pool integrity checks (Win 10+) Corrupted pool headers trigger BugChecks. The OOB access itself may corrupt headers; use precise offsets or target page-aligned objects.

7. Debugger PoC Playbook

Target: QkIsOrdinalUseable OOB Access

Module base: Load http.sys symbols or use lm http to get the base.

Breakpoints

; Break at the flag check — confirms which path is taken
bp http!QkIsOrdinalUseable+0x8

; Break at the "return usable" store reached without a non-arg2 bound check
bp http!QkIsOrdinalUseable+0x80

; Break at the non-arg2 bound check (only reached when table full and marker clear)
bp http!QkIsOrdinalUseable+0x77

; Break at the callers to see attacker-controlled ordinals
bp http!QkEncodePairControl
bp http!QkEncodePairData

If symbols are unavailable, compute offsets from the module base. The function is at RVA 0x13CA40 (i.e., bp http+0x13ca40).

What to Inspect at Each Breakpoint

At QkIsOrdinalUseable+0x4 (flag check):

; rcx = QPACK context (arg1)
; dl  = isArg2Path flag (arg2)  the non-arg2 (dl == 0) case is the vulnerable one
; r8  = ordinal (arg3)  ATTACKER-INFLUENCED  this is the value to watch

r rcx, r8, dl                    ; dump context, ordinal, and path flag

; Check the KIR flag value
db http!UxKirHttpBugFix25Q2 l1   ; 0x00 selects the OLD routine

; Check table state in the context
?? ((unsigned __int64*)@rcx)[0x406]   ; [+0x2030] = max_entries
?? ((unsigned __int64*)@rcx)[0x407]   ; [+0x2038] = insert_count (must be < max to bypass)

At QkIsOrdinalUseable+0x80 (mov al, 1):

; If dl == 0 and r8 > bound_2018 here, the non-arg2 bypass has fired.
r r8, dl                          ; the OOB ordinal value and path flag

; Verify the bound that SHOULD have been checked on the non-arg2 path:
?? ((unsigned __int64*)@rcx)[0x403]   ; [+0x2018] = valid max ordinal (non-arg2 path)
?? ((unsigned __int64*)@rcx)[0x402]   ; [+0x2010] = valid max ordinal (arg2 path)

; Distinguish which edge reached here:
;   jb  at +0x53 -> insert_count < max_entries (table not full)
;   jnz at +0x65 -> result->0x18 != 0 (marker set)
?? ((unsigned __int64*)@rcx)[0x407]   ; insert_count
?? ((unsigned __int64*)@rcx)[0x406]   ; max_entries

At QkEncodePairControl / QkEncodePairData (callers):

; r8 or r9 (depending on calling convention for 4th arg) holds the ordinal
; from the QPACK header block. Trace the ordinal value to confirm it matches
; the attacker-supplied header field.

; Dump the raw QPACK header block from the IRP if available:
; rcx = context, follow the IRP chain to find the request buffer

Key Instructions and Offsets

Offset (RVA) Instruction Significance
+0x04 cmp cs:UxKirHttpBugFix25Q2, 0 KIR flag check; 0 selects the OLD routine
+0x0B jz loc_14013CA54 Enter OLD routine when flag is 0
+0x0D call QkIsOrdinalUseableNew Corrected routine, taken only when flag != 0
+0x4C cmp [rcx+0x2038], rax Table-full check; jb (+0x53) returns usable, skipping the bound check
+0x61 cmp dword [rax+0x18], 0 Result-marker test; jnz (+0x65) returns usable, skipping the bound check
+0x6B cmp r8, [rcx+0x2010] arg2 bound check (full + no marker) — unchanged by the patch
+0x77 cmp r8, [rcx+0x2018] non-arg2 bound check — the check the OLD routine skips on the not-full / marker edges
+0x80 mov al, 1 "usable" return reached without a non-arg2 bound check

Trigger Setup from User Mode

1. Open a UDP socket and establish a QUIC connection to the target on port 443
   (or whichever port http.sys is listening on for HTTP/3).

2. Complete the QUIC handshake and HTTP/3 control stream setup.
   - Send SETTINGS frame with a QPACK max table capacity > 0.
   - Insert a small number of entries so the dynamic table is populated but
     NOT full (insert_count < max_entries), or alternatively drive at least
     one lookup so the cached result entry at ctx+0x2020 has result->0x18 != 0.
     Either state makes the OLD routine skip the non-arg2 bound check.

3. Send a HEADERS frame on a request stream with a QPACK-encoded header block
   that references a dynamic table ordinal = valid_2018_bound + DELTA on the
   non-arg2 lookup, where DELTA is chosen so the resulting array index points
   to a pool object you want to read/corrupt.

4. Monitor the kernel debugger for:
   - Breakpoint hit at +0x80 with dl == 0 (non-arg2 usable, no bound check)
   - r8 value exceeding the bound at [+0x2018]
   - Subsequent fault at the OOB access in QkSearchTableByNameValue or
     QkUpdateLargestReference (access violation, BugCheck 0x50 or 0xD1)

Expected Observations

Observation Meaning
Breakpoint hit at +0x04 then +0x80 with dl == 0 Non-arg2 bypass active (flag 0, OOB ordinal accepted)
r8 >> *(rcx+0x2018) Ordinal exceeds the valid non-arg2 bound
BugCheck 0x50 (PAGE_FAULT_IN_NONPAGED_AREA) OOB index landed on unmapped memory
BugCheck 0xD1 (DRIVER_IRQL_NOT_LESS_OR_EQUAL) OOB access in interrupt context
Corrupted pool header → BugCheck 0x19 (BAD_POOL_HEADER) OOB write corrupted adjacent allocation's metadata

Struct/Offset Reference

QpackCtx (rcx in QkIsOrdinalUseable):
  +0x0048  field_48             — pre-filter limit (routes non-arg2 to the table path)
  +0x2008  field_2008           — pre-filter limit (routes non-arg2 to the table path)
  +0x2010  bound_arg2           — max valid ordinal for arg2 path (check unchanged by patch)
  +0x2018  bound_non_arg2       — max valid ordinal for non-arg2 path (check the OLD routine skips)
  +0x2020  result_ptr           — cached lookup result (ResultEntry*)
  +0x2030  max_entries          — dynamic table capacity
  +0x2038  insert_count         — current number of inserted entries

ResultEntry (pointed to by ctx+0x2020):
  +0x18    marker               — non-zero is one of the two edges that skips the bound check

Not-full / marker shortcut:
  insert_count < max_entries  OR  result->0x18 != 0  → OLD routine returns usable

Static table boundary:
  0x62                          — ordinals ≤ 0x62 are static table entries

8. Changed Functions — Full Triage

Security-Relevant Changes

Function Similarity Change Type Note
QkIsOrdinalUseable 0.772 Security Primary change. Removed the UxKirHttpBugFix25Q2 gate; the corrected routine is now unconditional. It always bound-checks a non-arg2 ordinal against +0x2018, closing the OLD routine's skip on the not-full / result-marker edges.
UxStartEnvironmentModule 0.961 Security (root cause) Removes initialization of UxKirHttpBugFix25Q2 and UxKirSeEndConnectionStats. With the flags gone, the per-function branches that selected the OLD QkIsOrdinalUseable routine (and the redundant stats re-zeroing) are removed.

Reviewed — No Security-Relevant Change

Function Similarity Note
sub_1400D2029 0.851 Instruction offset inside UlCaptureWideString; string-capture logic identical between builds. Differences are a WPP trace GUID and codegen. No pool free added.
UlCaptureWideString 0.801 Functionally equivalent in both builds; no deferred-free / ExFreePoolWithTag exists in either. Only a WPP trace GUID and codegen differ.
UxpTpAllocateTransmitPacket 0.980 Fields +0x120/+0x130/+0x140 are zeroed by the allocation-time memset(pkt,0,0x150) in both builds; the removed UxKirSeEndConnectionStats-gated re-zero was redundant. The refcount debug check uses && in both builds.
UlHttpAttachConnection 0.985 Fields +0x100/+0x110/+0x120/+0x130 are zeroed by memset(conn,0,0x4A0) in both builds; the removed flag-gated re-zero was redundant. No uninitialized data is exposed.

Behavioral Changes (Non-Security)

Function Similarity Note
sub_1400D5439 0.873 Simplified redundant double-check; removed WPP trace variable.
sub_140133E9F 0.873 Same simplification pattern.
UxpTpMdlSendCompleteWorker 0.344 Major refactor: inlined UxpTpFreeChunkTracker teardown into success path; added __security_cookie stack guard; stack frame grew from 0x20 to 0xB8.
UxpTpFreeChunkTracker 0.669 Extracted MDL chain freeing into UxFreeMdlChain() helper; added explicit NULL-setting after frees. Now only called from error paths.
UxpTpEnqueueTransmitPacket 0.769 Extracted MDL byte counting into UxGetMdlChainByteCountEx(); control-flow restructured to while(true) loop.
UxTpCleanupTransmitQueue 0.877 Part of transmit packet lifecycle refactor cluster.
UxpTpDequeueTransmitPacket 0.866 Part of transmit packet lifecycle refactor cluster.

Cosmetic / Feature-Flag Dead-Code Removal

The following functions had UxKirHttpBugFix25Q2 or UxKirSeEndConnectionStats runtime branches removed. In every case, both branches contained identical or equivalent logic; the patch simply removes the conditional and keeps the new-path code. No behavioral change:

  • UxpTpBufferSendCompleteWorker (0.363) — removed UxKirSeEndConnectionStats branch
  • UlpGenerateAuthHeaders (0.777) — removed UxKirHttpBugFix25Q2 duplicated bit-mapping
  • UlpGenerateAuthHeadersOld (0.769) — same pattern
  • UlpComputeAuthHeaderSize (0.847) — same pattern
  • UlpComputeAuthHeaderSizeOld (0.847) — same pattern
  • UlpParseAuthHeader (0.891) — same pattern
  • UlVerifyInitializedAuthSchemes (0.838) — same pattern
  • UlVerifyAuthConfigOld (0.897) — same pattern
  • UlEventRequestDataTransmissionStats (0.891) — removed UxKirSeEndConnectionStats from IRQL check
  • UlpEventRequestConnectionStats (0.968) — removed UxKirSeEndConnectionStats gate
  • UlEgressDisconnectCompletion (0.935) — removed UxKirSeEndConnectionStats branch
  • UxpTpFreeTransmitPacket (0.973) — removed UxKirSeEndConnectionStats branch
  • UlCopyServiceBindings (0.958) — register/stack-offset renaming
  • UlCopyServiceBindings$filt$0 (0.989) — minor offset adjustments
  • UlEventDataTransmissionStats (0.991) — WPP trace GUID change
  • WPP_SF_lllDli (0.696) — WPP tracing GUID update

9. Unmatched Functions

No functions were added or removed (unmatched_unpatched: 0, unmatched_patched: 0). All changes are in-place modifications to existing functions. The corrected validation routine (QkIsOrdinalUseableNew) already existed in the unpatched binary and was reached whenever UxKirHttpBugFix25Q2 was non-zero; the patch makes it the only path.


10. Confidence & Caveats

Confidence: High that the validation routines differ as described; Medium for the full call chain and default reachability.

Rationale for high confidence on the code difference: - The unpatched QkIsOrdinalUseable (0x14013CA40) branches on UxKirHttpBugFix25Q2; the flag-0 body merges arg2 and non-arg2 handling and returns "usable" on the not-full and result-marker edges without a non-arg2 bound check. - The corrected routine QkIsOrdinalUseableNew (0x14013CAD0) gives the non-arg2 case its own branch that always compares against +0x2018. The patched QkIsOrdinalUseable (0x14013C4E0) is this corrected routine with the flag test removed. - UxStartEnvironmentModule no longer initializes UxKirHttpBugFix25Q2 in the patched build, and the flag has no remaining references there.

Assumptions that need manual verification:

  1. KIR default state. UxKirHttpBugFix25Q2 is a Known Issue Rollback flag set in UxStartEnvironmentModule from Feature_HttpBugFix25Q2__private_IsEnabledDeviceUsageNoInline(). Whether the OLD (flag-0) routine is active in a given deployment depends on the feature's default and any pushed rollback, which are not determinable from the binary alone. Read http!UxKirHttpBugFix25Q2 at runtime to confirm which routine is live.

  2. Call chain depth. QkEncodePairControl and QkEncodePairData are callers, and the recursive self-call QkIsOrdinalUseable(ctx, 0, ordinal) exercises the non-arg2 path; the full path from the QUIC receive callback through the HTTP/3 framing layer should be confirmed with cross-references or a stack trace.

  3. Consumer of the "usable" result. Confirm that an ordinal accepted by the non-arg2 path is subsequently used as an index/pointer-arithmetic base in the dynamic-table lookup (e.g. QkLookupElementFromOrdinal / QkSearchTableByNameValue) so that acceptance of an out-of-range ordinal yields an actual OOB access.

  4. Pool layout exploitability. Practical exploitability depends on the kernel pool layout at access time; use !pool, !poolfind, and special pool to determine adjacent objects.

  5. HTTP/3 availability. The target must have HTTP/3 enabled and reachable (Windows Server 2022+/Windows 11+ with QUIC and an HTTP/3-registered application).

What a researcher should do first: - Attach a kernel debugger, break on http!QkIsOrdinalUseable, and read UxKirHttpBugFix25Q2 to confirm the OLD routine is live. - Send a benign HTTP/3 request and confirm the breakpoint is hit. - With the table non-full (or the result marker primed), send an OOB non-arg2 ordinal; observe whether +0x80 is reached with dl == 0 and r8 exceeding +0x2018.