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.