基于 GDI 对象的 Windows 内核漏洞利⽤
0x00 引⼦
本⽂我们将讨论造成 Windows 内核池(Kernel Pool)破坏的整数溢出问题,并基于 Bitmap 和 Palette 这两个 GDI对象来探究内核漏洞的利⽤过程。当然,⽂中提出的观点仅代表作者如何理解以及解决这些问题。
注:仅针对其中讲解的前置知识点部分做了翻译。
0x01 WinDbg 中与内核池相关的命令
!poolused :此命令可⽤于查看具有特定标识或类型的内核池使⽤情况。
!poolfind :此命令⽤于查找具有特定标识的内核池分配对象。
!pool :此命令⽤于查看特殊地址所处的内核池信息。
0x02 内核池
内核池的类型
内核池可类⽐于⽤户态下的堆内存,不同之处在于它是在内核态中使⽤的。它有许多类型[1],其中最常⽤的⼏种类型如下:
桌⾯堆(Desktop Heap ):主要⽤于窗⼝、类、菜单等桌⾯对象,分配函数为 RtlAllocateHeap() 和DesktopAlloc(),释放函数为RtlFreeHeap()。
⾮分⻚池(Non-Paged Session Pool ):在该类池上分配的对象,其对应的虚拟地址和物理地址是存在映射关系的,其中⼀些为系统对象,如信号量、事件对象等。
分⻚池(Paged Session Pool ):此类型是本⽂主要关注的,对于该类池上分配的对象,其对应的虚拟地址和物理地址并不存在⼀⼀映射关系,只需保证对象在当前执⾏会话中是有效的,⽽在其余的内核操作时并不要求这些对象必须在内存中,如 GDI 对象和⼀些⽤户对象等。
对分⻚池和⾮分⻚池来说,分配函数均为 ExAllocatePoolWithTag(),其中以第⼀个参数作为类型区分,若为 0x21,则分配到分⻚池,若为 0x29,则分配到⾮分⻚池。⼆者的释放函数为 ExFreePoolWithTag() 和 ExFreePool()。
内核池的分配
通过查看 Win32AllocPool() 函数我们可以知道内核是如何分配分⻚池对象的(类型参数为 0x21)。
关于内核池需要了解的另⼀点是它的内存空间以 0x1000 字节⼤⼩划分成⻚,对每个池⻚⾯来说,初次分配的 chunk 块将位于⻚⾯的起始处,⽽接下去的 chunk 块在⼤部分情况下将从⻚底开始分配。
此外,在 64 位系统中,内核 Pool Header 结构的⼤⼩为 0x10 字节,相应的 32 位系统中的⼤⼩则为 0x8 字节。
Pool Feng shui(池喷射)
Pool Feng shui 背后依据的原理是通过适当操作可将池内存置于⼀种可预测的状态。即通过⼀系列的分配和释放操作来构造与漏洞对象⼤⼩相同的内存空洞(holes),以便将存在漏洞的对象分配到我们可控对象的相邻处,从⽽完成利⽤对象的内存布局。
如果该漏洞对象在执⾏过程中没有被释放,那么需要构造的空洞可位于池⻚⾯的任何地⽅,但如果该对象最终被释放掉了,那么就需要确保漏洞对象被置于池⻚⾯的最后,即下⼀ chunk 头将不再有效,这样对象被释放后不会由于 BAD POOL HEADER ⽽触发蓝屏。
强制对象分配到池⻚⾯的尾部
我们假设漏洞对象的⼤⼩为 0x40 字节(包含 Pool Header),则池⻚⾯初始 chunk 块的⼤⼩需要为 0x1000 – 0x40 =0xFC0 字节(包含 Pool Header)。
之后再分配池⻚⾯中余下的 0x40 字节。
如果溢出利⽤中需要借助其它对象,则在漏洞对象的特定偏移处进⾏相应的分配操作。
0x03 池内存的破坏
引起池内存破坏的原因有很多,如释放后重⽤(UAF)、池的线性溢出以及池的越界写(OOBW)等。
⽆符号整型溢出
⽆符号整型溢出是由于在计算过程中没有进⾏相应的检查使得计算结果超出了整型数所能表示的最⼤范围 MAX_UINT(0xFFFFFFFF, 32 位),从⽽导致最终结果远⼩于预期,⽽按其后溢出值的不同使⽤情形⼜会造成不同的错误影响。
为了更好的理解⽆符号整型数的溢出,我们来看个例⼦:假设⽬标系统为 x86 架构,因此 UINT 占 4 个字节(32 位),考虑如下的加法运算:
0xFFFFFF80 + 0x81 = 00000001 ??
对 x86 系统来说上述计算结果为 0x1,然⽽真实的计算结果应该是 0x100000001,但这超出了 x86 系统上 4 字节 UNIT数所能表示的范围,因此截断后得到结果 0x1。
⽽在 64 位系统中,虽然此概念仍然存在,但由于要求的数值过⼤,因此很难找到纯粹的 64 位整型数溢出的情况。不过,许多存在漏洞的函数在实际使⽤前会先将数值保存到 32 位寄存器中,所以⼜出现了前述解释的整数截断情形。
我们考虑如下的程序执⾏过程:
1. ⼊参变量为整型数,针对此整型数会进⾏⼀些运算操作;
2. 运算结果会导致整型数溢出;
3. 之后按此溢出值(较预期结果偏⼩)的⼤⼩进⾏新缓冲区分配操作;
4. 再按最初的⼊参整型数(未经过运算操作)进⾏相关操作:
a. 将原先内容拷⻉到新申请的缓冲区(这会导致线性溢出);
b. 向本应落在新分配缓冲区内的偏移进⾏写⼊操作(这会导致越界写, OOB Write)。
接着就来具体看⼀下。
线性溢出
在对象数据拷⻉过程中,如果没有对边界进⾏检查,那么就有可能发⽣线性溢出。其原因有多种,例如传给内存分配函数的⼤⼩是⼀个溢出值,这会导致新分配的空间偏⼩,⽽拷⻉函数却按原先的⼤⼩将数据拷⻉到新分配的内存空间,⼜或者对象本身是以固定⼤⼩分配的,⽽拷⻉时使⽤的⼤⼩却是未经校验的⽤户输⼊值。
越界写(OOB Write)
对于越界写的情况,⾸先需有⼀对象,其⼤⼩⼤于某⼀特定值。⽽当该⼤⼩变量传给分配函数后发⽣了整型溢出,使结果较期望值偏⼩。随后,程序尝试在新分配对象中按预期索引值进⾏读写操作,但由于分配的⼤⼩值发⽣了溢出,导致该对象⼤⼩⼩于预期,从⽽造成了越界读写。
通常对 Exp 开发⽽⾔,某些经第⼀阶段内存破坏后的对象可被利⽤在获取第⼆阶段内存破坏的 primitives 中。这些对象⼀般拥有实现这些利⽤操作的特定成员,例如某些对象成员可以控制对象或对象中数据块的⼤⼩,因⽽能够实现相对的内存
0x04 利⽤ GDI 对象获取 ring0 层 ARW Primitives
读写操作,在某些情形中这⾜以实现 bug 的利⽤。更进⼀步,如果对象同时还拥有另⼀成员,即指向对象数据块的指针,那么就能将内存破坏的 primitives 转换成内存 ARW primitives,这会让利⽤程序的开发变得更加容易。要实现此利⽤技术通常需要借助两个对象,其中⼀个对象(manager)将⽤于修改第⼆个(通常是相邻)对象(worker)的数据指针,使其获得 ARW primitives(Game Over)。
在 Windows 内核中, GDI 对象恰好能够满⾜这些要求,如 Bitmap 对象利⽤技术,该技术⾸先是由 k33n 团队提出的[3],后续被 Nicolas Economou 和 Diego Juarez 做了详细补充[4]。⽽我则⾜够幸运的发现了另⼀个能被利⽤的 GDI 对象,即Palette 对象, Vulcan 团队同样也提及了此利⽤技术[10]。 Palette 对象利⽤技术和 Bitmap 对象利⽤技术⼀样强⼤,也能够⽤于获取内核的任意内存读写能⼒。
相对内存读写
相对内存 RW primitives 允许我们对特定地址区域进⾏读写操作。通过破坏 GDI 对象的内存可增加其⼤⼩,这通常是触发bug 后⽤于获取任意内存 RW primitives 所需迈出的第⼀步。
任意内存读写
对⽤于实现任意内存读写的对象,我们通常要求其拥有⼀个能够指向对象数据的指针成员。如果该指针被修改了,那么当调⽤相应对象数据读写函数时就会转⽽读写修改后指针所指向的地址,从⽽获取强⼤的任意内存 RW primitives。
为了便于理解,我们来考虑这样的 manager/worker 对象组合。对于 A 对象(manager),我们扩增了其⼤⼩,因⽽能够实现相对的越界读写,即实现对 B 对象(worker)数据指针的读写,⽽后将该指针替换为我们需要进⾏读写的地址,这就使得 B 对象的数据读写操作能够被我们所控制。
0x05 SURFOBJ - Bitmap 对象
Bitmap 对象在内核中对应的 Pool 标识为 Gh?5 或 Gla5 ,其结构体 _SURFOBJ 的定义在 msdn[5]、 ReactOS 项⽬(32 位) [6] 以及 Diego Juarez 的博⽂(64 位) [7] 中有说明。
SURFOBJ 结构体
SURFOBJ 结构中最值得我们关注的成员当属 sizlBitmap,它是⼀个 SIZEL 结构体,系统由该变量来确定 bitmap 位图的⻓宽。⽽ pvScan0 和 pvBits 成员变量均表示指向 bitmap 位图的指针,按 bitmap 类型的不同,系统会选⽤⼆者之⼀。此外,在内存中 bitmap 位图通常位于 SURFOBJ 结构之后。
分配
CreateBitmap 函数⽤于分配 Bitmap 对象,其定义如下:
分配 2000 个 bitmap 对象:
for (int y = 0; y < 2000; y++) {
HBITMAP bmp[y] = CreateBitmap(0x3A3, 1, 1, 32, NULL);
}
释放
DeleteObject 函数则⽤于 Bitmap 对象的释放:
DeleteObject(hBITMAP);
读内存
GetBitmapBits 函数可⽤于读取由 pvScan0 或 pvBits(取决于 bitmap 类型) 指针指向的 cBytes 字节的 bitmap 位图内容,其中 cBytes 的取值需⼩于 sizlBitmap.Width * sizlBitmap.Height * BitsPerPixel 乘积。
写内存
相对的, SetBitmapBits 函数则⽤于向 pvScan0 或 pvBits(取决于 bitmap 类型)指针指向的 bitmap 位图写⼊cBytes 字节的内容,同样 cBytes 的取值也需⼩于 sizlBitmap.Width * sizlBitmap.Height * BitsPerPixel 的乘积。
相对内存读写 - sizlBitmap
sizlBitmap 成员变量为 SIZEL 类型的结构体,其中包含了 bitmap 位图的⻓宽, SIZEL 结构体和 SIZE 结构体是等价的,定义如下:
后续的所有 Bitmap 对象操作,例如 bitmap 位图读写,都依赖此变量来计算 bitmap 位图的⼤⼩以执⾏对应操作,其中Size = Width * Height * BitsPerPixel。通过破坏对象的 sizlBitmap 变量可实现相对内存读写。
任意内存读写 - pvScan0/pvBits
pvScan0 指针⽤于指向 bitmap 位图的第⼀⾏,但如果 bitmap 的格式为 BMF_JPEG 或 BMF_PNG ,那么此成员变量会被置为 NULL,转⽽由 pvBits 指针来指向 bitmap 位图数据。这两个指针在读写 bitmap 位图数据时会⽤到,通过对其进⾏控制可以实现任意内存读写。
利⽤思路
Diego Juarez 和 Nicolas Economou 在之前的演讲中对借助Manager/Worker ⽅式的 Bitmap 对象利⽤技术做了详尽分析,其思路是通过控制 Manager Bitmap 对象的 sizelBitmap 或 pvScan0 成员,从⽽达到控制 Worker Bitmap 对象 pvScan0 成员的⽬的,最终实现内核任意内存读写(ARW primitives)。
我们这⾥给出的思路是通过控制 Manager Bitmap 对象的 sizlBitmap 成员,以扩增 bitmap 的⼤⼩来获取相对内存读写的能⼒,接着再控制相邻 Worker Bitmap 对象的 pvScan0 指针达到任意内存读写。
下⾯我们将介绍基于 Palette 对象的新利⽤技术。此对象在内核中的Pool 标识为 Gh?8 或 Gla8 ,调试中相应的符号名为 _PALETTE 、 XEPALOBJ 或 PALOBJ 。 msdn 上并没有关于该对象的公开内核结构信息,但我们可以在 ReactOS项⽬中[8]找到其 x86 版的定义,⽽在 Deigo Juarez 的 WinDbg 插件项⽬ GDIObjDump 中[9]则可同时找到 x86 版和 x64版的定义。
PALETTE 结构体
对于 XEPALOBJ 结构体,我们⽐较感兴趣的成员变量是 cEntries,它表示 PALETTEENTRY 数组中的元素个数,此外还有 pFirstColor 成员变量,它是指向 PALETTEENTRY 数组 apalColors 的指针,可以看到, apalColors 表示的数组位于此 0x06 XEPALOBJ - Palette 对象结构体的尾部。
分配
CreatePalette 函数⽤于分配 Palette 对象,唯⼀的⼊参 lplgpl 为 LOGPALETTE 结构体指针类型,对 x86 系统其分配⼤⼩需不⼩于 0x98 字节,相应的对 x64 系统其分配⼤⼩需不⼩于 0xD8 字节。
不论 x86 系统还是 x64 系统, PALETTEENTRY 结构都是占 4 个字节:
分配 2000 个 Palette 对象:
LOGPALETTE *lPalette;
lPalette = (LOGPALETTE*)malloc(sizeof(LOGPALETTE) + (0x1E3 - 1) * sizeof(PALETTEENTRY));
lPalette->palNumEntries = 0x1E3;
lPalette->palVersion = 0x0300;
for (int k = 0; k < 2000; k++) {
hps[k] = CreatePalette(lPalette);
}
释放
而 Palette 对象的释放则由 DeleteObject 函数来完成:
DeleteObject(hPALETTE);
读内存
GetPaletteEntries 函数被⽤来读取 Palette 对象中的内容,即对应 hpal 句柄表示的 XEPALOBJ 结构中 pFirstColor指针指向的apalColors 数组⾃偏移 iStartIndex 开始的 nEntries 个元素,并将其保存到缓冲区 lppe 上。函数定义如下:
写内存
相对的, SetPaletteEntries 和 AnimatePalette 这两个函数可⽤来向 Palette 对象写⼊内容,即将缓冲区 lppe上的 nEntries 个元素写⼊ hpal 句柄表示的 XEPALOBJ 结构中 pFirstColor 指针指向的 apalColors 数组⾃偏移 iStart 或iStartIndex 开始的位置。
相对内存读写 - cEntries
XEPALOBJ 结构体的 cEntries 成员⽤于表示 Palette 对象中apalColors 数组元素的个数,若将其覆盖为⼀个⼤的数值,那么借助破坏后的 Palette 对象可以实现内存的越界读写。
任意内存读写 - pFirstColor
pFirstColor 指针指向的是 Palette 对象中 apalColors 数组的起始位置,通过控制该指针,可以实现内核态下内存的任意读写。
利⽤思路
针对 Palette 对象的利⽤思路和之前讨论的 Bitmap 对象利⽤思路是类似的,通过控制 Manager Palette 对象的 cEntries 或 pFirstColor 成员来达到控制相邻 Worker Palette 对象 pFirstColor 成员的⽬的,从⽽获取内核下的 ARW primitives。
我们这⾥讨论 Manager Palette 对象中 cEntries 成员可控的情况,通过对 cEntries 进⾏溢出使得 Manager Palette 对象获取相对内存读写的能⼒,再借此修改相邻 Worker Palette 对象的 pFirstColor 指针可实现任意内存读写。
0x07 基于 Palette 对象利⽤技术的⼏点限制
⾸先,在 x86 系统中要求 cEntires 成员溢出后得到的结果必须⼤于 0x26,相应的在 x64 系统中必须⼤于 0x36,这是因为在 XEPALOBJ 对象分配时, x86 系统要求其⼤⼩不⼩于 0x98 字节,⽽ x64 系统要求其⼤⼩不能⼩于 0xd8 字节。例如cEntires 成员经溢出后由 0x1 变为 0x6,但这显然不满⾜条件,该⼤⼩ 0x6 * 0x4 = 0x18 字节⼩于所要求分配 Palette 对象时的最⼩值。
其次,如果利⽤程序通过 SetPaletteEntries 函数向内存写⼊数据,那么需保证 XEPALOBJ 结构中的 hdcHead、ptransOld 以及ptransCurrent 成员不会被覆盖掉。
ring3 层的 SetPaletteEntries 调⽤会经由 NTSetPaletteEntries ⾄ GreSetPaletteEntries 函数,此时会对 hdcHead 成员进⾏检查,如果该值不为零,则程序执⾏流程会报错或直接蓝屏死机,即下图⻩⾊区域所示。
不过在此之前 GreSetPaletteEntries 还会先调⽤ XEPALOBJ::ulSetEntries 函数对 pTransCurrent 和 pTransOld 成员进⾏检查,如果它们⾮零,程序会进⼊下图所示的橘⾊区域,这有可能会导致蓝屏死机。
最后我们看下使⽤ AnimatePalettes 函数向 Palette 对象进⾏写操作的情况,唯⼀限制是要求 pFirstColor 指针所指内容的最⾼字节为奇数,对应的 XEPALOBJ::ulAnimatePalette 函数代码段如下。虽然不会导致蓝屏死机,但这使得我们⽆法完成写⼊操作。
0x08 Token 的替换
内核借助 _EPROCESS 结构来表示系统上运⾏的每⼀个进程,该结构包含很多重要成员,例如 ImageName、SecurityToken、ActiveProcessLinks 以及 UniqueProcessId,这些成员的偏移值因系统版本⽽异。
Windows 8.1 x64:
Windows 7 SP1 x86:
另外,内核中 SYSTEM 进程所对应的 EPROCESS 结构地址可通过如下⽅式计算得到:
KernelEPROCESSAddress = kernelNTBase + (PSInitialSystemProcess() - UserNTImageBase)
SecurityToken
SecurityToken 表示当前进程所持有的安全级别标识,当进程请求获取特定权限时,系统会借此判断其是否拥有所请求资源的权限。
ActiveProcessLinks
ActiveProcessLinks 是⼀个 LIST_ENTRY 对象,可借此遍历各进程对应的 EPROCESS 结构。
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
UniqueProcessId
UniqueProcessId 表示进程 PID。
步骤
1. 获取内核中 SYSTEM 进程对应的 EPROCESS 结构地址;
2. 借助 Read primitive 得到相应的 SecurityToken 和 ActiveProcessLinks;
3. 遍历 ActiveProcessLinks 得到当前进程的 EPROCESS 结构地址,即 ActiveProcessLinks->Flink.UniqueProcessId 和 GetCurrentProcessId() 的值相同;
4. 借助 Write primitive 将当前进程的 SecurityToken 替换为 SYSTEM 进程的 SecurityToken。
*参考部分详⻅原⽂
本文由看雪论坛 BDomne 编译,来源media@Saif El-Sherei
转载请注明来自看雪论坛
往期热门阅读:
扫描二维码关注我们,更多干货等你来拿!