win32kfull.sys xxxDIBtoBMP unchecked header length OOB

xxxDIBtoBMP is the reverse-direction sibling of xxxBMPtoDIB: it takes a device-independent bitmap in caller memory and produces the device- dependent form win32k ships to GDI. As with every clipboard render helper, the BITMAPINFOHEADER at the front of the bytes names its own length in biSize0x28 for V1, 0x6c for V4, 0x7c for V5 — but the caller hands over a separate byte count for the whole buffer (arg3), and nothing in the transport forces those two to agree. A forged header can claim any size while the actual buffer is whatever the attacker shipped.

The vulnerable build reads biSize straight off arg1 and immediately hands the header — and that attacker-controlled length — to the header-parsing helpers, never asking whether arg1 actually covers the declared header bytes:

/* xxxDIBtoBMP — vulnerable build */
ULONG biSize     = arg1->biSize;                        /* +0x00, attacker-controlled */
ULONG bitmapSize = GreGetBitmapSizeInternal(arg1, 0, biSize);  /* parses the declared header */
if (bitmapSize == 0)
    return NULL;
ULONG bitsSize   = GreGetBitmapBitsSize(arg1);          /* parses the declared header */
if (bitsSize == 0)
    return NULL;
ULONG total      = bitsSize + bitmapSize;               /* 32-bit add */
if (total < bitsSize || arg3 < total)                   /* overflow OR buffer < computed pixel+colortable extent */
    return NULL;

LONG   biWidth;                                         /* V1 fields read only after the parsers already ran */
LONG   biHeight;
USHORT biBitCount;
if (biSize == 0x28) {                                   /* BITMAPINFOHEADER */
    biWidth    = arg1->biWidth;                         /* +0x04 */
    biHeight   = arg1->biHeight;                        /* +0x08 */
    biBitCount = arg1->biBitCount;                      /* +0x0e */
} else if (biSize == 0xc) {                             /* BITMAPCOREHEADER (OS/2) */
    biWidth    = (LONG)((BITMAPCOREHEADER *)arg1)->bcWidth;    /* +0x04, WORD */
    biHeight   = (LONG)((BITMAPCOREHEADER *)arg1)->bcHeight;   /* +0x06, WORD */
    biBitCount =        ((BITMAPCOREHEADER *)arg1)->bcBitCount; /* +0x0a, WORD */
} else {
    return NULL;                                        /* unsupported kind — but the helpers already ran */
}

Two gates are missing above the first dereference. Nothing checks arg3 >= 4 before arg1->biSize is read, so even the four-byte header-length field can come from past the end of a sub-ULONG buffer. And nothing checks arg3 >= biSize before GreGetBitmapSizeInternal and GreGetBitmapBitsSize walk the header — both helpers take the header pointer and the biSize that governs it and read fields at offsets spanning the declared header, so a buffer shorter than the biSize it advertises sends both parsers off the end. The only length check that exists (arg3 >= bitmapSize + bitsSize) bounds the computed pixel and color-table extents, not the declared header extent, and it runs after the parsers have already consumed the buffer. The kind-discrimination (biSize == 0x28 vs == 0xc) happens even later, so a forged or unsupported biSize still drives a full header parse before it is rejected.

A caller that ships a tiny CF_DIB with a header claiming 0x28 (or larger) therefore feeds session-pool bytes after the buffer into the header parsers; whatever those parsers return — dimensions, bit count, color-table size — is then treated as fact and used to size the GreCreateCompatibleBitmap / GreSetDIBits work that follows. The overrun target and the renderer’s ability to observe it are the same as in the rest of the clipboard render path: an info-leak on the parse, and a downstream write-back driven by attacker-influenced dimensions.

The patch

The fix hoists a three-part gate above every header dereference. The supplied byte count must be large enough to read biSize at all, biSize must be one of the supported kinds, and the buffer must actually be that large before any parsing runs:

/* xxxDIBtoBMP — patched (gate prepended to the same body) */
if (arg3 < 4)
    return NULL;                                        /* need a ULONG to read biSize at +0x00 */
ULONG biSize = arg1->biSize;                            /* +0x00 */
if ((biSize == 0xc || biSize >= 0x28) && arg3 >= biSize)
    goto body;                                          /* supported kind AND buffer covers it */
return NULL;

body:
    biSize     = arg1->biSize;                          /* +0x00, re-read under the gate */
    bitmapSize = GreGetBitmapSizeInternal(arg1, 0, biSize);  /* helpers now run against a buffer that covers biSize */
    /* then the same bitsSize / total / V1-or-CORE field reads / GreSetDIBits body as before */

The supported-size list (0xc for the OS/2 CORE header, anything >= 0x28 for the Win32 family) is the explicit kind-discrimination the helper was missing; the arg3 >= biSize clause is the actual freshness check, “does this header even fit in the buffer I was handed?” — the first question a clipboard or IPC render path has to answer before it starts reading declared fields off the buffer.

Attack path

flowchart TD
    A["attacker ships a short CF_DIB whose biSize declares 0x28 or larger"] --> B["xxxDIBtoBMP reads biSize, calls GreGetBitmapSizeInternal and GreGetBitmapBitsSize without checking arg3 covers biSize"]
    B --> C["both helpers walk the declared header past the end of the supplied bytes"]
    C --> D["session-pool bytes after the buffer parsed as header fields and color table"]
    D --> E["bitmap creation and GreSetDIBits driven by attacker-influenced dimensions"]

Reaching the helper from user mode goes through the clipboard’s companion synthesis, and the routing is worth spelling out because it decides which requests land here. When only CF_DIB is placed on the clipboard, the close-time pass that munges companion formats synthesizes a CF_BITMAP slot carrying the marker hData == 2; a GetClipboardData(CF_BITMAP) then sees that marker and dispatches to the dummy-render family, which resolves the DIB descriptor (HMValidateHandleNoRip on the TYPE_DIB handle), locks it, and calls xxxDIBtoBMP with the descriptor’s inlined header as arg1 and the descriptor’s recorded payload size — the real byte count — as arg3. That last point contains the attack in one direction: the existing arg3 < bitsSize + bitmapSize check compares against the true buffer length, so inflating biSize alone cannot stretch the declared pixel extent past what the payload can hold. The residual hole is exactly the one the patch closes: the parsers read the declared header before anyone asks whether arg3 covers biSize bytes at all, so a short payload with a full-size declaration walks the fixed-offset field reads past the end. A CF_DIB as small as eight bytes declaring biSize 0x28 is enough to reach it, unprivileged, from any session.

The shape is the simplest one in the clipboard render family — the header’s self-declared size is treated as fact without bounding it against the buffer that carries it. biSize is a claim, not a fact, and the same is true of every header field that names a length ahead of variable trailing data; the render helper that reads such a field off the buffer before checking the buffer is long enough to contain it is reading past the end by construction.