dxgkrnl.sys DXGDEVICE CreateStandardAllocation unchecked scratch allocation
A standard allocation is the kernel’s bookkeeping for one of the fixed-type
GPU resources the DDI knows how to create without a driver callback —
cross-adapter surfaces, shared primaries, the well-known resource kinds. When
a D3D device asks for one, DXGDEVICE::CreateStandardAllocation builds the
per-allocation description the rest of the kernel and the driver will consume.
On a multi-adapter / heterogeneous-DXGI machine that description has to be
built per physical adapter the logical device spans, so the natural shape
of the builder is: ask the adapter how many physical adapters there are, size
a scratch buffer of count * 0x60 bytes (one 0x60-byte record per adapter),
then walk that buffer once per adapter writing one fixed-size record each
time. The bug is not in the count and not in the walk. It is that the walk
runs whether or not the buffer was ever allocated.
The vulnerable build reads the count once from
DXGADAPTER::GetNumDifferentPhysicalAdapters, sizes the scratch from it, then
drops straight into the descriptor build and the fill loop with no test that
the sizing actually produced a buffer:
/* DXGDEVICE::CreateStandardAllocation(this, pStd, pAccess) — vulnerable build */
PDXGADAPTER pAdapter = this->pRender->pAdapter; /* this+0x10 -> pRender; pRender+0x10 -> pAdapter */
ULONG n = DXGADAPTER::GetNumDifferentPhysicalAdapters(pAdapter); /* read once */
/* GetNumDifferentPhysicalAdapters:
* if (pAdapter->CapabilityClass s< 0x2000) return 1; // +0xab0
* return pAdapter->NumPhysicalAdapters; // +0x128
*/
/* size one 0x60-byte STANDARD_ALLOCATION_RECORD per physical adapter */
STANDARD_ALLOCATION_RECORD* buf = NULL;
if (n <= 4) {
buf = StackScratch; /* &var_1d0, 0x180 bytes (4 records) */
if (n != 0)
memset(StackScratch, 0, (SIZE_T)n * 0x60);
} else {
if (MAXULONGLONG / n >= 0x60) /* overflow guard */
buf = ExAllocatePool2(POOL_FLAG_NON_PAGED,
(SIZE_T)n * 0x60, 'DxgK');
/* else buf stays NULL — and nothing below tests it */
}
/* ... ADAPTER_RENDER::DdiGetStandardAllocationDriverData builds the descriptor ... */
/* fill loop — bounded by n, no check that buf is valid */
for (ULONG i = 0; i < n; i++) {
buf[i].pPrivateDriverData /* +0x10 */ = pDriverData;
buf[i].PrivateDriverDataSize /* +0x18 */ = DriverDataSize;
if (Type == 1 /* existing-sysmem surface */) {
buf[i].Flags /* +0x20 */ |= 1;
buf[i].SysMemPitch /* +0x1c */ = pExisting->Pitch; /* pStd+0x18 -> +0x14 */
}
/* ... more fixed-offset fields inside the 0x60-byte record ... */
}
The small-count case (n <= 4) lands on a 0x180-byte stack scratch slot
and is always fine; the general case allocates n * 0x60 from nonpaged pool.
The fill loop is bounded by the same n that sized the buffer — the count
itself is read exactly once and the sizing value and the fill bound never
diverge — so when the allocation succeeds the walk is exact. The trouble is
the path where it does not succeed. ExAllocatePool2 is allowed to return
NULL under pool pressure, and on this build that NULL flows untested into
buf[i].pPrivateDriverData: a store to address 0x0 + 0x10, then
0x0 + 0x18, then 0x60 + 0x10, one fixed-stride record per physical
adapter, all of it inside the unmapped null page.
This is a kernel null-pointer dereference, not a buffer overrun. There is no
adjacency, no spray target, no second object to corrupt — the records land on
the null page and the machine bugchecks on the first store. The reachability
conditions narrow it further: the pool path needs n > 4, i.e. a
linked-display-adapter chain of more than four physical GPUs, and then a
nonpaged-pool failure at exactly the allocation between size and fill.
Unlikely does not mean unreachable — the count comes from live adapter state
(DXGADAPTER::NumPhysicalAdapters, fed by the LDA topology), and any local
user with a D3D device or the D3DKMT* API reaches CreateStandardAllocation.
The interest here is the shape, not the severity.
The patch
The fix pins the count into a snapshot before the allocation and, more importantly, refuses to run the fill unless the allocation returned a real buffer:
/* DXGDEVICE::CreateStandardAllocation(this, pStd, pAccess) — patched */
PDXGADAPTER pAdapter = this->pRender->pAdapter; /* this+0x10 -> pRender; pRender+0x10 -> pAdapter */
ULONG n = DXGADAPTER::GetNumDifferentPhysicalAdapters(pAdapter);
ULONG saved_n = n; /* snapshot the sizing value */
STANDARD_ALLOCATION_RECORD* buf = NULL;
if (n <= 4) {
buf = StackScratch; /* &var_1d0 */
if (n != 0)
memset(StackScratch, 0, (SIZE_T)n * 0x60);
} else {
if (MAXULONGLONG / n >= 0x60)
buf = ExAllocatePool2(POOL_FLAG_NON_PAGED,
(SIZE_T)n * 0x60, 'DxgK');
}
n = saved_n; /* bind the fill to the sizing snapshot */
if (buf == NULL) /* guard the fill on a valid buffer */
return STATUS_NO_MEMORY; /* 0xC0000017 */
/* ... DdiGetStandardAllocationDriverData builds the descriptor ... */
for (ULONG i = 0; i < n; i++) {
buf[i].pPrivateDriverData /* +0x10 */ = pDriverData;
buf[i].PrivateDriverDataSize /* +0x18 */ = DriverDataSize;
/* ... */
}
The snapshot is the smaller half of the change — it makes the fill bound
provably the same value that sized buf, so that no future reload of the
count local can drift between size and walk. The load-bearing half is the
buf == NULL guard. Once the fill cannot run against a null pointer the bug
is closed, regardless of how the count local is reused in between; the
snapshot is defense in depth on top of that.
Attack path
sequenceDiagram
participant Dev as CreateStandardAllocation
participant Pool as nonpaged allocator
participant P as scratch buffer pointer
Dev->>Dev: read n, size scratch for n records of 0x60 bytes
Dev->>Pool: ExAllocatePool2 for n records
Pool-->>Dev: NULL when nonpaged pool is exhausted
Note over Dev: vulnerable build never tests the return
Dev->>P: fill loop writes n fixed 0x60 records through the NULL pointer
The shape to remember is the unchecked-allocation fill: a builder that sizes
a buffer, allocates it, and then walks it must treat the allocation result as
untrusted until it has been tested. A count that sizes correctly is not a
proxy for a buffer that exists — ExAllocatePool2 and every allocator that
can fail return NULL, and a fill loop that dereferences the result without a
null check is one pool-stress event away from a kernel null write. The
small-count stack path is worth flagging too: it looks safe because the buffer
is automatic and never null, which is exactly why the general heap path gets
forgotten. The two paths share a fill loop, and the easy path trains the
reader to assume the buffer is always valid. Audit every size-then-fill
builder for the allocation-failure check, not just the overflow arithmetic.