CVE-2021-1732 :win32k 中的相对偏移读写漏洞
前置知识
Windows 桌面编程
我们可以直接用 Visual Studio
生成以下的默认模板:
1 | // test.cpp : 定义应用程序的入口点。 |
运行起来便可以得到一个简单的桌面程序:
程序的入口变为了 wWinMain
,代表为 unicode
版的 WinMain
,每个窗口类都通过 RegisterClassExW
进行注册,在上面的代码中体现为 MyRegisterClass
函数:
1 | // |
传入的结构体为 [WNDCLASSEX](https://docs.microsoft.com/zh-cn/windows/win32/api/winuser/ns-winuser-wndclassexa)
,定义如下:
1 | typedef struct tagWNDCLASSEXA { |
在我们注册完窗口类后,就可以通过 InitInstance
来初始化应用程序,其会创建窗口并进行展示更新:
1 | // |
如果我们不执行别的操作,那么窗口随后就会被销毁。而实际情况下我们所使用的桌面程序都不会初始化之后就立即销毁,而是会根据我们的一系列操作(鼠标点击、键盘输入等)进行对应的反应。这些操作作为消息传入程序中进行对应的处理,所以我们需要一个循环来接收并处理消息,如下所示:
1 | // 主消息循环: |
操作系统会把每个操作包装为消息结构体(记录了发生在哪个窗口,也就是句柄;还有消息编号、消息坐标等),并放入线程的消息队列,我们通过 GetMessage
可以取出消息,并通过 TranslateMessage
将虚拟键消息进行转换,之后通过 DispatchMessage
处理每种消息。在 WNDCLASSEXW
结构中所对应的也就是 lpfnWndProc
,把它赋值为某一方法,则 DispatchMessage
就会调用到某个方法,我们这里所传入的是 WndProc
函数:
1 | wcex.lpfnWndProc = WndProc; |
则会调用到 WndProc
进行消息处理:
1 | // |
WndProc
函数对 WM_COMMAND, WM_PAINT, WM_DESTROY
消息进行了处理,如果我们点击菜单的 about
,则会调用 About
函数进行进一步处理:
1 | // “关于”框的消息处理程序。 |
当然也可以点击退出去销毁该窗口类。
额外字节
在 WNDCLASSEXA
结构中存在着 cbClsExtra
和 cbWndExtra
两个额外字节成员:
1 | typedef struct tagWNDCLASSEXA { |
这两者十分相似,它们有什么作用和区别呢?
对于窗口类额外字节,在应用程序注册窗口类的时候,便可以请求系统分配一定大小的内存空间作为该窗口类的拓展内存空间。在之后该窗口类的所有子窗口都共享该内存空间,且可以使用这片内存进行窗口通信。程序员可以使用以下方法进行该内存空间的读写:
1 | DWORD GetClassLongW( |
其中 GetClassLongW
和 SetClassLongW
是 32
位系统所使用的, GetClassLongPtrW
和 SetClassLongPtrW
兼容了 32
和 64
位系统,在 32
位系统中会直接调用 GetClassLongW
和 SetClassLongW
。
同样的,当窗口创建的时候,也可以让系统分配一定大小的内存空间作为每个窗口独有的拓展内控空间。窗口可以使用这部分空间进行数据存储与读取。程序员可以使用以下方法进行该内存空间的读写:
1 | LONG GetWindowLongW( |
其中 GetWindowLongW
和 SetWindowLongW
是 32
位系统所使用的, GetWindowLongPtrW
和 SetWindowLongPtrW
兼容了 32
和 64
位系统,在 32
位系统中会直接调用 GetWindowLongW
和 SetWindowLongW
。
tagWND 结构体
在 Windows
中会使用 tagWND
结构体来描述每个窗口,但由于 win32k
漏洞频出,所以微软去掉了 tagWND
结构体的符号。目前而言我们只能参考 WIn7
之前的符号来猜测 Win10
中 tagWND
结构体的内容。
不过由于 win32k
的漏洞频出,所以已经有很多安全研究人员针对 tagWND
结构体进行了对应的研究。一个需要关注的点便是用户态和内核态分别维护了每一个窗口的 tagWND
结构体,用户态 &poi + 0x28
处的一个 QWORD
为一个指向内核态 tagWND
结构体的指针。我们分别记用户态和内核态的指针为 ptagWND
和 ptagWNDk
。
结构体总体如下(不同版本不同):
1 | ptagWND |
该结构体的很多内容都是在后面分析中得到的,这里只是一个总结,细节还需要研究各个方法的使用。
漏洞分析
本节将介绍漏洞所涉及到的函数于触发条件
CreateWindowEx流程
tagWND.pExtraBytes
会在调用 CreateWindowEx
的时候初始化,最终走到 xxxCreateWindowEx
中,我们将上面的示例代码中 cbWndExtra
值进行设置:
1 | wcex.cbWndExtra = 0xab; |
之后运行,这里调试的时候要下硬件断点:
1 | WARNING: Software breakpoints on session addresses can cause bugchecks. |
其调用栈如下:
1 | 00 ffffd58e`fb0378f8 fffff9d4`d7a47c20 win32kfull!xxxCreateWindowEx |
在 xxxCreateWindowEx
中会调用 win32kbase!HMAllocObject
来初始化 pTagWND
,随后初始化 pTagWNDk->pExtraBytes
为 0
,并在后面判断 ptagWNDk->cbWndExtra
是否为 0 ,如果不为 0 则使用 xxxClientAllocWindowClassExtraBytes
初始化 pTagWNDk->pExtraBytes
:
1 | LOBYTE(TYPE_WINDOW) = 1; // TYPE_WINDOW |
在 win32kbase!HMAllocObject
中会使用 HMAllocateUserOrIsolatedType
生成用户态的 pTagWND
结构体指针,并使用 DesktopAlloc
生成内核态的 pTagWNDk
结构体指针。将 pTagWND + 0x28
处存储 pTagWNDk
并在 pTagWND + 0x30
处存储 pTagWNDk
和桌面堆基址的偏移。最后在 pTagWND + 0x0
和 pTagWNDk + 0x0
处都存储对应的 handle
:
1 | ... |
最终的内存布局:
1 | 0: kd> dq rsi // pTagWND |
然后回到 pTagWNDk->pExtraBytes
赋值的地方,tagWND::RedirectedFieldcbwndExtra<int>::operator!=
的实现如下:
1 | bool __fastcall tagWND::RedirectedFieldcbwndExtra<int>::operator!=(__int64 a1, _DWORD *a2) |
而我们输入的第一个参数为 (char *)pTagWND + 0xB1
,第二个参数为 0
,也就是说最后变成了 :
1 | pTagWNDk + 0xC8 != 0 |
而这个值经过调试就是我们前面所设定的 cbWndExtra
值,为 0xab
:
1 | 0: kd> dq rcx-0x89 |
那么它便会调用 xxxClientAllocWindowClassExtraBytes(cbWndExtra)
并将 pTagWNDk->pExtraBytes
设置为返回值的内容,下面我们来看一看 xxxClientAllocWindowClassExtraBytes
函数:
1 | volatile void *__fastcall xxxClientAllocWindowClassExtraBytes(SIZE_T Length) |
它会使用 KeUserModeCallback
回调 PEB.KernelCallbackTable
表中第 123
项用户态函数(该函数为 user32!_xxxClientAllocWindowClassExtraBytes
****),传入的参数为 length
,并判断返回的 OutputLength
是否为 0x18
且 NTSTATUS
为成功。随后检查了返回的 OutputBuffer
和 OutputBuffer + length
是否都在用户态地址范围(小于 0x7fffffff0000
)。如果检查正确则最终返回 OutputBuffer
。
user32!_xxxClientAllocWindowClassExtraBytes
函数的内容如下:
1 | NTSTATUS __fastcall _xxxClientAllocWindowClassExtraBytes(unsigned int *pSize) |
其通过 RtlAllocateHeap
分配用户空间的堆,大小为 size
,同时使用 NtCallbackReturn
返回分配的结果。
最终 xxxCreateWindowEx
使用 xxxClientAllocWindowClassExtraBytes
所返回的结果(也就是用户空间的堆指针)初始化了 pTagWNDk->pExtraBytes
的值,也就是说普通情况下 pTagWNDk->pExtraBytes
的值会是一个用户态空间的堆指针。
需要注意的是,这里的 user32!_xxxClientAllocWindowClassExtraBytes
是用户态的函数,我们可以对其进行 hook
并返回任意在用户态空间地址的值,其也会直接通过检查并存放至 pTagWNDk->pExtraBytes
处。
SetWindowLongPtr流程
首先在上面的实例中添加如下代码:
1 | SetWindowLongPtr(hWnd, 0x34, 0xdeadbeef); |
然后就可以开始调试了
SetWindowLongPtr
最终会调用到 xxxSetWindowLongPtr
,栈回溯如下:
1 | 1: kd> k |
xxxSetWindowLongPtr
的主要逻辑如下:
1 | __int64 __fastcall xxxSetWindowLongPtr(struct tagWND *pTagWND, int index, __int64 value, __int64 zero, int one){ |
其会对 index
的范围进行检查,随后检查 (pTagWNDk + 0xE8) & 0x800
的标志位是否设置,并分两种情况写值:
- 如果设置了,则 pTagWNDk->pExtraBytes + index + DesktopBaseAddr = value
- 如果没设置,则 pTagWNDk->pExtraBytes + index = value
在正常情况下,我们是没有设置该标志位的,那么 pTagWNDk->pExtraBytes
中存储的就是用户态空间的堆地址,其设置值的方式也就是在用户态堆地址的偏移处写值;而如果设置了该标志位,则是从内核态的桌面堆基地址开始取偏移值并写入。
GetWindowLongPtr
的逻辑类似,这里不再分析。
ConsoleControl流程
ConsoleControl
位于 user32.dll
中,是一个未公开的函数。最终该函数会调用到 win32kfull!xxxConsoleControl
,逻辑如下所示:
1 | __int64 __fastcall xxxConsoleControl(int funcIndex, struct _CONSOLE_PROCESS_INFO *pParam, int paramLength){ |
当我们所输入的 funcIndex
为 6
且 param
长度为 0x10
的时候,会检查 flags
的设置,并进行如下操作:
- 如果设置了,则 buffer = pTagWNDk->pExtraBytes + DesktopBaseAddr
- 如果没设置,则 buffer = DesktopAlloc(*(pTagWND+0x18),cbWndExtra)
也就是如果设置了则证明已经有了内核态的 buffer
,直接取出;否则使用 DesktopAlloc
分配新的 buffer
。然后拷贝原先的 ExtraBytes
到该 buffer
,并存储 bufferAddr - DesktopBaseAddr
即 buffer
的地址到桌面堆基地址的偏移到 pTagWNDk->pExtraBytes
,最后将 flags
设置为 1
结束。
漏洞成因
经过上面的介绍,我们可以发现在调用 CreateWindowEx
的时候分配给 ExtraBytes
的用户空间堆是通过用户态函数 _xxxClientAllocWindowClassExtraBytes
来申请的,此后在 SetWindowLongPtr / GetWindowLongPtr
的时候会按照 ExtraBytes + index
的方式来计算写入地址并写入 value
。
然而 _xxxClientAllocWindowClassExtraBytes
函数是可以被 hook
的,也就意味着我们可以控制其的返回值。同时在 hook
中我们可以使用 ConsoleControl
来将 flags
设为 1
,这样 SetWindowLongPtr / GetWindowLongPtr
便会按照内核空间堆的相对偏移逻辑,即 ExtraBytes + index + DesktopBaseAddr
来计算写入地址并写入 value
。而 ExtraBytes
我们又可控,那么我们就可以相对 DesktopBaseAddr
进行越界读写。
可以用 iamelli0t
的一张图进行归纳:
漏洞利用
HMValidateHandle使用
如果我们需要调用 ConsoleControl
方法,那么我们需要传入 tagWND
的 handle
,但是我们此时处在 CreateWindowEx
函数中,还没有返回 handle
,而只是在 tagWND
和 tagWNDk
中存储了 handle
,所以我们可以泄露 tagWNDk
中存储的 handle
来进行利用。那么我们该怎么泄露呢?可以使用长久以来一直被使用的未公开函数 user32!HMValidateHandle
泄露信息。当我们把窗口的句柄和类型传入该函数,就可以得到 tagWNDk
在用户空间的只读映射指针。通过 free
再进行占位就可以获得接下来的新的 tagWNDk
的内容。
我们可以使用 user32!IsMenu
的第一个 call
来计算 HMValidateHandle
的地址,如下所示:
代码 如下:
1 |
|
当然也可以不用 IsMenu 而用别的函数来寻找 HMValidateHandle 的地址
BSOD
整合上面所说的内容,编写如下代码即可 BSOD
:
1 |
|
这里不需要 SetWindowLongPtr 就会 BSOD 好像是因为会在程序流中使用 ExtraBytes 导致的,会有很多神奇的情况(x
堆块布局和任意地址写
我们可以申请三个窗口,分别为 0 , 1 , 2
,然后将 0
块设置为内核寻址模式,将 2
作为被我们设置的混淆堆块,把它的 ExtraBytes
指向 0
块(可以通过 HmValidateHandle
读取 0
块的内容,其中 +0x8
的位置就是它相对于 DesktopBaseAddr
的偏移)。这样我们可以通过 2
块来改变 0
块的内容,同时也可以改写 1
块的 pExtraBytes
指针使其指向任意位置而进行任意位置的读写。
整体的描述可以参考 in1t
师傅的 文章 画的图:
任意地址读与地址泄露
虽然上面提到了如何任意地址读写,但是我们还未泄露出内核的地址,更不好进行所谓的任意地址读写。在野的 EXP
中使用了 [user32!GetMenuBarInfo](https://docs.microsoft.com/zh-cn/windows/win32/api/winuser/nf-winuser-getmenubarinfo)
通过伪造 tagMENU
结构体进行内核读取,其最终会调用到 win32kfull!xxxGetMenuBarInfo
中,逻辑如下:
1 | __int64 __fastcall xxxGetMenuBarInfo(__int64 pTagWND, int idObject, int idItem, tagMENUBARINFO *pmbi){ |
GetMenuBarInfo 会根据传入的 hwnd 找到对应的 pTagWND 并传入 xxxGetMenuBarInfo ,此时 idObject 为 OBJID_MENU(0xFFFFFFFD) 且 idItem 为 1,idItem 的定义为:The item for which to retrieve information. If this parameter is zero, the function retrieves information about the menu itself. If this parameter is 1, the function retrieves information about the first item on the menu, and so on.
当我们传入的 idObject
为 OBJID_MENU
且满足一定条件的时候,会根据 *((_QWORD *)*spMenuk + 0xB)
取出一个地址并读取那里的值用于计算 pmbi->rcBar
的内容。所以我们可以伪造 tagMENU
结构来达到任意读的目的。约束条件归纳如下:
- pTagWNDk->dwStyle 不能有 WS_CHILD
- idObject == 0xFFFFFFFD
- 0 < idItem <= ((*spMenuk + 0x28) + 0x2c)
- *(*spMenuk + 0x40) == 1 and *(*spMenuk + 0x44) == 1
- readAddr = targetAddr - 0x40
参考 in1t
师傅的 文章 画的图:
同时在 spMenu
中也可以泄露 eprocess
,具体来说是在:
1 | spMenu = *(pTagWND + 0xA8) |
in1t
师傅的 文章 也提到了另一种泄露 eprocess
的方式,不过可能由于版本问题在我的环境上不行,简单贴一下:
1 | spMenu = *(pTagWND + 0xA8) |
在 *(pTagWND + 0x98) 处也有 spMenu ,不过该字段默认情况下是未初始化的,不能直接读它获得 eprocess 。另外 0xa8 这个偏移也在各个版本不太一样,需要调整。同时也不是一定可以读到 eprocess 的,很多情况下读不到的,需要反复来几遍。
那么我们该如何设置 spMenu
呢?我们可以使用 tagWNDk0
直接越界写 tagWNDk1
的结构并修改,或者直接使用 SetWindowLongPtr
也可以设置,根据 官方文档 的说明,我们只需要将传入的 nIndex 设为 GWLP_ID
****即可:
其最终会调用 win32kfull!xxxSetWindowData
,大致逻辑为:
1 | unsigned __int64 __fastcall xxxSetWindowData(struct tagWND *pTagWND, int index, __int64 value, unsigned int a4) |
它不仅仅会设置 tagWND
和 tagWNDk
中的 spMenu
,还会把旧的 spMenu
返回给调用者。这样就可以拿到旧的 spMenu
并泄露 eprocess
了。
清理环境
在有了地址泄露、任意地址读写之后,替换 token
即可提权,最后我们需要清理一下被破坏的结构,防止出现蓝屏。需要清理的有以下几个点:
- tagWND0 不需要恢复
- tagWND1 需要恢复 spMenu 和 pExtraBytes
- tagWND2 需要恢复 pExtraBytes 和分辨用户态和内核态堆的 flags
最后结果
本来还有个 debug 的窗口的不过运行完就直接关闭了,所以只剩下这两个页面了
PATCH分析
就是在 create windows
的时候检查了标志位,如果设置的话则证明有问题,会将 pTagWNDk
回退回去:
另外该文章撰写于 2022 年 1 月,这个月报了几个 win32k 的 CVE 如 CVE-2022-21882 ,信息为绕过了该 patch 。之前我一直没想通这该怎么绕,后面看了补丁发现原来不止这一处调用了 xxxClientAllocWindowClassExtraBytes ,还有别的类型的窗口也会调用该函数从而导致一样的问题。感觉自己看这个洞看晚了,说不定看的早还能发现这个问题。
EXP
1 |
|
Reference
Technical Analysis of CVE-2021-1732 | McAfee Blogs
CVE-2021-1732 Windows10 本地提权漏洞复现及详细分析 - 安全客,安全资讯平台 (anquanke.com)
WNDCLASSEXA (winuser.h) - Win32 apps | Microsoft Docs
Win10 tagWnd partial member reverse (window hidden, window protected) - Programmer Sought
Microsoft Windows提权漏洞 (CVE-2021-1732) 分析 - 安全内参 | 决策者的网络安全知识库 (secrss.com)
CVE-2021-1732: win32kfull xxxCreateWindowEx callback out-of-bounds | iamelli0t’s blog
A simple protection against HMValidateHandle technique · theevilbit blog
GetMenuBarInfo function (winuser.h) - Win32 apps | Microsoft Docs
SetWindowLongPtrW function (winuser.h) - Win32 apps | Microsoft Docs