dxgmms2.sys — Use-After-Free (CWE-416) in VidSchiAddPendingCommandToSyncPointList fixed
KB5094127
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(0x1C0014848unpatched) — 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 atarg1+0x648(list headFlink) /arg1+0x650(list headBlink, i.e. the tail pointer). After the insertion it fills two variable-length sub-arrays that hang off the node (+0x18allocation records ofarg4entries;+0x48range records ofarg5entries). Each sub-array is heap-allocated only when its count exceeds 2; counts of 0-2 use embedded storage at+0x20/+0x50and cannot fail. On the reachable submission pathsarg5is a constant 0, so only the+0x18sub-array (sizearg4 << 4, tagVia5) is heap-allocated, and only whenarg4 > 2. If thatExAllocatePoolWithTagreturnsNULL, the error path calls the_VIDSCH_SYNC_POINTscalar deleting destructor at0x1C0029DF0to free the node. That destructor releases the node and its sub-arrays withExFreePoolWithTagbut performs noRemoveEntryList, so the freed node stays chained into the list.arg1+0x650and the previous node'sFlinkboth 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 at0x1C000FB20). 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.sysdispatches intodxgmms2.sysmodule entriesVidMmOfferAllocation(0x1C000F450) /VidMmCloseAllocation(0x1C000FBD0) /VidMmTerminateAllocation(0x1C000F260).- These reach
VIDMM_GLOBAL::OfferOneAllocation(0x1C0063DF0) /VIDMM_GLOBAL::TerminateOneAllocation(0x1C0088064) /VIDMM_GLOBAL::TerminateAllocation(0x1C0062A60), each of which callsVidSchSubmitDeviceCommand(0x1C000ED90). VidSchSubmitDeviceCommandcomputesarg4by walking the device's tracked-entry list atarg1+0x48(soarg4scales with the workload), passesarg5 = 0, and callsVidSchiAddPendingCommandToSyncPointList(0x1C0014848).- With
arg4 > 2, the+0x18sub-arrayExAllocatePoolWithTag(tagVia5) 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:
- Obtain a Handle: Open a DirectX graphics adapter device handle from a user-mode process (e.g. via
D3DKMTOpenAdapter). - Prepare Resources: Create video-memory allocations referenced by the graphics scheduler using
D3DKMTCreateAllocation/CreateResource. - Induce Memory Pressure: Exhaust
NonPagedPoolNxto maximize the chance ofExAllocatePoolWithTag(tagVia5) failing, e.g. by spraying kernel pool from multiple processes. - Invoke a Path That Submits a Device Command: Operations such as
D3DKMTOfferAllocationsor allocation destruction reachVIDMM_GLOBAL::OfferOneAllocation(0x1C0063DF0) /VIDMM_GLOBAL::TerminateOneAllocation(0x1C0088064) /VIDMM_GLOBAL::TerminateAllocation(0x1C0062A60), which callVidSchSubmitDeviceCommand(0x1C000ED90) and thenVidSchiAddPendingCommandToSyncPointList(0x1C0014848).VidSchSubmitDeviceCommandderivesarg4(the+0x18allocation-record count) by walking the device's tracked-entry list atarg1+0x48, and passesarg5 = 0. - Hit the Error Path: Cause enough qualifying entries so that
arg4 > 2, making the+0x18sub-array (arg4 << 4bytes, tagVia5) a heap allocation that fails under the induced pressure. (arg5is 0 on these paths, so the+0x48sub-array uses embedded storage and cannot fail.) The node is freed while still linked into the per-device list atarg1+0x648. - 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'sFlink(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
NonPagedPoolNxchunk (tagViaf). - Demonstrable Impact: After the error path frees the node, the per-device list tail (
arg1+0x650) and the previous node'sFlinkstill 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_POINTnode: the key at+0x10, the sub-array pointers at+0x18and+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
NonPagedPoolNxchunk with controlled contents inside the window between the error-path free and the next list traversal. The trigger itself depends on forcing aNonPagedPoolNxallocation 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_DEVICEcontext (list head at+0x648, tail pointer at+0x650).r9d:arg4(allocation-record count, workload-scaled); the+0x18sub-array is heap-allocated (sizer9d << 4) only whenr9d > 2.arg5(range count) is 0 on the reachable submission paths, so the+0x48sub-array uses embedded storage and is not allocated.0x1c0014993(List Insertion):rbx: the newly allocated 0xA8-byte node.!pool rbx 2should show tagViaf. 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 rbxshowsFlink/Blinkstill 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], rsireads freed memory.
Key Instructions/Offsets
0x1c0014993:mov [rbx], rdi— links node before sub-array allocation.0x1c0021184:call 0x1c0029df0(_VIDSCH_SYNC_POINTdtor) — frees node (still linked) on allocation failure.0x1c00148a7:cmp [rbx+0x10], rsi— UAF read on a subsequent submission.
Trigger Setup
- Run a standard-user process.
- Write a C/C++ tool that repeatedly performs allocation offer/terminate operations (e.g.
D3DKMTOfferAllocationsfollowed by destruction) with a large, growing set of allocations so that the device's tracked-entry count (arg4) exceeds 2 and the+0x18sub-array becomes a heap allocation. - To reach the failure path, drive
NonPagedPoolNxtoward exhaustion soExAllocatePoolWithTag(tagVia5) for the+0x18sub-array returnsNULL. 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_ENTRYFlink).+0x650: sync-point list tail (LIST_ENTRYBlink).- 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: embeddedLIST_ENTRYsub-list heads (selected by command typearg6).
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_POINTscalar deleting destructor (0x1C0029DF0unpatched /0x1C0029E60patched); neither version performsRemoveEntryList(the fix is the reordering, not an added unlink). - Register Reallocation:
r15d/r14d/r13dusage 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
+0x18sub-array allocation, and the failure path frees it through the_VIDSCH_SYNC_POINTdestructor with noRemoveEntryList— 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.
VidSchiAddPendingCommandToSyncPointListis called only fromVidSchSubmitDeviceCommand(0x1C000ED90), which is called byVIDMM_GLOBAL::OfferOneAllocation(0x1C0063DF0),VIDMM_GLOBAL::TerminateAllocation(0x1C0062A60), andVIDMM_GLOBAL::TerminateOneAllocation(0x1C0088064) — all direct calls. These sit under theVidMmmodule entry points for D3DKMT offer / destroy / terminate, so the path is reachable from a standard-user process, not init-only.arg4is workload-scaled (a count of qualifying entries on the device'sarg1+0x48list);arg5is a constant 0, so only the+0x18sub-array is heap-allocated. - Feature Gating: The corrective ordering is controlled by the WIL feature-staging gate
Feature_1859884346(__private_IsEnabledDeviceUsage, backed by the staticFeature_1859884346__private_featureStateand 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
NonPagedPoolNxallocation 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.