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 biSize — 0x28 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.