cdd.dll bCopyColorPointer out of bounds read

cdd.dll is the Canonical Display Driver, the legacy session-space display driver that turns GDI drawing requests into the bitmaps a software cursor and a remote framebuffer (RDP, ICA, base composition) actually consume. The cursor shape conversion is one of its hot paths — every pointer move can re-blit the sprite. A cursor (the API calls it a “pointer”) can be supplied by an application either as a colour bitmap directly or as a 1-bpp alpha mask plus a colour bitmap. When the system needs a colour pointer sprite and only has the mask form, bCopyColorPointer is the helper that expands the mask into a full- colour pointer bitmap — for each destination pixel, read one bit from the mask, write the corresponding colour (or transparent) value into the destination.

The two bitmaps at the call site carry their own geometry: a colour bitmap at arg3 whose width at arg3->Width /* +0x20 */ is what the destination loop counts to, and a 1-bpp mask at arg2 carrying its own width at arg2->Width /* +0x20 */ and its bits at arg2->pBits /* +0x30 */. The two widths do not have to agree — the colour bitmap may describe a fixed-size sprite that is wider than a particular mask — and the helper is responsible for not reading more mask bytes than the mask actually owns. The vulnerable build never consulted arg2->Width at all: the inner loop ran for arg3->Width pixels and read one mask bit per pixel.

// bCopyColorPointer — vulnerable build
dst_width = arg3->Width /* +0x20 */;               // colour-bitmap width drives the loop
inner     = dst_width;                              // no clamp: full destination width
for (row = 0; row < arg3->Height /* +0x24 */; row++) {
    for (col = 0; col < inner; col++) {
        // read one mask bit at col — arg2->Width is never consulted
        bit = arg2->pBits /* +0x30 */[col >> 3] & (0x80 >> (col & 7));
        if (bit) {
            // ... write colour pixel into arg1->pBits /* +0x28 */ ...
        } else {
            // ... write transparent into destination ...
        }
    }
    arg2->pBits /* +0x30 */ += arg2->Stride /* +0x40 */;
    dst                       += arg1->Stride /* +0x1c */;
}

The destination width is the only bound on the read. When arg3->Width exceeds arg2->Width, the inner loop reads mask bytes past the mask allocation — col >> 3 is past the mask once col passes arg2->Width * 8 — and whatever the session-pool allocator placed after the mask becomes the alpha decisions, and downstream the rendered pixels, in the destination pointer sprite. The leaked bytes are deterministic (they are whatever allocation sits after the mask) and the sprite is observable by the renderer, so this is a session-space pool info-leak: bytes the attacker was never meant to read (other GDI objects, kernel pointers in session pool) returned into a bitmap the attacker can sample. The leak is small per call but repeatable, and a patient attacker can position the mask adjacent to interesting session-pool objects through documented GDI allocation patterns. Info- leak primitives are typically the pairing half of an LPE chain.

The patch

The fix takes the inner-loop bound from the mask’s own width and fills the destination tail with a fixed transparent constant instead of leaked bytes:

// bCopyColorPointer — patched
dst_width = arg3->Width /* +0x20 */;
src_width = arg2->Width /* +0x20 */;                // consult the mask's own width
clamped   = 1;
if (dst_width >= src_width)
    inner = src_width;                              // clamp inner loop to mask width
else
    inner = dst_width;
for (row = 0; row < arg3->Height /* +0x24 */; row++) {
    for (col = 0; col < inner; col++) {
        bit = arg2->pBits /* +0x30 */[col >> 3] & (0x80 >> (col & 7));
        // ... write colour or transparent ...
    }
    // pad the destination tail with transparent black for every pixel the
    // mask could not supply
    if (clamped && inner < dst_width)
        __memfill_u32(&dst[inner], 0xff000000, dst_width - inner);
    arg2->pBits /* +0x30 */ += arg2->Stride /* +0x40 */;
    dst                       += arg1->Stride /* +0x1c */;
}

No mask byte past arg2->Width is read; the destination tail is filled with 0xff000000 (transparent black) instead of leaked heap. The read bound is the mask size, the write bound is the destination size, and the gap between them is padding from a constant — never from past-the-end mask memory.

Attack path

flowchart TD
    A["narrow cursor mask, wider colour-pointer sprite"] --> B["bCopyColorPointer, inner loop counts to the colour-bitmap width"]
    B --> C["the mask width (arg2 +0x20) never consulted, loop reads past the mask allocation"]
    C --> D["session-pool bytes after the mask are read as alpha bits"]
    D --> E["leaked bytes drive colour vs transparent for the destination tail, session-space info-leak"]

Cursor / pointer shape conversion runs in session space and is reachable from any user session that can install a cursor sprite. The shape to remember is narrower than “cdd needs bounds checks”: it is that any mask-to-bitmap expansion — cursor sprites, icons, alpha masks — that loops for destination pixels and reads one source bit per pixel silently reads off the source when the destination is wider, and the read bound has to come from the source’s own width while the destination tail is padded from a constant. Session-space display drivers are the quiet info-leak source of choice precisely because the overread reaches session pool, and the constant-pad fix is the whole difference between a defined tail and a leak.