http.sys — Out-of-bounds access via missing QPACK ordinal validation (CWE-787, CWE-125) in QkIsOrdinalUseable fixed
KB5074109
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):
- Attacker opens an HTTP/3 connection over QUIC to a server backed by
http.sys(Windows HTTP Server API). - The QUIC stack delivers QPACK-encoded request headers to
http.sys. - During QPACK header encoding/decoding,
QkEncodePairControl(0x14013b020) orQkEncodePairData(0x14013b260) is invoked. - These functions call
QkIsOrdinalUseableto validate each dynamic-table ordinal referenced in the header block. - The flawed validation permits an out-of-bounds ordinal to pass.
- 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 (0x1400D1F58–0x1400D20D4), 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, 1at0x14013CAC0is reached from two edges:jbat0x14013CA93(table not full) andjnzat0x14013CAA5(result marker set). On both, the ordinal inr8is returned "usable" without anycmpagainst+0x2010/+0x2018.- Because arg2 and non-arg2 share this block, the
+0x2018bound check at0x14013CAB7is 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 at0x14013CAABand 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.
- 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 usingHttpReceiveHttpRequestover HTTP/3). - 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 atctx+0x2020is non-null withresult->0x18 != 0. Either condition makes the routine skip the+0x2018bound 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.) - Craft a header block referencing an out-of-bounds ordinal on the non-arg2 lookup. Reference a dynamic-table entry with an ordinal value
Nwhere: N > 0x62(above the static-table range)N > *(ctx + 0x48)orN > *(ctx + 0x2008)(so the routine reaches the shared table path rather than the outright-reject filter)N > *(ctx + 0x2018)(exceeds the valid non-arg2 dynamic-table bound that the corrected routine would enforce)- No ordering constraint. The insertions and the OOB reference can be in the same or different QUIC streams within the connection.
- 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:
- A kernel pool OOB read (information disclosure or fault if unmapped).
- A kernel pool OOB write if the ordinal flows into a table-update path.
- A BSOD with
PAGE_FAULT_IN_NONPAGED_AREAorINVALID_DATA_ACCESSif 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) — removedUxKirSeEndConnectionStatsbranchUlpGenerateAuthHeaders(0.777) — removedUxKirHttpBugFix25Q2duplicated bit-mappingUlpGenerateAuthHeadersOld(0.769) — same patternUlpComputeAuthHeaderSize(0.847) — same patternUlpComputeAuthHeaderSizeOld(0.847) — same patternUlpParseAuthHeader(0.891) — same patternUlVerifyInitializedAuthSchemes(0.838) — same patternUlVerifyAuthConfigOld(0.897) — same patternUlEventRequestDataTransmissionStats(0.891) — removedUxKirSeEndConnectionStatsfrom IRQL checkUlpEventRequestConnectionStats(0.968) — removedUxKirSeEndConnectionStatsgateUlEgressDisconnectCompletion(0.935) — removedUxKirSeEndConnectionStatsbranchUxpTpFreeTransmitPacket(0.973) — removedUxKirSeEndConnectionStatsbranchUlCopyServiceBindings(0.958) — register/stack-offset renamingUlCopyServiceBindings$filt$0(0.989) — minor offset adjustmentsUlEventDataTransmissionStats(0.991) — WPP trace GUID changeWPP_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:
-
KIR default state.
UxKirHttpBugFix25Q2is a Known Issue Rollback flag set inUxStartEnvironmentModulefromFeature_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. Readhttp!UxKirHttpBugFix25Q2at runtime to confirm which routine is live. -
Call chain depth.
QkEncodePairControlandQkEncodePairDataare callers, and the recursive self-callQkIsOrdinalUseable(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. -
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. -
Pool layout exploitability. Practical exploitability depends on the kernel pool layout at access time; use
!pool,!poolfind, and special pool to determine adjacent objects. -
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.