本文翻译自: 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
函数的过程(图片来源)
有许多有用的信息,但是仍然没有描述对漏洞工作方式和触发的细节。
补丁对比
漏洞模块是win32k.sys,我下载了该模块的修复和未修复版本。
对于win 7 x64而言,补丁编号是:
- 修复编号: KB4530692
- 未修复编号: KB4525233
可以从微软官方补丁下载网站去下载它们
下面是用 bindiff 比较两个版本的结果
在排除一些和功能性有关的DebugHook
函数之后,我们需要关注的就是这个稍微改变了一些的 InitFunctionTables()
函数表
这里肯定不是最关键的补丁所在。这不会帮助我们立即识别漏洞的关键点,但值得注意的是新添加的*(gpsi+0x14E), *(gpsi+0x154), *(gpsi+0x180)
,这里可能存在和未初始化变量相关的漏洞。
一步一步构造POC
在本节中我会逐步构造触发此漏洞的POC,同时我也会分析清楚这个漏洞的根本原因。
从何处开始
补丁对比一开始没有给出很多有用的信息,所以在开发的第一阶段,我主要是依据卡巴斯基的分析文章。为了有一个良好的测试环境,我提前准备了 Win7 SP1 x64的虚拟机并拥有最新版本的win32k补丁。需要注意的是我将Windbg附加到该虚拟机上进行内核调试,同时我还配置好了它的符号路径。
我决定通过博客文章中提到的win32k!DrawSwitchWndHilite
函数开始我的分析。有两个地方交叉引用到了它:xxxMoveSwitchWndHilite
函数和xxxPaintSwitchWindow
函数,后者立刻引起了我的注意,因为其中在GetKeyState/GetAsyncKeyState
周围调用到了博客中提到的关键函数并且它检查了ALT键是否被按下。
注:从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 = 0x14
和dwType = 0xE0
,让我们看看它是如何做到的
1 | HINSTANCE hInstance = GetModuleHandle(NULL); |
在这里我简单的注册了一个窗口类并创建了窗口,然后我调用了NtUserMessageCall
函数并赋予它和exploit中相同的参数观察结果。为了了解实际情况,我设置了断点 kd> ba e 1 win32k!NtUserMessageCall
并运行代码。
其中被断下来很多次,我们必须获取正确的调用链,这并不困难,因为它的调用栈很短。
注:NtUserMessageCall
单步调试代码可以发现它从 gapfnMessageCall
数组指针中通过索引来调用的函数,这里索引是0,索引是根据msg
值计算的,因此他会调用NtUserfnDWORD
函数
注:NtUserfnDWORD
下一个调用会比较dwType
的值,并且现在gpsi
的偏移等于0x40,导致调用到xxxWrapSwitchWndProc
函数(这个函数在刚才DrawSwitchWndHilite
函数的调用链中出现)。
xxxWrapSwitchWndProc
函数中又调用了xxxSwitchWndProc
函数
注:xxxSwitchWndProc
代码执行到这里就会失败,没有办法继续执行到xxxPaintSwitchWindow
函数,这是我们基于msg
值等于0x14
执行的流程。让我们检查一下原因。
触发正确的路径
就像前面那张图显示的,代码在这个地方会执行失败,因为窗口的fnid值不等于0x2A0
(FNID_SWITCH
),并且正在发送的消息不等于1,所以会直接结束xxxDefWindowProc
函数。为了避免这种情况,我们需要将fnid值设置为FNID_SWITCH
然后再调用xxxSwitchWndProc
函数,这样我们就可以通过执行switch语句调用到xxxPaintSwitchWindow
函数。
如何设置正确的fnid值?实际上上图中红色框已经显示的很清楚了,我们只需要将if中的检查全部失败即可到达设置fnid值的地方。
下面是一些我们需要满足的条件,使这三个if判断都失败:
fnid == 0
和cbwndExtra + 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 | HINSTANCE hInstance = GetModuleHandle(NULL); |
我为窗口类添加了extraData
,并添加了第二次调用的NtUserMessageCall
函数。现在我们的调用链就可以达到xxxPaintSwitchWindow
函数了。
(附带说明:dwType
的值不需要等于 0xE0
, 其值为 0
效果也是一样的 , 因为在 NtUserfnDWORD
函数中会和 0x1F
进行与操作)
注:xxxPaintSwitchWindow
经过仔细的检查,我发现从窗口对象(25行)获取的 extraWndData
的值会被当成一个指针去修改一块内存的值!(46-52行)如果我能通过代码设置extraWndData
的值,那我们就可以实现破坏任意内存!
要达到此目的我们需要再通过一些检查(红色标记):
检查窗口属性是否有
WS_VISIBLE
标志这个标志可以直接通过
CreateWindowEx
设置fnid == 0x2A0
和cbwndExtra + 0x128 == *(gpsi + 0x154)
fnid的值已经被第一次调用的
NtUserMessageCall
函数设定了。问题出在第二个检查,由于
*(gpsi + 0x154)
没有在未补丁的win32k
模块中初始化,因此这里的检查会一直不通过。除非我们以某种方式将其设置为正确的值。事实证明,创建特殊的切换窗口(卡巴斯基文章中提到)可以做到这一点。检查窗口是否未销毁
在这种情况下已经实现未销毁。
要创建特殊的切换窗口,我们需要调用CreateWindowEx
函数并设置窗口名为0x8003
(#32771
)。这样我们最终就会在内核中调用到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函数片段
这是我们实际利用未初始化变量*(gpsi + 0x154)
的地方,通过此检查后,我们将wnd->extraData
设置为任意值。如果正确初始化,则此处的利用将失败
1 | HINSTANCE hInstance = GetModuleHandle(NULL); |
这是运行上述代码的结果
不久之后,当 rdi
取消引用时,我们就会产生一次异常检查。在修复的Windows上运行利用程序显示如下:
1 | [*] Registering window |
SetWindowLongPtr
函数失败,返回错误码为 0x585
因为*(gpsi + 0x154)
变量被正确的初始化了,所以不会引起异常检查。
根本原因(回顾)
总的来讲,主要问题是未正确初始化*(gpsi+0x154)
变量。
但这个值有什么作用,为何它如此重要?
gpsi
是一个全局指针指向tagSERVERINFO
结构。这个结构描述了系统窗口(意味着菜单,桌面,切换等等),而不是用户窗口。这些窗口通过FNID值进行识别,例如 0x2A0
代表着切换窗口。
当使用RegisterClassEx
注册窗口时,我们有机会在WNDCLASSEX
上指定cbWndExtra
字段,该字段描述了除tagWND
结构外还将分配多少字节的额外数据,以储存窗口的额外信息。然后,我们可以通过调用SetWindowLongPtr
函数修改这些额外的字节。
系统窗口使用完全相同的机制来储存工作所需要的额外数据。但是原则上,不应使用 SetWindowLongPtr
修改此数据。我们看到xxxSetWindowLongPtr
函数中确实有一个阻止它的检查。在申明类型信息之后,下面是检查部分:
1 | if (nIndex >= gpsi->mpFnid_serverCBWndProc[(window->fnid & 0x3FFF) - FNID_FIRST] - sizeof(tagWND)) |
数组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
),这应该也是为了防止其他类似的变种利用方法。
破坏内存
运行exploit我们可以产生一次异常,崩溃发生在以下指令:
1 | xxxPaintSwitchWindow + 0x8B: |
在编写POC的最后一步是造成更有用的崩溃,或者造成更好的内存损坏并不使系统崩溃。
为了实现最后一步,我们需要:
提供一个有效的指针指向可读可写的内存
我选择使用
VirtualAlloc
函数去分配一些内存并将返回的指针作为参数传递给SetWindowLongPtr
模拟按下ALT键
就像之前提到的,我们会在
xxxPaintSwitchWindow
函数中调用GetKeyState/GetAsyncKeyState
并检查是否有按下ALT键。如果没有按下,程序就会退出。不论是使用GetKeyState
还是GetAsyncKeyState
它们都是由[extraWndData+6Ch]
中的标志决定我选择调用
SetKeyboardState
函数来模拟ALT键。这只适用于和GetKeyState
函数一起调用,因此我需要将偏移0x6C
处的值设置为1
1 | ptr = VirtualAlloc(0, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); |
通过这段代码,我发生了另一次崩溃
1 | DrawSwitchWndHilite + 0x10A: |
因此,我提供了一个有效的偏移量指针0x20
(指向自身)
1 | ptr[0x20 / sizeof(*ptr)] = ptr; // make double derefence succeed |
现在该漏洞利用程序可以正常工作而不会崩溃,并且当我们检查分配的页面的内容时,我们可以看到它已被修改!
我们实现了一个稳定的POC并且破坏了一块给予它的内存。这比在内存读取时POC崩溃要好得多,因为这种任意的内存损坏可以更容易地转变为任意的内核读/写。另外,我们已经提出了要损坏内存必须满足的条件。
结论
在这次练习中我介绍了如何通过一个漏洞描述报告实现一个有用的内核漏洞利用的POC。
这是一个非常有趣的利用就因为少了一行的代码。所以我想说的是,永远要记得初始化你的全局变量。
POC
poc.cpp
1 |
|
asm.asm
1 | _DATA SEGMENT |