
Executive Summary
CVE-2026-58629 is a local elevation-of-privilege bug in dxgkrnl, the Windows Display Driver Model (WDDM) kernel component that every graphical session already talks to. The vulnerability lives in the rollback path of D3DKMTCreateAllocation: when the create succeeds but a later output write-back fails, the kernel tears down what it thinks it built — and to decide what to destroy, it re-reads a user-controlled handle array that it never captured into kernel memory. Because the same array slot is read twice with real kernel work in between, an attacker gets a genuine double-fetch (TOCTOU) window.
That double-fetch yields two primitives: freeing an allocation of the attacker’s choosing (a use-after-free on a still-referenced object) and a full kernel-pool double-free of a DXGALLOCATION C++ object with a vtable — the classic shape from which kernel code execution is built. The trigger is largely deterministic: flipping the output page to PAGE_READONLY makes the write-back fault on demand, leaving only a wide, forgiving race to swap the second read’s value. Microsoft classified it as use-after-free (CWE-416), attack complexity high, and fixed it in build 26100.8875 behind a servicing feature flag. This article walks the rollback, both primitives, the crash, and the fix.
Getting to the Code
User mode reaches dxgkrnl through the D3DKMT* API exposed by gdi32full.dll, which thunks down into the NtGdiDdDDI* syscalls. There is nothing exotic about the entry point: any graphical session already owns an adapter handle, so a sandboxed renderer or a background service can reach this surface just as easily as a foreground application. That broad reachability is a big part of why the WDDM kernel is such productive hunting ground.
D3DKMTCreateAllocation creates one or more GPU allocations for a device; its kernel body is DxgkCreateAllocationInternal. The interesting behaviour is not the create itself — it is what happens when the create succeeds but a subsequent step fails.
The Rollback
Consider a create with no resource attached (hResource == 0). Inside DxgkCreateAllocationInternal the flow is roughly:
- Capture the caller’s
D3DKMT_CREATEALLOCATIONinto a kernel-stack copy. - Call
DXGDEVICE::CreateAllocation. On success it allocates the objects and writes their handles back into the user’spAllocationInfo[]array. - Copy a few output fields (
hResource,hGlobalShare,NumAllocations) back into the user struct. - Return.
Step 3 sits inside the function’s SEH scope, and that is the lever. If the caller flips the destination page to non-writable after the input capture in step 1 but before the write-back in step 3, the kernel’s own store faults and the exception handler takes over. The disassembly makes the mechanism concrete:
140301a23: mov [rsp+0xb0], eax ; status = CreateAllocation() result (>= 0 on success)
...
140301a72: lea rcx, [r14+8] ; &user->hGlobalShare
140301a95: call RtlCopyVolatileMemory ; write-back; faults here if the user page is read-only
; the function's __except handler overwrites [rsp+0xb0] with 0xC000000D and resumes below:
140301b1d: mov r14d, [rsp+0xb0] ; reload status (now negative)
140301b28: js 0x14030226e ; negative status -> rollback decision
14030226e: test r12b, r12b ; "allocation was created" flag (still set)
At this point the status is negative but the “allocation was created” flag is still set. The common path reads that combination as “we built something, then failed” and rolls back to tear the allocation down. Crucially, that continuation lives in the unwind metadata rather than in the linear control flow, which is why a decompiler happily presents a tidy function with no obvious rollback at all. As the author notes, it takes a look at the .xdata to see where control actually goes on the fault — the clean pseudocode hides it.
Two Reads of the Same Pointer
Here is the rollback, per array entry. It reads pAllocationInfo[i].hAllocation out of user memory twice, with real kernel work in between:
; FIRST READ - resolve the handle and queue it for destruction
140302404: mov r9d, [r12 + rax*1] ; r12 = i*0x60, rax = pAllocationInfo; r9d = handle X
; validate X: generation tag, not already destroying (0x2000), type-code 5 (allocation)
1403025e5: mov rax, [r8 + rcx*8] ; translate to its DXGALLOCATION* kernel pointer
1403024c5: mov [rcx + rdx*8], rax ; destroy_table[i] = kernel object pointer
; between the reads: the handle-table walk and the store above
; SECOND READ - same array slot
14030251f: mov r8d, [r12 + rax*1] ; r8d = handle Y
; validate Y: generation tag, not destroying, type tag non-zero
140302571: or [r9 + rax*8 + 8], r14d ; r14d = 0x2000; set the "destroying" bit on Y's slot
The struct itself is captured into the kernel stack. The array it points to is not, and the rollback dereferences it live. Whatever ends up in destroy_table is then handed to DXGDEVICE::DestroyAllocationInternal, which frees every object in it via RemoveAllocationFromList, HMGRTABLE::FreeHandle, and the object’s deleting destructor.
Primitive One: Free an Allocation of Your Choosing
Look at what the first read is actually validated against. The handle at 140302404 only has to be a live, type-5 allocation handle owned by the calling process. That is the whole check. Nothing verifies that it is the allocation this call just created. The value comes out of user memory, gets resolved to a DXGALLOCATION*, and drops into the destroy table.
So place a handle to some other allocation you own — one you created earlier and still have mapped and referenced — into pAllocationInfo[i].hAllocation, and the rollback frees that instead. The allocation the API pretended to create is left untouched; the object you named gets destroyed. If it is still referenced anywhere else in the kernel, you now have a use-after-free on an object of your choosing, with no race required to get it.
Primitive Two: Walk Past the Guard for a Double-Free
That 0x2000 “destroying” bit set by the second read is the anti-duplicate guard. First-read validation rejects any handle whose slot already carries it, so under normal single-fetch semantics an object is queued for teardown at most once: the second read flags it, and any later entry naming the same handle bounces.
The double-fetch lets the bug step around its own guard. Fill the array with entries that all name victim B, but arrange for the second read of iteration zero to come back as some decoy handle C. The 0x2000 bit lands on C. B is never flagged, so iteration one reads B again, passes validation, and queues it a second time. destroy_table ends up holding B twice, and DestroyAllocationInternal runs the entire teardown on the same DXGALLOCATION object twice over — a kernel-pool double-free, with the attacker choosing both the victim and the repeat count (bounded by NumAllocations, itself capped at 0x682AA).
No Race for the Hard Part
What makes this bug pleasant to trigger is that the fault is deterministic. The kernel reads the input page during capture and writes to it during the output write-back — the same page, two different access types. Mark it PAGE_READONLY and the read-based capture still succeeds while the write-back faults every single time, so the rollback fires on demand. The only timing-sensitive piece left is swapping B for C on the second read, and that is a wide, forgiving interleave rather than the usual one-instruction knife-edge. In practice, a second thread flipping the value in a loop lands it in well under a second.
Actually Reaching It
There is a genuine catch, and it shaped most of the work. The rollback only re-reads user memory when the create produced no resource: the captured hResource has to still be zero when the rollback looks at it. Set Flags.CreateResource and the create builds a resource, fills that field in, and cleanup takes a completely different branch that frees the resource by a kernel-side handle and never touches user memory again.
That is awkward, because the easy way for an unprivileged process to make allocations without a vendor UMD is StandardAllocation — and StandardAllocation always sets CreateResource, so it never reaches the bug. To get there you need a standalone allocation (CreateResource == 0), and that requires a valid per-allocation pPrivateDriverData blob: the opaque UMD-to-KMD contract that dxgkrnl does not parse and that differs on every GPU.
You do not, however, have to reverse that blob per vendor. Just borrow it from the driver already installed: hook D3DKMTCreateAllocation in-process, let the runtime create a throwaway texture, and capture the blob the vendor UMD hands down as it calls through. Then replay it yourself with CreateResource == 0. That makes the whole technique GPU-agnostic — it runs on any box with a real user-mode driver, with no vendor-specific data baked into the PoC.
The Crash
Standalone allocation, a destroy table with the same object in it twice, and the second teardown pass falls over:
SYSTEM_SERVICE_EXCEPTION (3b)
Exception 0xC0000005 at nt!ExfReleaseRundownProtection+0x32
lock xadd qword ptr [r8], rax ds:00000000`00000000 ; r8 = NULL
nt!ExfReleaseRundownProtection+0x32
dxgkrnl!DxgkUnreferenceDxgAllocation+0x14
dxgkrnl!DXGDEVICE::DestroyAllocations+0x497
dxgkrnl!DXGDEVICE::TerminateAllocations+0x387
dxgkrnl!DXGDEVICE::DestroyAllocationInternal+0x348
dxgkrnl!DxgkCreateAllocationInternal+0xf50
dxgkrnl!DxgkCreateAllocation+0xb
nt!KiSystemServiceCopyEnd
win32u!NtGdiDdDDICreateAllocation
The first pass runs down the object’s rundown reference. The second pass reaches DxgkUnreferenceDxgAllocation on the same object, whose rundown reference is already gone, and the atomic decrement dereferences NULL. The single call stack shows the object going through teardown twice on one D3DKMTCreateAllocation — exactly what you would want to see.
Impact
Local EoP, and MSRC scored it about how you would expect for a kernel-pool double-free: use-after-free (CWE-416), attack complexity high (you have to win a race), kernel code execution as the ceiling. That lines up with the primitive. The double-fetch hands you two of them — an arbitrary free of a still-referenced object, and a double-free — and the double-free is the one you would build on. DXGALLOCATION is a C++ object with a vtable and a scalar deleting destructor, so a double-free of it is the classic shape kernel code execution comes from: reclaim the freed chunk with your own bytes and the teardown dispatches through a pointer you control.
The race in that AC:H is the one from earlier. The trigger is deterministic; what you win is the second read of an entry coming back as your decoy, so the guard lands on the wrong slot and the same object goes into the destroy table twice.
Getting there is not a freebie, and this is where the rundown reference from the crash comes back. The second teardown pass faults inside ExfReleaseRundownProtection — the object’s reference was already run down by the first pass, before it ever reaches the deleting destructor — and the two passes run back-to-back inside the one syscall. So there is no obvious user-mode moment to slip your own bytes into the freed chunk between the frees. The real work in a double-free like this is not producing it; it is opening that reclaim window and controlling what lands there. On its own, what the crash demonstrates is the double-teardown: one object, two passes, one syscall.
It is also easy to walk straight past. Read the rollback as a single fetch and the worst you see is a handle that can get wedged in the “destroying” state — a benign-looking state-machine quirk and nothing more. The extra free comes entirely from the second read being decoupled from the first, and you do not notice that unless you already went in looking for a double-fetch.
The Fix
The fix does the obvious thing: stop trusting the array. It landed in build 26100.8875, gated behind a servicing feature flag (Feature_1677956409), with the original loop left intact as the fallback for when the flag is off.
With the flag on, the rollback never reads pAllocationInfo again. As soon as CreateAllocation succeeds it records what actually got built, walking the device’s own allocation list instead of anything the caller passed in:
if (feature_on && hResource == 0) {
node = device->AllocationListHead; // DXGDEVICE + 0x30
for (i = 0; i < NumAllocations && node; i++) {
destroy_table[i] = node; // the allocation this call created
node = node->Next; // + 0x40
}
}
And the 0x2000 “destroying” flag that used to come out of the second user read now comes from the allocation’s own handle — the one the kernel stored on the object:
h = destroy_table[i]->m_hAllocation; // + 0x10, no user memory involved
That is all of it. The destroy table is built from the kernel’s own record of what the call made, and every handle it flags is one the kernel wrote and the caller cannot reach. There is no second fetch to decouple from the first, and no user pointer in the table to redirect. Both primitives close at once, because they were always the same bug underneath: a rollback that asked user memory what it had just built.
The fallback is the part worth a second look. Turn the flag off and the old double-fetch loop is still there, byte for byte. On a patched box the flag is on, so it does not matter in practice — but it is why the vulnerable function still reads as vulnerable if you diff against the wrong build, which is exactly the trap the author fell into before finding the build that actually carried the fix.
Key Takeaways
- Double-fetch in an error path. The rollback in
DxgkCreateAllocationInternalre-reads the user-controlledpAllocationInfo[]array twice; the struct was captured to kernel stack, but the array it points to was not. - Two primitives, one bug. The first read frees an allocation of the attacker’s choosing (arbitrary free of a still-referenced object); the second read’s decoupling defeats the
0x2000“destroying” guard, yielding a kernel-pool double-free. - Deterministic trigger. Marking the output page
PAGE_READONLYmakes the write-back fault on demand, so the rollback fires reliably; only the decoy swap on the second read needs a (wide, forgiving) race. - Reachability constraint. The bug only fires for standalone allocations (
CreateResource == 0), which requires a valid vendorpPrivateDriverDatablob — obtained GPU-agnostically by hooking the real UMD in-process and replaying its blob. - High-value target object.
DXGALLOCATIONis a C++ object with a vtable and scalar deleting destructor; a double-free of it is the classic route to kernel code execution once the freed chunk is reclaimed. - The fix removes the trust. Build 26100.8875 walks the device’s own allocation list instead of the user array, and reads the “destroying” flag from the kernel-stored handle — closing both primitives at once (behind feature flag
Feature_1677956409).
Defensive Recommendations
- Patch to build 26100.8875 or later and confirm the servicing feature flag
Feature_1677956409is enabled — the vulnerable loop remains present as an off-by-default fallback, so build number alone is not proof of protection. - Do not rely on version diffing against the wrong build. The unpatched loop still reads as vulnerable when the flag is off; verify the actual servicing state rather than the function bytes.
- Treat the WDDM/
dxgkrnlattack surface as reachable from low-privilege and sandboxed code — any graphical session owns an adapter handle. Factor this into threat models for browser renderers and other constrained processes. - Audit your own kernel/driver code for double-fetch patterns: capture caller-supplied structures and every buffer they point to into kernel memory once, then operate only on the captured copy — never re-read user memory in error/rollback paths.
- Build teardown tables from kernel-owned state, not from user-supplied handles; validate that objects queued for destruction were actually created by the current operation, not merely owned by the caller.
- Deploy kernel exploit-mitigation and pool hardening (e.g. pool zeroing, low-fragmentation heap isolation, and driver-integrity/HVCI protections) to raise the cost of reclaiming a freed
DXGALLOCATIONchunk. - Hunt for anomalous
NtGdiDdDDICreateAllocationusage — repeated calls that fault on write-back to a deliberately read-only output page are a strong behavioural signal for attempts against this class of bug.
Conclusion
CVE-2026-58629 is a compact demonstration of how a single decoupled read in an error path can turn a “clean” function into a kernel-pool double-free. The rollback logic looked correct precisely because the decompiler hid it in unwind metadata, and the single-fetch reading of the code makes the bug look like a harmless state-machine quirk. The lesson generalises well beyond dxgkrnl: any kernel path that re-reads user memory it has already acted on is a double-fetch waiting to be weaponised, and the durable fix is always the same — trust the kernel’s own record of what it did, never ask user memory to describe it a second time.
Original text: “July 2026 Patch Tuesday [CVE-2026-58629] Freeing the Wrong Allocation: a double-free in dxgkrnl’s CreateAllocation rollback” by gengstah at gengstah (personal blog).


