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.