July 2026 Patch Tuesday [CVE-2026-58629] Freeing the Wrong Allocation: a double-free in dxgkrnl’s CreateAllocation rollback

12 minute read

Published:

I went into dxgkrnl looking at the usual suspects: the escape paths, the sync-object handlers, anywhere a count from user mode drives a copy. What I didn’t expect was to find the bug in an error handler, in code that only runs when a D3DKMTCreateAllocation call has already half-succeeded and something downstream trips. The rollback that’s supposed to undo a partial create instead reads an allocation handle back out of user memory and frees whatever it points at, with no check that it’s the allocation the call actually made. Point it at a different object you own and the kernel frees that one instead. Get the timing right on a second read and it frees the same object twice.

Both work from a plain unprivileged process, and both are the kind of primitive you build an LPE on.

Getting to the code

User mode reaches dxgkrnl through the D3DKMT* API in gdi32full.dll, which thunks into the NtGdiDdDDI* syscalls. There’s nothing exotic about the entry: any graphical session already owns an adapter handle, so a sandboxed renderer or a background service can call this surface as easily as a foreground app can. That’s most of why the WDDM kernel is such good hunting.

D3DKMTCreateAllocation makes one or more GPU allocations for a device, its kernel body is DxgkCreateAllocationInternal. The interesting part isn’t the create. It’s what happens when the create works and a later step doesn’t.

The rollback

Take a create with no resource attached (hResource == 0). Inside DxgkCreateAllocationInternal the shape is roughly:

  1. Capture the caller’s D3DKMT_CREATEALLOCATION into a kernel-stack copy.
  2. Call DXGDEVICE::CreateAllocation. On success it allocates the objects and writes their handles back into the user’s pAllocationInfo[] array.
  3. Copy a few output fields (hResource, hGlobalShare, NumAllocations) back to the user struct.
  4. Return.

Step 3 sits inside the function’s SEH scope, and that’s 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 handler takes over:

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)

Now 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. That continuation lives in the unwind metadata, not in the linear flow, which is why the decompiler happily shows you a tidy function with no obvious rollback at all. I stared at the clean pseudocode for a while before going to the .xdata and realizing where control actually goes on the fault.

Two reads of the same pointer

Here’s 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 gets captured into the kernel stack. The array it points to does 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: 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 has to be a live, type-5 allocation handle owned by the calling process. That’s it. Nobody checks that it’s 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 put a handle to some other allocation you own in pAllocationInfo[i].hAllocation, one you created earlier and still have mapped and referenced, and the rollback frees that instead. The allocation the API pretended to create is left untouched, the object you named gets destroyed. If it’s still referenced anywhere else in the kernel, congratulations, you have a use-after-free on an object of your choosing, and you didn’t have to win a race to get it.

Primitive two: walk past the guard for a double-free

That 0x2000 “destroying” bit the second read sets is the anti-duplicate guard. First-read validation rejects any handle whose slot already carries it, so under normal single-fetch semantics an object gets 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 never gets 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 whole teardown on the same DXGALLOCATION object twice over.

A kernel-pool double-free, with you choosing the victim and the repeat count (bounded by NumAllocations, itself capped at 0x682AA).

No race for the hard part

The nice thing about this one is that the trigger is deterministic. The kernel reads the input page during capture and writes to it during the output write-back: same page, two 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’s 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’s a catch, and it’s worth being honest about because 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.

Which is annoying, because the easy way for an unprivileged process to make allocations without a vendor UMD is StandardAllocation, and StandardAllocation always sets CreateResource. It never reaches the bug. To get there you need a standalone allocation (CreateResource == 0), and that needs a valid per-allocation pPrivateDriverData blob, the opaque UMD-to-KMD contract that dxgkrnl doesn’t parse and that’s different on every GPU.

You don’t have to reverse that blob per vendor, though. Just borrow it from the driver that’s already installed: hook D3DKMTCreateAllocation in-process, let the runtime create a throwaway texture, and grab the blob the vendor UMD hands down as it calls through. Then replay it yourself with CreateResource == 0. That makes the whole thing GPU-agnostic. It runs on any box with a real user-mode driver, no vendor-specific data baked into the PoC.

The crash

Standalone allocation, 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

First pass runs down the object’s rundown reference. Second pass reaches DxgkUnreferenceDxgAllocation on the same object, whose rundown reference is already gone, and the atomic decrement dereferences NULL. The one call stack shows the object going through teardown twice on a single D3DKMTCreateAllocation, which is exactly what you’d want to see.

Impact

Local EoP, and MSRC scored it about how you’d expect for a kernel-pool double-free: use-after-free (CWE-416), attack complexity high because you have to win a race, kernel code execution as the ceiling. That all 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’d 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 isn’t a freebie, though, and it’s where the same 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’s 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 isn’t producing it, it’s 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’s 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, which looks like a benign state-machine quirk and nothing more. The extra free comes entirely from the second read being decoupled from the first, and you don’t 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’s all of it. The destroy table gets built from the kernel’s record of what the call made, and every handle it flags is one the kernel wrote and the caller can’t reach. No second fetch to decouple from the first, 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 doesn’t matter in practice, but it’s why the vulnerable function still reads as vulnerable if you diff against the wrong build, which is exactly the hole I fell into before I found the build that actually carried the fix.