dxgkrnl.sys DxgkOpenSyncObjectFromNtHandle sync object handle UAF
DXG sync objects are the GPU-side fences and semaphores that D3D uses to
coordinate rendering across processes, between CPU and GPU, and across shared
swap chains. A sync object created in one process can be shared to another by
serialising it into an NT handle and passing that handle across; the receiving
process then calls DxgkOpenSyncObjectFromNtHandle to convert the shared NT
handle into a fresh per-process DXG sync-object handle its own D3D calls can
use. The interesting part is what “per-process handle” actually means here.
DXG handles do not live in the regular NT object table. Each DXGPROCESS
keeps its own slot array, indexed by the handle value, with audit bits baked
into the high bits of the handle and metadata per slot. The handle layout
reconstructed from the patched comparison is:
NewHandle bits
[0..5] unused
[6..29] slot index into pEntryTable (NewHandle >> 6) & 0xffffff
[30..31] generation / audit (NewHandle >> 0x1e)
Each slot is a 0x10-byte DXGHANDLE_TABLE_ENTRY whose Meta field at
+0x8 carries the audit/state word: the low five bits are slot state (0 is
a dead slot), the next two bits are the generation the handle’s audit bits are
compared against, and bit 0x2000 is the slot’s live marker — the destroy
path clears it under HandleTableLock before the slot is recycled, so a
resolver that re-reads Meta under that lock and finds the bit clear knows
the slot is gone. The audit comparison (NewHandle >> 0x1e) == ((meta >> 5) & 3)
is exactly how a resolver tells a stale handle from a live one — the same
shape as the NT object table, just on a private structure. The contract is the
same too: a handle is a live reference only between publish and destroy, and
any code that resolves it must verify liveness under the same lock the destroy
path takes.
DxgkOpenSyncObjectFromNtHandle gets that ordering backwards on the
vulnerable build. It mints the per-process handle, hands it to user mode, and
only then re-reads the slot under HandleTableLock. The vulnerable path
(typed decompilation; the public PDB is stripped, so the field names are
reverse-engineered from use):
// DxgkOpenSyncObjectFromNtHandle — vulnerable build
ObReferenceObjectByHandle(Packet->hSharedSync, ..., &Object, ...);
DXGSYNCOBJECTLOCK::AcquireShared(&GlobalSyncLock); // serialises Open, not destroy
DXGSYNCOBJECT::Open(*Object, ..., &NewHandle, ...); // mint the per-process handle
RtlCopyVolatileMemory(&Packet->OutHandle, &NewHandle, 4); // PUBLISH to user mode
// re-validate only now, under the lock destroy actually takes
DXGPUSHLOCK::AcquireExclusive(&process->HandleTableLock); /* +0xf8 */
ULONG idx = (NewHandle >> 6) & 0xffffff;
if (idx < process->EntryTableCount) { /* +0x128 */
ULONG meta = process->pEntryTable[idx].Meta; /* +0x8 in 0x10-byte entry */
if ((NewHandle >> 0x1e) == ((meta >> 5) & 3) && (meta & 0x1f) != 0)
if ((process->pEntryTable[idx].Meta & 0x2000) == 0) { /* live bit clear = retired */
process->pEntryTable[idx].Meta &= ~0x2000;
process->SyncObjectHandle = NULL; /* +0x100 */
}
}
ExReleasePushLockExclusiveEx(&process->HandleTableLock, 0);
Open runs under the global sync-object lock. That serialises mint-vs-mint on
the shared sync object, but it is not the lock the destroy path takes to
retire a slot — that lock is the per-process HandleTableLock. The vulnerable
build writes NewHandle into the output packet before it acquires
HandleTableLock, so a destroy on another thread can land between the publish
and the re-validate: take HandleTableLock, clear the slot’s live bit, free
the underlying sync object, and release. By the time the open path acquires
HandleTableLock and re-reads Meta, the slot is retired and the open path
dutifully tears it down — but the handle value is already in user mode, and
any thread in the process that resolves it between the publish and the
retirement walks through freed pool. The late re-validate limits how long the
slot stays dangling; it does not close the window.
This is not a timing path you have to win against the kernel. Both sides are
the attacker’s: the open is a normal call on the attacker’s own shared handle,
and the destroy is a normal close of a sibling object the attacker also owns.
Driving the race is a matter of spinning a second object-close on a sibling
thread so that the destroy lands between the RtlCopyVolatileMemory and the
DXGPUSHLOCK::AcquireExclusive. The shape is the same family that produced a
long string of DirectX elevation bugs on this exact handle table, and it reads
elevation-grade: the same process owns the bulk of the allocations in this
pool and size class, so an attacker who shapes the replacement allocation
gets a type-confusion / UAF primitive with controlled content at controlled
timing. Reachability is from any user with a D3D device or the D3DKMT* API.
The patch
The fix is a single reordering: take HandleTableLock and re-validate the
slot before the publish, not after. If the slot has already been retired, the
open refuses to publish and returns an error; otherwise it publishes knowing
the destroy path cannot slip in underneath.
// DxgkOpenSyncObjectFromNtHandle — patched
ObReferenceObjectByHandle(Packet->hSharedSync, ..., &Object, ...);
DXGSYNCOBJECTLOCK::AcquireShared(&GlobalSyncLock);
DXGSYNCOBJECT::Open(*Object, ..., &NewHandle, ...);
// re-validate BEFORE publishing, under the same lock destroy takes
DXGPUSHLOCK::AcquireExclusive(&process->HandleTableLock);
ULONG idx = (NewHandle >> 6) & 0xffffff;
if (idx < process->EntryTableCount) {
ULONG meta = process->pEntryTable[idx].Meta;
if ((NewHandle >> 0x1e) == ((meta >> 5) & 3) && (meta & 0x1f) != 0)
if ((process->pEntryTable[idx].Meta & 0x2000) == 0) { /* retired under lock */
process->pEntryTable[idx].Meta &= ~0x2000;
process->SyncObjectHandle = NULL;
ExReleasePushLockExclusiveEx(&process->HandleTableLock, 0);
return STATUS_INVALID_HANDLE; // refuse to publish
}
}
ExReleasePushLockExclusiveEx(&process->HandleTableLock, 0);
RtlCopyVolatileMemory(&Packet->OutHandle, &NewHandle, 4); // PUBLISH after re-validate
The destroy path can no longer land between mint and publish: either it ran
before the re-validate, in which case the re-validate observes the cleared
live bit and tears the entry down itself, refusing to hand the handle out; or
it runs after the publish, in which case the open path’s slot observation
already won the race. Either way the handle that reaches user mode names a
slot that was live at the moment of publication. (The patch also adds a type
guard on the shared sync object — only some DXGSYNCOBJECT.Type values at
+0x194 reach Open — but the UAF fix is the ordering swap, not the guard.)
Attack path
sequenceDiagram
participant T1 as Thread A open
participant T2 as Thread B destroy
participant HT as DXGPROCESS handle table
participant U as user mode
T1->>HT: Open mints NewHandle at slot idx under global sync lock
T1->>U: publish NewHandle into OutHandle, no HandleTableLock held
T2->>HT: take HandleTableLock, clear slot idx live bit, free sync object
U->>HT: resolve NewHandle into the retired slot, UAF
T1->>HT: take HandleTableLock, re-check slot idx, too late
The shared-sync-object surface is reachable from any local user with a D3D
device or the D3DKMT* API. The open and destroy are both ordinary user-mode
operations on the attacker’s own objects, so the race is drivable from any
thread in the process; the historical handle-recycling DirectX elevation bugs
on this table used the same setup.
This is the standard handle-table contract — resolution, validation, and destruction must share a lock — applied to the one path that had the consumer on the wrong side of it. The audit bits and the live marker only help you if you read them under the lock before you hand the handle out; the open path is as much a consumer of the slot as any later dereference. Minting a handle and publishing it are two different trust decisions, and the only way to collapse them into one is to hold the destroy-path lock across both.