1. Overview

  • Unpatched Binary: dxgmms2_unpatched.sys
  • Patched Binary: dxgmms2_patched.sys
  • Overall Similarity Score: 0.9916
  • Diff Statistics:
  • Matched Functions: 2041
  • Changed Functions: 1
  • Identical Functions: 2040
  • Unmatched (Unpatched / Patched): 0 / 0

Verdict: The patch addresses a Use-After-Free (UAF) in the DirectX graphics video scheduler. A sync-point tracking node is inserted into a per-device linked list before its variable-length sub-array is allocated; on allocation failure the node is freed without being unlinked, leaving a dangling pointer in the list. The next traversal of that list dereferences freed pool memory. The patched build adds a feature-staged code path that allocates the sub-array first and defers the list insertion until the allocation succeeds, so the failure path frees only an unlinked node. The corrective ordering is behind a WIL feature-staging gate and the original insert-before-allocate ordering is retained in the disabled branch. The demonstrable impact is a kernel crash (denial of service) on the reachable allocation-failure path; escalation beyond DoS is not demonstrated, so this is rated Medium.


2. Vulnerability Summary

Medium Severity: Use-After-Free (CWE-416)

  • Affected Function: VidSchiAddPendingCommandToSyncPointList (0x1C0014848 unpatched) — records the allocations referenced by a video-scheduler device command into a per-device sync-point list.
  • Root Cause: The function allocates a 0xA8-byte tracking node (pool tag Viaf) and inserts it into a doubly-linked list rooted at arg1+0x648 (list head Flink) / arg1+0x650 (list head Blink, i.e. the tail pointer). After the insertion it fills two variable-length sub-arrays that hang off the node (+0x18 allocation records of arg4 entries; +0x48 range records of arg5 entries). Each sub-array is heap-allocated only when its count exceeds 2; counts of 0-2 use embedded storage at +0x20/+0x50 and cannot fail. On the reachable submission paths arg5 is a constant 0, so only the +0x18 sub-array (size arg4 << 4, tag Via5) is heap-allocated, and only when arg4 > 2. If that ExAllocatePoolWithTag returns NULL, the error path calls the _VIDSCH_SYNC_POINT scalar deleting destructor at 0x1C0029DF0 to free the node. That destructor releases the node and its sub-arrays with ExFreePoolWithTag but performs no RemoveEntryList, so the freed node stays chained into the list. arg1+0x650 and the previous node's Flink both still point at freed pool memory. The next command submission that walks this list dereferences the freed node.
  • The Fix: The patched function (VidSchiAddPendingCommandToSyncPointList, 0x1C000F918) introduces a WIL feature-staging gate, Feature_1859884346__private_IsEnabledDeviceUsage (0x1C0018F04). When the feature is enabled, the early list insertion is skipped, the sub-array is allocated first, and the node is linked into the list only after the allocation succeeds (deferred insertion at 0x1C000FB20). On allocation failure in that path the node is freed while still unlinked, so no dangling pointer is created. When the feature is disabled the routine keeps the original insert-before-allocate ordering; the vulnerable path is retained in that branch, so this is a staged rollout rather than an unconditional fix in the patched binary.
  • Attacker Entry Point & Data Flow: (call chain verified as direct symbol-name calls in the disassembly)
  • A user-mode D3D application performs an operation that offers, destroys, or terminates video-memory allocations (e.g. D3DKMTOfferAllocations, D3DKMTDestroyAllocation).
  • dxgkrnl.sys dispatches into dxgmms2.sys module entries VidMmOfferAllocation (0x1C000F450) / VidMmCloseAllocation (0x1C000FBD0) / VidMmTerminateAllocation (0x1C000F260).
  • These reach VIDMM_GLOBAL::OfferOneAllocation (0x1C0063DF0) / VIDMM_GLOBAL::TerminateOneAllocation (0x1C0088064) / VIDMM_GLOBAL::TerminateAllocation (0x1C0062A60), each of which calls VidSchSubmitDeviceCommand (0x1C000ED90).
  • VidSchSubmitDeviceCommand computes arg4 by walking the device's tracked-entry list at arg1+0x48 (so arg4 scales with the workload), passes arg5 = 0, and calls VidSchiAddPendingCommandToSyncPointList (0x1C0014848).
  • With arg4 > 2, the +0x18 sub-array ExAllocatePoolWithTag (tag Via5) can fail under memory pressure. The error path frees the tracking node but leaves it linked in the per-device list.
  • A subsequent device-command submission traverses the corrupted list and dereferences the freed pool memory.

3. Pseudocode Diff

// === UNPATCHED (VidSchiAddPendingCommandToSyncPointList @ 0x1C0014848) - VULNERABLE ===
rbx = ExAllocatePoolWithTag(NonPagedPoolNx, 0xA8, 'Viaf');
rbx[2] = arg3;                 // key at +0x10
// init embedded sub-lists at +0x88 / +0x98

// 1. UNCONDITIONAL LINKED LIST INSERTION (before sub-array alloc)
rax = *(arg1 + 0x650);         // list tail (head->Blink)
if (*rax != arg1 + 0x648) FailFast(3);
*rbx = arg1 + 0x648;           // rbx->Flink = head
rbx[1] = rax;                  // rbx->Blink = old tail
*rax = rbx;                    // old_tail->Flink = rbx   *** NODE NOW IN LIST ***
*(arg1 + 0x650) = rbx;         // tail = rbx

// 2. ATTEMPT SUB-ARRAY ALLOCATION
P_alloc = ExAllocatePoolWithTag(NonPagedPoolNx, arg4 << 4,   'Via5');  // +0x18
P_range = ExAllocatePoolWithTag(NonPagedPoolNx, arg5 * 0x18, 'Via5');  // +0x48

// 3. ERROR PATH: free without unlinking
if (!P_alloc || !P_range) {
    if (P_alloc) ExFreePoolWithTag(P_alloc, 0);
    if (P_range) ExFreePoolWithTag(P_range, 0);
    dtor_0x1c0029df0(rbx);     // _VIDSCH_SYNC_POINT dtor: frees rbx, NO RemoveEntryList
    return STATUS_INSUFFICIENT_RESOURCES;  // 0xC0000017
}
// === PATCHED (VidSchiAddPendingCommandToSyncPointList @ 0x1C000F918) - FIXED (feature-gated) ===
rbx = ExAllocatePoolWithTag(NonPagedPoolNx, 0xA8, 'Viaf');
rbx[2] = arg3;
// init embedded sub-lists at +0x88 / +0x98

if (!Feature_1859884346__private_IsEnabledDeviceUsage()) {
    // feature DISABLED: keep old ordering — link node now (before allocs)
    rax = *(arg1 + 0x650);
    if (*rax != arg1 + 0x648) FailFast(3);
    *rbx = arg1 + 0x648; rbx[1] = rax; *rax = rbx; *(arg1 + 0x650) = rbx;
}

// 1. ALLOCATE SUB-ARRAYS
P_alloc = ExAllocatePoolWithTag(NonPagedPoolNx, arg4 << 4,   'Via5');  // +0x18
P_range = ExAllocatePoolWithTag(NonPagedPoolNx, arg5 * 0x18, 'Via5');  // +0x48

// 2. ERROR PATH
if (!P_alloc || !P_range) {
    if (!Feature_1859884346__private_IsEnabledDeviceUsage()) {
        if (P_alloc) ExFreePoolWithTag(P_alloc, 0);
        if (P_range) ExFreePoolWithTag(P_range, 0);
    }
    dtor_0x1c0029e60(rbx);     // dtor; when feature enabled the node was never linked
    return STATUS_INSUFFICIENT_RESOURCES;
}

// 3. FEATURE ENABLED: insert into list ONLY AFTER both allocs succeed
if (Feature_1859884346__private_IsEnabledDeviceUsage()) {
    rax = *(arg1 + 0x650);
    if (*rax != arg1 + 0x648) FailFast(3);
    *rbx = arg1 + 0x648; rbx[1] = rax; *rax = rbx; *(arg1 + 0x650) = rbx;  // safe link
}

4. Assembly Analysis

Unpatched Sequence (The Bug)

The node is linked at 0x1c0014993 (before any sub-array allocation). The sub-array allocations live in a cold block near 0x1c00211aa/0x1c00211df; on failure control reaches 0x1c0021181, where rbx is freed without being unlinked.

; === NODE ALLOCATION ===
0x1c0014922: mov edx, 0A8h                   ; NumberOfBytes
0x1c0014927: mov ecx, r12d                   ; NonPagedPoolNx (0x200)
0x1c001492a: mov r8d, 66616956h              ; tag 'Viaf'
0x1c0014930: call ExAllocatePoolWithTag

; === UNCONDITIONAL LINKED LIST INSERTION (BEFORE SUB-ARRAY ALLOC) ===
0x1c0014966: mov [rbx+10h], rsi              ; rbx->key = arg3
0x1c0014986: mov rax, [rdi+8]                ; rax = tail (head->Blink), rdi = arg1+0x648
0x1c001498a: cmp [rax], rdi                  ; verify tail->Flink == head
0x1c001498d: jnz 0x1c002142b                 ; FailFast on corruption
0x1c0014993: mov [rbx], rdi                  ; rbx->Flink = head
0x1c001499f: mov [rax], rbx                  ; old_tail->Flink = rbx   *** IN LIST ***
0x1c00149a2: mov [rdi+8], rbx                ; head->Blink = rbx (tail = rbx)

; === SUB-ARRAY ALLOCATION (AFTER insertion) ===
0x1c00211b0: shl rdx, 4                       ; arg4 * 16
0x1c00211b7: call ExAllocatePoolWithTag       ; tag 'Via5' (may fail)  -> +0x18
0x1c00211ef: call ExAllocatePoolWithTag       ; tag 'Via5', arg5*0x18   -> +0x48

; === ERROR PATH: free without unlink ===
0x1c0021411: call ExFreePoolWithTag           ; free +0x18 sub-array (rcx = r12)
0x1c0021175: call ExFreePoolWithTag           ; free +0x48 sub-array (rcx = rsi; only if arg5 > 2)
0x1c0021181: mov rcx, rbx                     ; rbx = node STILL IN THE LIST
0x1c0021184: call ??_G_VIDSCH_SYNC_POINT@@..  ; _VIDSCH_SYNC_POINT dtor (0x1c0029df0): frees rbx, NO unlink
0x1c0021189: mov eax, 0C0000017h              ; STATUS_INSUFFICIENT_RESOURCES

; === 0x1c0029df0 (_VIDSCH_SYNC_POINT scalar deleting destructor) ===
0x1c0029dfd..0x1c0029e14: free [rbx+0x48] sub-array (if not embedded)
0x1c0029e29..0x1c0029e40: free [rbx+0x18] sub-array (if not embedded)
0x1c0029e58: call ExFreePoolWithTag(rbx, 0)   ; free NODE. No RemoveEntryList anywhere.

Patched Sequence (The Fix)

The feature gate at 0x1c000f9f2 skips the early insertion when the feature is enabled; the sub-arrays are allocated, and the insertion is performed at 0x1c000fb20, strictly after both allocation checks pass.

; === NODE ALLOCATION ===
0x1c000f99d: call ExAllocatePoolWithTag       ; 0xA8, tag 'Viaf'
0x1c000f9d2: mov [rbx+10h], rsi               ; key = arg3

; === FEATURE GATE #1 (skip early insertion when enabled) ===
0x1c000f9f2: call Feature_1859884346__private_IsEnabledDeviceUsage
0x1c000f9f7: test eax, eax
0x1c000f9f9: jnz 0x1c000fa16                   ; enabled -> jump to allocation, do NOT link
0x1c000fa08: mov [rbx], rdi                    ; (disabled path only) link before alloc
0x1c000fa0f: mov [rax], rbx

; === ALLOCATE SUB-ARRAYS ===
0x1c000fa43: call ExAllocatePoolWithTag        ; +0x18, tag 'Via5', arg4<<4
0x1c000fab1: call ExAllocatePoolWithTag        ; +0x48, tag 'Via5', arg5*0x18

; === CHECK BOTH ALLOCATIONS SUCCEEDED ===
0x1c000faf8: test r12, r12                     ; if (!P_alloc)
0x1c000fafb: jz 0x1c000fe67                    ; -> cleanup
0x1c000fb01: test r14, r14                     ; if (!P_range)
0x1c000fb04: jz 0x1c000fe67                    ; -> cleanup

; === FEATURE GATE #2: insert only after allocs succeed ===
0x1c000fb0a: call Feature_1859884346__private_IsEnabledDeviceUsage
0x1c000fb11: jz 0x1c000fb2e                     ; disabled -> already linked, skip
0x1c000fb20: mov [rbx], rdi                     ; rbx->Flink = head
0x1c000fb27: mov [rax], rbx                     ; *** LINK INTO LIST (safe) ***
0x1c000fb2a: mov [rdi+8], rbx                   ; tail = rbx

; === CLEANUP (allocation failed) ===
0x1c000fe67: call Feature_1859884346__private_IsEnabledDeviceUsage
0x1c000fe70..0x1c000fe97: (disabled) free sub-arrays
0x1c000fe9f: call 0x1c0029e60                   ; _VIDSCH_SYNC_POINT dtor (node never linked when enabled)

5. Trigger Conditions

To reach the UAF an attacker must force a sub-array allocation failure during scheduler command submission:

  1. Obtain a Handle: Open a DirectX graphics adapter device handle from a user-mode process (e.g. via D3DKMTOpenAdapter).
  2. Prepare Resources: Create video-memory allocations referenced by the graphics scheduler using D3DKMTCreateAllocation / CreateResource.
  3. Induce Memory Pressure: Exhaust NonPagedPoolNx to maximize the chance of ExAllocatePoolWithTag (tag Via5) failing, e.g. by spraying kernel pool from multiple processes.
  4. Invoke a Path That Submits a Device Command: Operations such as D3DKMTOfferAllocations or allocation destruction reach VIDMM_GLOBAL::OfferOneAllocation (0x1C0063DF0) / VIDMM_GLOBAL::TerminateOneAllocation (0x1C0088064) / VIDMM_GLOBAL::TerminateAllocation (0x1C0062A60), which call VidSchSubmitDeviceCommand (0x1C000ED90) and then VidSchiAddPendingCommandToSyncPointList (0x1C0014848). VidSchSubmitDeviceCommand derives arg4 (the +0x18 allocation-record count) by walking the device's tracked-entry list at arg1+0x48, and passes arg5 = 0.
  5. Hit the Error Path: Cause enough qualifying entries so that arg4 > 2, making the +0x18 sub-array (arg4 << 4 bytes, tag Via5) a heap allocation that fails under the induced pressure. (arg5 is 0 on these paths, so the +0x48 sub-array uses embedded storage and cannot fail.) The node is freed while still linked into the per-device list at arg1+0x648.
  6. Trigger the Dereference: Submit another device command. Function entry reads [arg1+0x650] (the tail pointer); if the new key matches it compares [rbx+0x10] against the new key (0x1C00148A7), otherwise the re-link path reads the freed tail's Flink (0x1C0014986) — either way dereferencing the freed node.

Observable Effect: The demonstrable result is a kernel crash: the system bug-checks with PAGE_FAULT_IN_NONPAGED_AREA (0x50) or KERNEL_MODE_HEAP_CORRUPTION / POOL_CORRUPTION (0x19) when the freed node is dereferenced.


6. Exploit Primitive & Development Notes

  • Primitive Type: Use-After-Free of a 0xA8-byte NonPagedPoolNx chunk (tag Viaf).
  • Demonstrable Impact: After the error path frees the node, the per-device list tail (arg1+0x650) and the previous node's Flink still reference the freed chunk. The next traversal of this list dereferences freed pool memory. The reliably reachable outcome is a kernel crash (denial of service) from a standard-user process, as noted in the observable effect above.
  • Fields Referenced on Re-Traversal: When the list is next walked, the freed chunk is interpreted as a _VIDSCH_SYNC_POINT node: the key at +0x10, the sub-array pointers at +0x18 and +0x48, and the embedded sub-list heads at +0x88/+0x98. If the freed chunk were reclaimed with attacker data before the traversal, these fields would be read/followed as node state.
  • Escalation Beyond DoS (not demonstrated): Turning this into memory corruption would require reclaiming the freed 0xA8-byte NonPagedPoolNx chunk with controlled contents inside the window between the error-path free and the next list traversal. The trigger itself depends on forcing a NonPagedPoolNx allocation failure (memory pressure), which is non-deterministic. Neither a reliable reclaim under that condition nor a resulting read/write primitive is shown here; the impact substantiated by the binaries is denial of service. The severity is rated Medium on that basis.

7. Debugger PoC Playbook

To analyze the bug against the unpatched binary with WinDbg/KD attached:

Breakpoints

bp dxgmms2!VidSchiAddPendingCommandToSyncPointList        "Function entry (0x1c0014848). Inspect arg1, arg4(r9d), arg5"
bp 0x1c0014993                                            "List insertion (mov [rbx], rdi) BEFORE sub-array alloc"
bp 0x1c0021184                                            "Error path: call to _VIDSCH_SYNC_POINT dtor (free without unlink)"
bp 0x1c00148a7                                            "Re-entry UAF read: cmp [rbx+0x10], rsi on stale tail node"

What to Inspect at Each Breakpoint

  • Entry (0x1c0014848):
  • rcx: _VIDSCH_DEVICE context (list head at +0x648, tail pointer at +0x650).
  • r9d: arg4 (allocation-record count, workload-scaled); the +0x18 sub-array is heap-allocated (size r9d << 4) only when r9d > 2.
  • arg5 (range count) is 0 on the reachable submission paths, so the +0x48 sub-array uses embedded storage and is not allocated.
  • 0x1c0014993 (List Insertion):
  • rbx: the newly allocated 0xA8-byte node. !pool rbx 2 should show tag Viaf. Once this executes the node is in the list and becomes orphaned if a later allocation fails.
  • 0x1c0021184 (Error Cleanup):
  • rbx: the node being freed. dt _LIST_ENTRY rbx shows Flink/Blink still pointing into the live list. After the call, [arg1+0x650] is a dangling pointer.
  • 0x1c00148a7 (UAF Dereference):
  • rbx: the pointer loaded from [rcx+0x650]. cmp [rbx+0x10], rsi reads freed memory.

Key Instructions/Offsets

  • 0x1c0014993: mov [rbx], rdi — links node before sub-array allocation.
  • 0x1c0021184: call 0x1c0029df0 (_VIDSCH_SYNC_POINT dtor) — frees node (still linked) on allocation failure.
  • 0x1c00148a7: cmp [rbx+0x10], rsi — UAF read on a subsequent submission.

Trigger Setup

  1. Run a standard-user process.
  2. Write a C/C++ tool that repeatedly performs allocation offer/terminate operations (e.g. D3DKMTOfferAllocations followed by destruction) with a large, growing set of allocations so that the device's tracked-entry count (arg4) exceeds 2 and the +0x18 sub-array becomes a heap allocation.
  3. To reach the failure path, drive NonPagedPoolNx toward exhaustion so ExAllocatePoolWithTag (tag Via5) for the +0x18 sub-array returns NULL. This trigger is probabilistic, not deterministic.

Expected Observation

On the next list traversal after the free (e.g. the re-entry read at 0x1c00148a7), the kernel bug-checks with 0x50 (PAGE_FAULT_IN_NONPAGED_AREA) or 0x19 (POOL_CORRUPTION) while dereferencing the freed node. !pool rbx shows the allocation as freed.

Struct/Offset Notes

  • _VIDSCH_DEVICE (arg1):
  • +0x48: allocation list walked to fill sub-array 1.
  • +0x58: allocation list walked to fill sub-array 2.
  • +0x648: sync-point list head (LIST_ENTRY Flink).
  • +0x650: sync-point list tail (LIST_ENTRY Blink).
  • Tracking Node _VIDSCH_SYNC_POINT (rbx):
  • +0x00: Flink
  • +0x08: Blink
  • +0x10: Key (arg3)
  • +0x18: sub-array 1 pointer (allocations, count at +0x40; embedded storage at +0x20)
  • +0x48: sub-array 2 pointer (ranges, count at +0x80; embedded storage at +0x50)
  • +0x88 / +0x98: embedded LIST_ENTRY sub-list heads (selected by command type arg6).

8. Changed Functions — Full Triage

VidSchiAddPendingCommandToSyncPointList (0x1C0014848 unpatched) — Patched: 0x1C000F918

  • Similarity Score: 0.856
  • Change Type: Security Relevant / Logic Reorder (feature-gated)
  • Key Changes:
  • Feature-Gated Reordering: The patched build adds calls to Feature_1859884346__private_IsEnabledDeviceUsage (0x1C0018F04). When the feature is enabled, the node is not linked before allocation; the sub-array is allocated first, and the node is linked into the list only after the allocation succeeds (0x1C000FB20). When the feature is disabled the original insert-before-allocate ordering is retained, so the patched binary still contains the vulnerable path in that branch.
  • Error Path: In the enabled path the failure cleanup frees an unlinked node, eliminating the dangling-pointer window. The node cleanup uses the _VIDSCH_SYNC_POINT scalar deleting destructor (0x1C0029DF0 unpatched / 0x1C0029E60 patched); neither version performs RemoveEntryList (the fix is the reordering, not an added unlink).
  • Register Reallocation: r15d/r14d/r13d usage shifts to accommodate the restructured control flow (non-behavioral).

9. Unmatched Functions

No functions were added or removed between the unpatched and patched binaries. The single changed function is VidSchiAddPendingCommandToSyncPointList (0x1C0014848 unpatched / 0x1C000F918 patched).


10. Confidence & Caveats

  • Mechanism Confidence: High. In the unpatched build the node is unconditionally linked into the per-device list before the +0x18 sub-array allocation, and the failure path frees it through the _VIDSCH_SYNC_POINT destructor with no RemoveEntryList — a missing-unlink UAF. On re-entry the freed tail is dereferenced (0x1C00148A7 / 0x1C0014986). The patched build allocates the sub-array first and defers insertion until it succeeds.
  • Reachability: Confirmed. VidSchiAddPendingCommandToSyncPointList is called only from VidSchSubmitDeviceCommand (0x1C000ED90), which is called by VIDMM_GLOBAL::OfferOneAllocation (0x1C0063DF0), VIDMM_GLOBAL::TerminateAllocation (0x1C0062A60), and VIDMM_GLOBAL::TerminateOneAllocation (0x1C0088064) — all direct calls. These sit under the VidMm module entry points for D3DKMT offer / destroy / terminate, so the path is reachable from a standard-user process, not init-only. arg4 is workload-scaled (a count of qualifying entries on the device's arg1+0x48 list); arg5 is a constant 0, so only the +0x18 sub-array is heap-allocated.
  • Feature Gating: The corrective ordering is controlled by the WIL feature-staging gate Feature_1859884346 (__private_IsEnabledDeviceUsage, backed by the static Feature_1859884346__private_featureState and a descriptor-driven fallback). The safe ordering is in effect only when the feature is enabled; when disabled the routine keeps the insert-before-allocate ordering, so the patched binary retains the vulnerable path behind the gate (staged rollout).
  • Severity Rationale: Medium. The demonstrable impact is a kernel crash (denial of service) reached by forcing a NonPagedPoolNx allocation failure (memory pressure), a non-deterministic trigger. Escalation to memory corruption would additionally require reclaiming the freed 0xA8-byte chunk within the pre-traversal window and is not demonstrated here. CWE-416.