Out-of-bounds read information disclosure vulnerability in Microsoft Windows GDI+ EMR_STRETCHDIBITS record

CVE-2022-21915

This article, the first in a seven-part series on vulnerabilities found via fuzzing the Graphics Device Interface (GDI) of Microsoft Windows, is about an information disclosure vulnerability in the EMR_STRETCHDIBITS enhanced metafile record. For other articles in the series click here.

TL;DR

An information disclosure vulnerability (CVE-2022-21915) exists when the Windows GDI+ component improperly discloses the contents of its memory.

This vulnerability allows remote attackers to disclose sensitive information on affected installations of Microsoft Windows. User interaction is required to exploit this vulnerability in that the target must visit a malicious page or open a malicious file.

The specific flaw exists within the processing of EMF metafiles in gdiplus.dll. A specially crafted EMR_STRETCHDIBITS record can result in a read past the end of an allocated buffer and disclose uninitialized heap memory. An attacker can leverage this in conjunction with other vulnerabilities to execute code in the context of the current process.

Description

The following analysis is based on Microsoft Windows 10 Professional (x86) using version 10.0.19041.1110 of gdi32full.dll and version 10.0.19041.1151 of gdiplus.dll.

The below is the exception output and the relevant excerpt of the call stack from WinDbg after a crash detected in the MRBDIB::vInit() function.

 10:000> g
 2(1fd0.848): Access violation - code c0000005 (first chance)
 3First chance exceptions are reported before any exception handling.
 4This exception may be expected and handled.
 5eax=087254c4 ebx=08746df0 ecx=001ae4c4 edx=001c0098 esi=08577000 edi=087589c4
 6eip=75a3fc8e esp=00daed20 ebp=00daed44 iopl=0         nv up ei pl nz na pe cy
 7cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010207
 8ucrtbase!memcpy+0x4e:
 975a3fc8e f3a4            rep movs byte ptr es:[edi],byte ptr [esi]
100:000> kn
11 # ChildEBP RetAddr  
1200 00daed24 76861859 ucrtbase!memcpy+0x4e
1301 00daed44 76861a0b gdi32full!MRBDIB::vInit+0x79
1402 00daedf8 76874d24 gdi32full!MF_AnyDIBits+0x16d
1503 00daeee4 76a871a3 gdi32full!StretchDIBitsImpl+0x134
1604 00daef24 70505ed5 GDI32!StretchDIBits+0x43
1705 00daf088 70505646 gdiplus!MfEnumState::OutputDIB+0x886
1806 00daf0e8 705051e9 gdiplus!EmfEnumState::StretchDIBits+0x10b
1907 00daf0fc 70528a0d gdiplus!EmfEnumState::ProcessRecord+0x79
2008 00daf120 70548eec gdiplus!GdipPlayMetafileRecordCallback+0xdd
2109 00daf14c 76863945 gdiplus!EnumEmfDownLevel+0x6c
220a 00daf220 7685cb4c gdi32full!bInternalPlayEMF+0x855
230b 00daf234 76a8462b gdi32full!EnumEnhMetaFile+0x2c
240c 00daf254 70509332 GDI32!EnumEnhMetaFileStub+0x2b
250d 00daf2a8 7050683c gdiplus!MetafilePlayer::EnumerateEmfRecords+0xc8
260e 00daf360 7050ae0c gdiplus!GpGraphics::EnumEmf+0x464
270f 00daf4d0 70518211 gdiplus!GpMetafile::EnumerateForPlayback+0x651
2810 00daf628 7052942f gdiplus!GpGraphics::DrawImage+0x541
2911 00daf694 70548d36 gdiplus!GpGraphics::DrawImage+0x61
3012 00daf6f8 70548c07 gdiplus!GdipDrawImage+0x116
3113 00daf718 008b12e6 gdiplus!GdipDrawImageI+0x37
3214 (Inline) -------- Harness!Gdiplus::Graphics::DrawImage+0x18
33...
340:000> !msec.exploitable
35Exploitability Classification: PROBABLY_EXPLOITABLE
36Recommended Bug Title: Probably Exploitable - Read Access Violation on Block Data Move starting at ucrtbase!memcpy+0x000000000000004e (Hash=0x4e667e2b.0x3c6f1875)
37
38This is a read access violation in a block data move, and is therefore classified as probably exploitable.

Crash analysis

At first glance, it seems a large value 0x1c0098 is passed to memcpy() as the Size parameter by the MRBDIB::vInit() function, as shown by the following pseudocode:

 1void MRBDIB::vInit(...)
 2{
 3  if ( Size ) // 0x1c0098
 4  {
 5    if ( srcBmci->bmciHeader.bcSize == 0xc )
 6    {
 7      ...
 8    }
 9    else
10    {
11      memcpy(dstBmci, srcBmci, Size); // Crash here!
12      ...
13    }
14    ...
15  }
16  ...
17}

Note that the MRBDIB::vInit() function checks only that the Size parameter has been provided. When the bcSize member of the BITMAPCOREHEADER1 structure is not set to the minimum 0xC size of a header, it will call memcpy() with the provided Size parameter.

Replaying the execution flow using the Time Travel Debugging capabilities of WinDbg Preview reveals how the value of the Size parameter is calculated in the bMetaGetDIBInfo() function which is called by MF_AnyDIBits() before MRBDIB::vInit().

The above hypothesis of the Size variable containing the incorrect value can be confirmed – after capturing a time travel trace of the test harness and the crashing sample file – by setting a memory access breakpoint on the memory address of the Size variable using the ba w4 0x59ee50 command and running back to the last point of memory access of this variable using the g- command. We can also add the variables to the Watch window like (int*)(0x59ee50) to observe how they change during the execution.

Time Travel Debugging in WinDbg Preview

Time Travel Debugging in WinDbg Preview

Further analysis showed that the value 0x1c0098 comes from the &size parameter which is set by the bMetaGetDIBInfo() function based on the value of the biClrUsed member of the BITMAPINFOHEADER2 structure, as shown by the following pseudocode:

 1int bMetaGetDIBInfo(HDC hdc, 0, LPBITMAPINFO lpbmi, int &size, int &sizeImage, UINT ColorUse, int cLines, 0)
 2{
 3  ...
 4  biBitCount = lpbmi->bmiHeader.biBitCount;
 5  if (biBitCount == 16 || biBitCount == 32)
 6  {
 7    ...
 8  }
 9  else if (biBitCount != 24 &&
10           lpbmi->bmiHeader.biCompression != BI_JPEG &&
11           lpbmi->bmiHeader.biCompression != BI_PNG)
12  {
13    biClrUsed = lpbmi->bmiHeader.biClrUsed; // 0x7001c
14    if (biClrUsed)
15    {
16      // 0x1c0070    =        4        *  0x7001c
17      colorTableSize = sizeof(RGBQUAD) * biClrUsed; 
18      goto LABEL_28;
19    }
20    if (biBitCount < 16)
21    {
22      colorTableSize = cbBits * (1 << biBitCount);
23LABEL_28:
24      // 0x28 +     0x1c0070     =  0x1c0098
25      biSize += colorTableSize; 
26      goto LABEL_12;
27    }
28  }
29LABEL_12:
30  ...
31  biWidth = lpbmi->bmiHeader.biWidth;
32  if ( biWidth >= 0 )
33    res = CJSCAN(biWidth,
34                 lpbmi->bmiHeader.biPlanes,
35                 lpbmi->bmiHeader.biBitCount,
36                 &cbBits);
37  if ( res )
38  {
39    if ( !cLines )
40    {
41      cLines = lpbmi->bmiHeader.biHeight;
42      if ( cLines < 0 )
43        cLines = -cLines;
44    }
45    if ( ULongLongToULong(cbBits * cLines, cbBits * cLines >> 32) >= 0 )
46    {
47      biSizeImage = cbBits;
48LABEL_21:
49      *size       = biSize;      // 0x1c0098
50      *sizeImage  = biSizeImage; // 0x8b80
51      return 1;
52    }
53  }
54  return 0;
55}

Root cause analysis

Based on the Windows GDI documentation, the bmiColors member of the BITMAPINFO3 structure is used for bitmaps that do not use the full color range, e.g. pixels in an 8-bit bitmap can only have 256 possible color values. The color table stored in the bmiColors member consists of an array of RGBQUAD4 values. Full 24-bit bitmaps do not have a color table, hence they do not have a bmiColors member. The biClrUsed member of the BITMAPINFOHEADER structure specifies the number of color indices in the color table that are actually used by the bitmap. The size of the color table is calculated as sizeof(RGBQUAD) * biClrUsed.

EMR records in 010 Editor

EMR records in 010 Editor

A quick search for the little-endian byte sequence 1C 00 07 in the sample file that triggered the crash shows that the value of the biClrUsed member is directly controllable by the value at offset 2664h in the sample file:

12644h: 28 00 00 00 F8 00 00 00 90 00 00 00 01 00 08 00  (...ΓΈ...........
22654h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
32664h: 1C 00 07 00 00 00 00 00 00 00 00 00              ............

The following is the human readable representation of the specially crafted BITMAPINFOHEADER structure, as also shown by the 010 Editor after applying the EMF binary template.

 1typedef struct tagBITMAPINFOHEADER {
 2  DWORD  biSize          = 0x28;
 3  LONG   biWidth         = 0xf8;
 4  LONG   biHeight        = 0x90;
 5  WORD   biPlanes        = 0x1;
 6  WORD   biBitCount      = 0x8;
 7  DWORD  biCompression   = 0x0;
 8  DWORD  biSizeImage     = 0x0;
 9  LONG   biXPelsPerMeter = 0x0;
10  LONG   biYPelsPerMeter = 0x0;
11  DWORD  biClrUsed       = 0x7001c;
12  DWORD  biClrImportant  = 0x0;
13} BITMAPINFOHEADER;

There are two members of the BITMAPINFOHEADER structure which are particularly interesting for us:

  • The biBitCount member determines the number of bits that define each pixel and the maximum number of colors in the bitmap.
  • The biClrUsed member determines the number of color indexes in the color table that are actually used by the bitmap.

According to the documentation, the system has the following assumptions:

  • If biCompression equals BI_RGB and the bitmap uses 8 bpp or less, the bitmap has a color table immediately following the BITMAPINFOHEADER structure.
  • If biBitCount is 8 the bitmap has a maximum of 256 colors, and the bmiColors member of BITMAPINFO contains up to 256 entries.
  • If biClrUsed is nonzero and the biBitCount member is less than 16, the biClrUsed member specifies the actual number of colors the graphics engine or device driver accesses.

Based on the above, it seems that the root cause of the vulnerability is that the value of the biClrUsed member should not be greater than 256 as this maximum possible value is already determined by the biBitCount member.

The call stack also tells us that the vulnerability can be triggered by an EMR_STRETCHDIBITS5 record containing a specially crafted BITMAPINFOHEADER structure with a large biClrUsed value that may lead to memory corruption in the MRBDIB::vInit() function and could cause memcpy() to read memory out-of-bounds.

Patch analysis

We already know the root cause, however, we do not know how this issue was fixed in version 10.0.19041.1706 of gdiplus.dll. Diffing the patched and the vulnerable DLLs using the BinDiff plugin available for IDA Pro, Binary Ninja or Ghidra, we can identify what changes have been made to the patched file.

The changed functions in the patched binary

The changed functions in the patched binary

The Matched Functions subview displays the pairs of functions that are associated with each other. We can filter the results with the stretch keyword for functions related to the EMR_STRETCHDIBITS record. The EmfEnumState::StretchDIBits() function shows only 41% similarity with 97% confidence. Let’s examine the changes in this function in the BinDiff graph GUI.

Zoomed-in on the changed parts of the patched function

Zoomed-in on the changed parts of the patched function

The red nodes indicate basic blocks to which the comparison algorithms were unable to find equivalents. Let’s zoom in on the first red block on the left hand side that have been inserted between the two other green blocks. The new block added a CALL instruction to a new function at 0x10080d08 that can be found among the unmatched functions:

Unmatched (new) function in the patched binary

Unmatched (new) function in the patched binary

It seems that the exploitation of this vulnerability is facilitated by a flaw in the original GetBitmapFromRecord() function, which is supposed to check that an EMR record is sufficiently large to fully contain the bitmap data, and is called at the beginning of the EMR_STRETCHDIBITS record handler function EmfEnumState::StretchDIBits() before any EMF parsing actually takes place.

The vulnerability was fixed by the new GetBitmapFromRecordEx() function that contains additional validation logic to check the value of the biClrUsed member of the BITMAPINFOHEADER structure, as shown by the below pseudocode:

 1if ( GetDibNumPalEntries(
 2        0,
 3        biSize,
 4        srcBmi->bmiHeader.biBitCount,
 5        srcBmi->bmiHeader.biCompression,
 6        srcBmi->bmiHeader.biClrUsed,
 7        &numPalEntries) )
 8{
 9  if ( ULongLongToULong(4 * numPalEntries, v18) >= 0 )
10  {
11    if (...)
12    {
13      // Number of colors used is larger than size of color table.
14      if ( srcBmi->bmiHeader.biClrUsed <= numPalEntries )
15      {
16        dstBmi = srcBmi;
17        iUsageSrc = DIB_RGB_COLORS;
18        goto LABEL_15;
19      }
20      // Allocate new memory for BITMAPINFO.
21      dstBmi = HeapAlloc(GpRuntime::GpMemHeap, 0, dwBytes);
22      if ( dstBmi )
23      {
24        // Copy BITMAPINFO to the new memory area.
25        memcpy(dstBmi, srcBmi, dwBytes);
26        // Reset biClrUsed member to maximum allowed value.
27        dstBmi->bmiHeader.biClrUsed = numPalEntries;
28        // The color table consists of an array of 16-bit indexes.
29        iUsageSrc = DIB_PAL_COLORS;
30LABEL_15:
31        BitmapInfoPointer::Attach(pBmi, dstBmi, iUsageSrc);
32        return 1;
33      }
34    }
35  }
36}

If the GetBitmapFromRecordEx() function encounters a large biClrUsed value, it will copy the affected BITMAPINFO structure to a new memory area and reset the biClrUsed member of the BITMAPINFOHEADER structure to the maximum allowed value based on the number of entries in the color palette determined by the GetDibNumPalEntries() function.

Timeline

⬅️ 2021-09-29: Reported issue to MSRC.
➡️ 2021-09-29: MSRC opened case 67754.
➡️ 2021-09-30: MSRC confirmed the vulnerability.
⬅️ 2021-12-03: Requested status update.
➡️ 2021-12-03: MSRC provided January 2022 PT as target date.
➡️ 2021-12-22: MSRC assigned CVE-2022-21915 and confirmed target date.
➡️ 2022-01-11: Coordinated public release of advisory.

Bibliography