1. Overview

  • Unpatched Binary: http_unpatched.sys
  • Patched Binary: http_patched.sys
  • Similarity Score: 0.9918
  • Diff Statistics: 3910 matched, 6 changed, 3904 identical, 0 unmatched.
  • Verdict: The patch resolves a high-severity Time-of-Check Time-of-Use (TOCTOU / CWE-367) double-fetch vulnerability in the HTTP Server API kernel driver (http.sys), where concurrent user-mode threads could race to trick the kernel into copying arbitrary kernel memory into an attacker-readable output buffer.

2. Vulnerability Summary

  • Severity: High
  • Vulnerability Class: Double-fetch / TOCTOU (CWE-367)
  • Primary Vulnerable Function: UlGenerateTrailers (sub_1400755C0)
  • Related KIR-gated user-mode pointer changes: UxQuicConnectionCopyBindings (sub_1400E1AB4), UxQuicConnectionGetPeerIssuerListClient (sub_1400E28A8), UxpQuicConnectionCopyCertBlob (sub_1400E9D8C)

Root Cause & Exploitability: UlGenerateTrailers builds the trailer section of an outbound HTTP response from a user-supplied descriptor. The descriptor at a2 holds a count at offset +0x8 and, at offset +0x10, a pointer to an array of 0x18-byte entries. Each entry carries two length fields (offsets +0x0 and +0x2) and two data pointers (offsets +0x8 and +0x10) that point into user-mode memory. The driver validates the entry array once with UlProbeForRead, then validates each embedded data pointer with UlProbeAnsiString before copying its bytes with memmove.

In the unpatched build, each embedded data pointer is read from user memory twice: once when it is passed to UlProbeAnsiString, and again when it is passed to memmove. Between those two reads a second user-mode thread can overwrite the pointer field, swapping the probed user-mode address for a kernel address. The probe passes on the original value while the copy dereferences the swapped value, so memmove reads from an attacker-chosen kernel address into the response trailer buffer.

The patched build captures each embedded data pointer once into register r12 and, when the KIR feature flag UxKirHttpReadingTrailersFromUm is enabled, uses that captured value for both the probe and the copy, closing the race window. When the flag is disabled the old re-read path is retained, so the fix is gated on that Known-Issue-Rollback flag (set from a Feature-staging query in UxStartEnvironmentModule).

Attacker-Reachable Entry Point & Data Flow: 1. A local process using the HTTP Server API submits a response-send request carrying trailer descriptors. 2. The request is dispatched through UlSendEntityBodyIoctl (sub_140098b80) / UlSendEntityBodyFastIo (sub_14004b3e0). 3. Processing flows UlFastSendHttpResponse (sub_1400978b8) -> UlCaptureHttpResponse (sub_14009ab5c) -> UlpPrepareHttpResponse (sub_14009b744). 4. UlGenerateTrailers (sub_1400755C0) is called to copy the user-supplied trailer entries. The double-fetch occurs here.

3. Pseudocode Diff

Below is a conceptual look at the vulnerable code pattern versus the patched code pattern in UlGenerateTrailers (sub_1400755C0).

// === UNPATCHED LOGIC (Vulnerable to TOCTOU) ===
void UlGenerateTrailers(..., USER_ENTRY *EntryArray, int Count, char *OutBuf) {
    UlProbeForRead(EntryArray, 24 * Count, 8, mode);   // whole array validated once
    for (int i = 0; i < Count; i++) {
        USER_ENTRY *entry = &EntryArray[i];
        USHORT len = entry->length;

        // TIME OF CHECK: probe reads entry->data_ptr from user memory
        UlProbeAnsiString(entry->data_ptr, len, mode);

        // --- RACE WINDOW: attacker overwrites entry->data_ptr with a kernel address ---

        // TIME OF USE: memmove re-reads entry->data_ptr from user memory
        memmove(OutBuf, entry->data_ptr, len);
    }
}

// === PATCHED LOGIC (Single-Capture, KIR-gated) ===
void UlGenerateTrailers(..., USER_ENTRY *EntryArray, int Count, char *OutBuf) {
    UlProbeForRead(EntryArray, 24 * Count, 8, mode);
    for (int i = 0; i < Count; i++) {
        USER_ENTRY *entry = &EntryArray[i];
        USHORT len = entry->length;

        // SINGLE FETCH: capture the pointer once (register r12)
        void *captured_ptr = entry->data_ptr;
        UlProbeAnsiString(captured_ptr, len, mode);

        // Source used for the copy depends on the Known-Issue-Rollback flag:
        void *src = UxKirHttpReadingTrailersFromUm ? captured_ptr   // fixed: reuse captured value
                                                   : entry->data_ptr; // rollback: re-read (old behavior)
        memmove(OutBuf, src, len);
    }
}

4. Assembly Analysis

The double-fetch is visible in the unpatched assembly of UlGenerateTrailers (sub_1400755C0). The function body is split, with the entry-processing loop placed in a cold chunk beginning at 0x14015f402. The entry array base is evaluated, and the pointers at offsets +0x8 and +0x10 are fetched twice. sub_1400640f0 is UlProbeAnsiString; sub_14014de80 is memmove.

Unpatched UlGenerateTrailers (sub_1400755C0) — double-fetch

; === Entry processing loop setup ===
0x14015f49a: movzx   eax, ax                  ; loop index
0x14015f4a1: mov     rax, qword [rsp+0x58]    ; rax = array base (user memory)
0x14015f4a6: lea     rax, [rax+rcx*8]         ; rax = current entry (base + index*0x18)
0x14015f4af: movzx   ecx, word [rax]          ; ecx = entry->length (offset 0x0)

; === DOUBLE-FETCH #1: entry+0x8 pointer ===
0x14015f4cb: mov     rcx, qword [rax+0x8]     ; FIRST READ of ptr for probe
0x14015f4cf: call    UlProbeAnsiString        ; UlProbeAnsiString(ptr, length, mode)
...
0x14015f504: mov     rdx, qword [rsp+0x50]    ; reload entry base
0x14015f509: mov     rdx, qword [rdx+0x8]     ; SECOND READ of ptr (TOCTOU WINDOW)
0x14015f50d: mov     rcx, r13                 ; rcx = destination trailer buffer
0x14015f510: call    memmove                  ; memmove(out_buf, attacker_swapped_ptr, len)

; === DOUBLE-FETCH #2: entry+0x10 pointer ===
0x14015f546: mov     rcx, qword [rax+0x10]    ; FIRST READ of secondary ptr
0x14015f54a: call    UlProbeAnsiString        ; UlProbeAnsiString
...
0x14015f56b: mov     rdx, qword [rsp+0x50]    ; reload entry base
0x14015f570: mov     rdx, qword [rdx+0x10]    ; SECOND READ of secondary ptr
0x14015f574: mov     rcx, r13                 ; rcx = destination trailer buffer
0x14015f577: call    memmove                  ; memmove(out_buf, attacker_swapped_ptr, len)

Patched UlGenerateTrailers (sub_14009DE00) — single-capture

The patched version reads each pointer into r12 once; when UxKirHttpReadingTrailersFromUm is set it feeds that captured register to the copy instead of re-reading user memory.

0x14009df66: mov     r12, qword [rax+8]       ; CAPTURE pointer once into r12
0x14009df77: mov     rcx, r12                 ; probe uses the captured r12
0x14009df7a: cmp     cs:UxKirHttpReadingTrailersFromUm, 0
0x14009df81: jz      0x14009dfa5              ; flag==0 -> rollback (re-read) path
0x14009df83: call    UlProbeAnsiString        ; UlProbeAnsiString(captured_ptr)
...
0x14009dfa0: mov     rdx, r12                 ; FIX: reuse captured r12 as copy source
0x14009dfa3: jmp     0x14009dfc6
; --- rollback path (flag==0): re-reads entry+8 from user memory ---
0x14009dfc2: mov     rdx, qword [r13+8]       ; source re-read (old behavior)
0x14009dfc6: mov     rbx, r14
0x14009dfcf: call    memmove                  ; memmove(out_buf, source, len)

sub_14014de80 (unpatched) and sub_14014e300 (patched) are the same memmove routine at a shifted address; the copy helper itself is unchanged between builds.

5. Trigger Conditions

  1. Obtain a handle to the HTTP Server API via Win32 (HttpCreateHttpHandle, HttpCreateRequestQueue) or native (NtDeviceIoControlFile directly targeting \Device\Http\*).
  2. Allocate an input buffer for the IOCTL containing an array header (count at offset +0x8, must be 1 to 0x2710) and a pointer to the array at offset +0x10.
  3. Populate the array with N entries. Each entry is 0x18 bytes: [uint16 length1][uint16 length2][ptr data1][ptr data2]. Ensure length matches your buffer sizes.
  4. Initialize data1 to point to a legitimate user-mode buffer.
  5. Spawn a racing thread that continuously executes InterlockedExchange64 on the data1 field, alternating between the legitimate user-mode pointer and a target kernel address (e.g., 0xFFFFF805'C0000000).
  6. Submit the IOCTL from the main thread in a tight loop.
  7. Inspect the IOCTL output buffer after each call. If the race is won, the output buffer will contain the raw bytes of the target kernel address instead of your user-mode buffer data.

6. Exploit Primitive & Development Notes

  • Primitive: Arbitrary Kernel Memory Read (Information Disclosure).
  • Limitations: Because it relies on winning a race condition, you cannot reliably read large contiguous blocks in a single request. However, by requesting small lengths (e.g., 8 bytes) and looping, you can slowly exfiltrate memory.
  • Usage: This primitive is primarily an info-leak. It can be used to:
  • Defeat KASLR by reading known kernel structures to find base addresses.
  • Scan for authentication tokens in kernel pool memory.
  • Locate the EPROCESS of System.exe to prepare for a subsequent token-stealing exploit.
  • Mitigations: Standard kernel protections like SMEP, SMAP, and CFG do not prevent this vulnerability, as the kernel is legally performing a read on behalf of an IRP. SMAP actually helps the attacker by ensuring the probe correctly passes user-mode pointers before the swap.

7. Debugger PoC Playbook

To validate and analyze this bug on the unpatched binary, attach WinDbg in kernel mode and use the following commands.

Breakpoints

Set targeted breakpoints to monitor the probe and the copy phases:

bp http+0x15f4cb  "printf \"[PROBE] Entry Ptr: %p\\n\", rax; .if (poi(rax+8) > 0xFFFF800000000000) { echo WARNING: Kernel Addr in user struct! }; gc"
bp http+0x15f509  "printf \"[COPY ] Re-read Ptr: %p\\n\", rdx; gc"
bp http+0x15f510  ".if (rdx > 0xFFFF800000000000) { echo [VICTORY] Kernel Address detected in memmove source! } .else { gc }"

What to Inspect

  • At UlGenerateTrailers cold chunk (0x14015f4cb): Inspect rax (the user-mode entry structure). Look at [rax+8] — this is the pointer being submitted to UlProbeAnsiString.
  • At 0x14015f509: Inspect rdx. If rdx differs from what you observed at 0x14015f4cb, the race succeeded. If rdx is greater than 0xFFFF800000000000, a kernel address was swapped in.
  • At the memmove routine (http+0x14de80): rcx is the destination trailer buffer (r13), rdx is the source, r8 is the length.

Key Instructions/Offsets

  • 0x14015f4cb: First fetch of the user pointer.
  • 0x14015f4cf: UlProbeAnsiString call.
  • 0x14015f509: THE BUG. Second fetch of the user pointer.
  • 0x14015f510: The dangerous memmove execution.

Trigger Setup

Drive an HTTP response send through the HTTP Server API (or NtDeviceIoControlFile against \Device\Http\*) with a response descriptor whose trailer field points to an array of at least one 0x18-byte entry, each entry carrying non-zero length fields and user-mode data pointers.

Expected Observation

If the race is lost (the probe catches the kernel address), UlProbeAnsiString raises a STATUS_ACCESS_VIOLATION which http.sys catches internally and converts to a request error. The system remains stable. If the race is won, the breakpoint at 0x14015f510 halts. rdx holds a kernel address; stepping over the call memmove executes the copy, and dumping rcx afterward reveals the leaked kernel memory.

8. Changed Functions — Full Triage

  • UlGenerateTrailers (sub_1400755C0) (Sim: 0.684): Vulnerable. Copies user-supplied HTTP trailer entries, performing a source-pointer double-fetch on entry+0x8 and entry+0x10 (probe with UlProbeAnsiString, then re-read for memmove). Patched by capturing each pointer once into r12, used for the copy when UxKirHttpReadingTrailersFromUm is enabled.
  • UxQuicConnectionCopyBindings (sub_1400E1AB4) (Sim: 0.858): User-mode destination-pointer handling. Copies ALPN/binding data from an in-kernel QUIC connection object (*(a1+1712)) into a caller output buffer under a push lock (a1+0x28). The source is kernel data read once, so this is not a source double-fetch. The unpatched code truncates the destination pointer a3+8 to 32 bits ((unsigned int)(a3+8)); the patched code preserves the full 64-bit destination pointer when UxKirWritingAndReadingUm is set, and only truncates in the rollback path.
  • UxQuicConnectionGetPeerIssuerListClient (sub_1400E28A8) (Sim: 0.898): Destination-base capture. Copies the peer certificate issuer list from an in-kernel QUIC object (*(a1+1680)) into the caller buffer under a shared push lock. Source pointers are kernel data. The patched code, gated on UxKirWritingAndReadingUmCertList, captures the output base a3+16 once instead of re-reading *(a3) (which was just written into the caller-visible buffer) when placing the per-entry header pointers.
  • UxpQuicConnectionCopyCertBlob (sub_1400E9D8C) (Sim: 0.864): User-mode destination-pointer handling. Copies a certificate blob from an in-kernel structure into the caller buffer. The unpatched a2 != 0 branch truncates the destination a3+8 to 32 bits. The patched build moves this branch into the helper UxpQuicConnectionCopyCertBlob32 (sub_1400ea280), which preserves the full 64-bit destination when UxKirWritingAndReadingUm is set.
  • UxStartEnvironmentModule (sub_1400B6AF0) (Sim: 0.942): Initialization / feature staging. Sets the three Known-Issue-Rollback flags UxKirHttpReadingTrailersFromUm, UxKirWritingAndReadingUm, and UxKirWritingAndReadingUmCertList from Windows Feature-staging queries (Feature_2280337722, Feature_1255102777, Feature_1282877755). These flags gate the corrected paths above. Not directly reachable by an attacker.
  • sub_14015F5C7 (Sim: 0.969): This address falls inside the UlGenerateTrailers cold chunk, not a separate function. The register-allocation difference (rdi vs rbx) is a byproduct of the recompile with no independent behavioral change.

9. Unmatched Functions

  • Removed: None.
  • Added: None. (Note: sub_14014de80 in the unpatched build and sub_14014e300 in the patched build are the same memmove routine at a relocated address; the copy helper is unchanged. The behavioral fix lives in the callers, not in a new wrapper. UxpQuicConnectionCopyCertBlob32 (sub_1400ea280) already exists in both builds as UxpQuicConnectionCopyCertBlob32.)

10. Confidence & Caveats

  • Confidence Level: High. The double-fetch assembly pattern is a textbook CWE-367 vulnerability. The fix directly maps to standard mitigation techniques for TOCTOU bugs.
  • Assumptions Made:
  • The attacker has normal, unprivileged access to the HTTP Server API to submit a response with trailer descriptors that reach UlGenerateTrailers.
  • The UxQuicConnection* functions are query paths that copy data out of in-kernel QUIC connection objects into a caller buffer; their changes concern 64-bit user-mode destination-pointer handling rather than a source double-fetch, so they are lower severity than the trailer path.
  • Verification Required: A PoC writer must reconstruct the trailer descriptor layout (count at +0x8, entry-array pointer at +0x10, 0x18-byte entries with data pointers at +0x8/+0x10) that reaches the UlGenerateTrailers loop, and must confirm UxKirHttpReadingTrailersFromUm is enabled on the patched build when validating the fix, since the single-capture path is gated on that flag.