2022年1月,微软发布了一个补丁来修复Windows内核中的漏洞。此漏洞允许攻击者以SYSTEM权限执行代码,本文介绍CVE-2022-21882漏洞,以及它如何绕过2021年2月修补的CVE-2021-1732的补丁。
CVE-2022-21882属于CVE-2021-1732的补丁绕过,所以这里对漏洞原理的介绍就直接用1732代替,网上对1732的漏洞分析已经非常多了,在这里我推荐阅读in1t对1732的分析文章,真的非常详细,本人在分析过程中也大量参考了in1t的报告,十分感谢。
本次分析用到的EXP来自KaLendsi。
win32kfull.sys 10.0.17763.194
用户态进程创建Windows窗口时,需要先注册一个窗口类结构体,然后基于注册好的窗口类,来创建一个相应的窗口。
WinUser.h中定义的窗口类结构体如下。
`typedef struct tagWNDCLASSEXW { UINT cbSize; /* Win 3.x */ UINT style; WNDPROC lpfnWndProc; int cbClsExtra; int cbWndExtra; HINSTANCE hInstance; HICON hIcon; HCURSOR hCursor; HBRUSH hbrBackground; LPCWSTR lpszMenuName; LPCWSTR lpszClassName; /* Win 4.0 */ HICON hIconSm; } WNDCLASSEXW, *PWNDCLASSEXW, NEAR *NPWNDCLASSEXW, FAR *LPWNDCLASSEXW; `
在上述结构体中,与漏洞相关的字段为cbWndExtra,此字段决定了创建的窗口的扩展内存的大小。当我们使用CreateWindowExW创建窗口时,最终会在内核中调用win32kfull!xxxCreateWindowEx函数,在此函数内会判断cbWndExtra是否为0,如果不为0,就会调用win32kfull!xxxClientAllocWindowClassExtraBytes函数,在用户层申请一块cbWndExtra大小的内存空间用作该窗口的扩展内存。当win32kfull!xxxClientAllocWindowClassExtraBytes函数返回后,没有做任何判断,直接将返回值赋值给tagWND.pExtraBytes,关于tagWND.pExtraBytes,后面会介绍。
win32kfull!xxxCreateWindowWx
每个窗口的扩展内存都是独享的,用于保存当前窗口的一些临时数据,Windows系统提供了一些用户层API用于操作这块内存,例如GetWindowLong、GetWindowLongPtr、SetWindowLong、SetWindowLongPtr。
上面提到的tagWND,是一个结构体,Windows用它来描述创建的每个窗口,此结构体的符号在Win7系统中还是导出的,但由于win32k模块中的漏洞较多,所以在现在的Win10中,这个结构体就不再导出符号了,但通过一些研究人员逆向分析以及网上公开的资料,仍然能够获得tagWND较为详细的结构,这极大的帮助了我们的分析。
tagWND结构如下(源自in1t):
`ptagWND(win10) 0x10 unknown 0x00 pTEB 0x220 pEPROCESS(of current process) 0x1A0 pEPROCESS(of current process) 0x18 unknown 0x80 kernel desktop heap base 0xA8 spMenu 0x28 ptagWNDk(tagWND(win7)) 0x00 hwnd 0x08 kernel desktop heap base offset 0x18 dwStyle 0x58 Window Rect left 0x5C Window Rect top 0x98 spMenu(uninitialized) 0xC8 cbWndExtra 0xE8 dwExtraFlag 0x128 pExtraBytes 0x90 spMenu 0x00 hMenu 0x18 unknown0 0x100 unknown 0x00 pEPROCESS(of current process) 0x28 unknown1 0x2C cItems(for check) 0x40 unknown2(for check) 0x44 unknown3(for check) 0x50 ptagWND 0x58 rgItems 0x00 unknown(for exploit) 0x98 spMenuk 0x00 pSelf 0xA8 spMenu `
在tagWND结构体中,较为重要的是cbWndExtra、dwExtraFlag、pExtraBytes。
cbWndExtra我们已经知道,表示扩展内存的大小,这里重点介绍pExtraBytes和dwExtraFlag。
总的来说,pExtraBytes与窗口的扩展内存地址相关,当我们调用SetWindowLong函数来操作窗口的扩展内存时,内核中实际会调用win32kfull!xxxSetWindowLong函数,此函数内会判断dwExtraFlag的值,通过dwExtraFlag的值来决定pExtraBytes内保存相对地址偏移还是地址。
当dwExtraFlag & 0x800等于0时,也就是dwExtraFlag没有控制台窗口标志,此时窗口的扩展内存位于用户桌面堆,pExtraBytes内保存位于用户空间的扩展内存的地址。当dwExtraFlag & 0x800不为0时,表示当前的dwExtraFlag具有控制台窗口标志,此时窗口的扩展内存位于内核桌面堆,pExtraBytes内保存扩展内存与内核桌面堆基址的偏移。
win32kfull!xxxSetWindowLong
那dwExtraFlag由谁来控制?
正常情况下,调用CreateWindowExW创建一个Windows窗口,在内核中会调用xxxCreateWindowEx,接着会判断tagWNDk.cbWndExtra是否为0,不为0时,就会调用win32kfull!xxxClientAllocWindowClassExtraBytes ,win32kfull!xxxClientAllocWindowClassExtraBytes函数内会通过nt!KeUserModeCallback调用用户模式回调函数user32!_xxxClientAllocWindowClassExtraBytes,在回调函数内,会申请一块cbWndExtra大小的堆空间,并将申请的堆空间地址作为NtCallbackReturn的第一个参数,接着调用NtCallbackReturn,进行堆栈修正并返回内核层,将用户空间堆地址赋tagWNDk.pExtraBytes。在上述流程中,并没有操作tagWNDk.dwExtraFlag,所以此时dwExtraFlag的默认值不具有控制台窗口标志,那么pExtraBytes内也就保存的是地址,而不是偏移。
win32kfull!xxxClientAllocWindowClassExtraBytes
user32!_xxxClientAllocWindowClassExtraBytes
但如果在用户态调用未公开的user32!ConsoleControl函数,就可以实现在内核桌面堆中申请空间作为扩展内存,并且设置tagWNDk.dwExtraFlag具有控制台窗口标志。
用户态调用未公开的user32!ConsoleControl函数时,实际会调用内核中的win32kfull!NtUserConsoleControl函数。如果此时参数1,也就是功能号为6,且第三个参数,也就是参数信息的长度不大于0x18时,就会调用win32kfull!xxxConsoleControl。在xxxConsoleControl函数中先判断了dwExtraFlag的值,然后通过DesktopAlloc从内核桌面堆中申请空间,接着判断当前pExtraBytes的值,如果不为0,则表示当前窗口已经有一个扩展内存,就调用xxxClientFreeWindowClassExtraBytes将其释放,然后将申请的内核桌面堆空间与内核桌面堆基址的偏移赋给pExtraBytes,最后修改dwExtraFlag,使其具有控制台窗口标志。
win32kfull!xxxConsoleControl
对上面的内容进行一个总结:
创建的窗口可以具有扩展内存,Windows提供了SetWindowLong等API可以对扩展内存进行操作。
使用SetWindowLong等API操作扩展内存时,在内核中具体索引这块内存是由pExtraBytes来决定的,而dwExtraFlag又决定了pExtraBytes内保存偏移还是地址。
当dwExtraFlag & 0x800 == 0时,窗口扩展内存位于用户空间桌面堆,pExtraBytes中保存用户空间扩展内存堆地址。使用扩展内存时,通过 pExtraBytes+nIndex 索引内存。
当dwExtraFlag & 0x800 != 0时,窗口扩展内存位于内核桌面堆,pExtraBytes中保存扩展内存与内核桌面堆基址的偏移。使用扩展内存时,通过 内核桌面堆基址+pExtraBytes+nIndex 索引内存。
而dwExtraFlag又可以通过在用户层调用user32!ConsoleControl来进行控制。
此时漏洞已经清晰明了,内核中的xxxCreateWindowEx对于xxxClientAllocWindowClassExtraBytes的返回值没有进行任何判断就赋给tagWNDk.pExtraBytes,而用户层的ConsoleControl函数又可以直接控制tagWNDk.dwExtraFlag,导致调用SetWindowLong函数时,发生类型混淆,从而可以直接操作内核内存。
内核漏洞一般都被用于权限提升,而权限提升最常用的方法就是TOKEN替换,但是TOKEN结构位于内核层,这意味着我们需要具备内核地址读写的能力,而dwExtraFlag的值可以决定是否让SetWindowLong操作内核内存,所以对于CVE-2021-1732,漏洞利用的思路如下。
正常创建具有扩展内存的窗口都会通过nt!KeUserModeCallback回调机制来调用用户态的 user32!_xxxClientAllocWindowClassExtraBytes 函数,我们对user32!_xxxClientAllocWindowClassExtraBytes进行HOOK,在HOOK函数中调用user32!ConsoleControl,实现对dwExtraFlag的修改(其实这里也会修改pExtraBytes),接着调用ntdll!NtCallbackReturn,向回调函数返回一个指定的内核地址(此地址会覆盖被user32!ConsoleControl修改过的pExtraBytes),回调结束后,我们已经实现了对dwExtraFlag和pExtraBytes的修改,此时再利用SetWindowLong就可以直接操作内核内存了,实际上此时已经实现了相对地址写原语,接着我们利用这个相对地址写原语继续构造出读原语,最终实现EXP进程的TOKEN替换。
这里借用两张iamelli0t图,有助于理解。
正常流程
漏洞利用流程
win32kfull.sys 10.0.19041.1387
通过上述分析,我们已经知道,1732本质上是一个逻辑漏洞,创建窗口时,在win32kfull!xxxClientAllocWindowClassExtraBytes函数中通过nt!KeUserModeCallback回调机制来调用用户态的 user32!_xxxClientAllocWindowClassExtraBytes 函数,在回调返回后,没有对返回的扩展内存地址和dwExtraFlag进行校验,就直接用于内存寻址,从而导致了内核内存越界访问,到最后实现权限提升。
那么微软的补丁就是在回调返回后,还没有给pExtraBytes赋值之前,检查tagWNDk.pExtraBytes值,如果pExtraBytes不为0,说明在回调函数中有异常行为,可能存在漏洞利用,从而对窗口等资源进行释放。
绕过的思路比较简单,1732的利用流程是win32kfull!xxxCreateWindowEx -> win32kfull!xxxClientAllocWindowClassExtraBytes -> HOOK_xxxClientAllocWindowClassExtraBytes,而补丁代码在win32kfull!xxxCreateWindowEx函数中,所以我们只需找到一个能够调用win32kfull!xxxClientAllocWindowClassExtraBytes,但不是win32kfull!xxxCreateWindowEx函数,即可绕过补丁。
在IDA中利用交叉引用可以看到哪些函数中会调用win32kfull!xxxClientAllocWindowClassExtraBytes。
如上图所示,xxxMenuWindowProc、xxxSwitchWndProc等函数都会调用win32kfull!xxxClientAllocWindowClassExtraBytes。
EXP代码中,使用NtUserMessageCall来实现利用。
NtUserMessageCall调用时的栈回溯结果如下。
可以看到通过NtUserMessageCall函数,最终会执行到win32kfull!xxxSwitchWndProc,而xxxSwitchWndProc正好会调用win32kfull!xxxClientAllocWindowClassExtraBytes,从而进入我们的HOOK函数,到这里其实就实现了补丁绕过,接下来的利用流程和1732基本一致。
这里按照EXP的执行流程进行调试分析。
下面这块代码主要进行一些准备工作,获取相应函数的地址等。
创建两个窗口类,后面的CreateWindowExW会利用这个窗口类来创建窗口。
循环创建10个窗口,将窗口句柄保存在arrhwndNormal数组中,通过 user32!HMValidateHandle获取每个窗口对应的tagWNDk结构在用户空间映射的地址并保存在qwfirstEntryDesktop数组中。
判断创建的10窗口中,窗口1和窗口2对应的tagWNDk1和tagWNDk2对象,哪个位于高地址,哪个位于低地址。高地址对应的tagWNDk对象与内核桌面堆基址的偏移为kernel_desktop_heap_base_offset_Max,那相对应的低地址对应的tagWNDk对象与内核桌面堆基址的偏移为kernel_desktop_heap_base_offset_Min,最后销毁剩余的8个窗口。
Windbg调试结果:
接下来的代码先将窗口1的扩展内存寻址模式通过NtUserConsoleControl改为offset模式,接着创建了窗口3,并HOOK了user32!xxxClientAllocWindowClassExtraBytes和user32!xxxClientFreeWindowClassExtraBytes,最后调用NtUserMessageCall,因为NtUserMessageCall最终会执行到win32kfull!xxxSwitchWndProc,从而通过win32kfull!xxxClientAllocWindowClassExtraBytes调用我们的HOOK函数,实现对窗口3扩展内存的修改。
调试结果如下:
调用NtUserConsoleControl,修改窗口1的dwExtraFlag和pExtraBytes。
接着创建第三个窗口
开始HOOK user32!xxxClientAllocWindowClassExtraBytes和user32!xxxClientFreeWindowClassExtraBytes。
在g_newxxxClientFreeWindowClassExtraBytes函数中,调用NtUserConsoleControl修改dwExtraFlag,调用NtCallbackReturn,将窗口1的tagWNDk对象地址与内核桌面堆基址的偏移作为参数,回调返回后,将此偏移赋给窗口3的tagWNDk.pExtraBytes。
HOOK完成后,就要调用NtUserMessageCall对窗口3的cbWndExtra和pExtraBytes的修改,从上面EXP的源码可以看到,NtUserMessageCall的参数2和参数6分别Message和dwStyle,其中Message的值为0x1,dwStyle的值为0xE0。关于这两个参数的用途,可以从下图得知。
win32k!NtUserMessageCall
NtUserMessageCall函数的参数2为 WM_CREATE,也就是1,所以Message[1]==2
所以gapfnMessageCall[2]==NtUserfnINLPCREATESTRUCT
win32kfull!NtUserfnINLPCREATESTRUCT
win32kfull基址为 ffff964b90600000
ffff964b90600000 + 0x33A020 = ffff964b9093a020
通过之前栈回溯的那张图,可以知道,我们需要的函数正好是xxxWrapSwitchWndProc,所以此函数在mpFnidPfn中索引为0x6,那么在NtUserfnINLPCREATESTRUCT中,索引的计算公式为(dwType+6) & 0x1F ,所以dwType为0xE0时,得到的索引刚好为0x6,如果dwType为0,最后的计算出的索引也为0x6。
win32kfull!xxxWrapSwitchWndProc
这里要注意的一个点是,在win32kfull!xxxSwitchWndProc函数中,tagWNDk+0xFC 处会被赋值,调试时可知这里赋的值为0x10,后续在调用SetWindowLong系列函数时会用到这个值。
win32kfull!xxxSwitchWndProc
可以看到最终在win32kfull!xxxSwitchWndProc中调用了xxxClientAllocWindowClassExtraBytes,之后就会通过Nt!KeUserModeCallback进入我们在用户层的HOOK函数。
调用NtUserMessageCall之前 tagWNDk3中的关键值
调用NtUserMessageCall后,执行了用户层被HOOK的回调函数,实现对dwExtraFlag和pExtraBytes的修改。
此时,窗口1、2、3的tagWNDk结构体如下
到这里,基本实现了我们想要的内存布局,如下图所示
接下来就要利用SetWindowLong系列函数和已构造好的内存布局来创建读写原语。
在win32kfull!xxxSetWindowLong中,先判断参数2,也就是索引值,当索引值+0x4小于tagWNDk.cbWndExtra时,才会继续向后执行,操作扩展内存。
win32kfull!xxxSetWindowLong
其次会判断tagWNDk.dwExtraFlag & 0x800的值,如果为0,寻址方式为 内核桌面堆基址+pExtraBytes+nIndex,否则为 pExtraBytes+nIndex。同时从下图可以看到,参数nIndex,在被用于索引扩展内存前,先执行了nIndex-(tagWNDk+0xFC) 的运算,而窗口3的tagWNDk+0xFC在xxxSwitchWndProc函数内被赋值为0x10,所以在调用SetWindowLong时,如果传入的参数1为窗口3的句柄,则参数2,nIndex需要先加0x10,才能保证在后续索引扩展内存时精确找到相关结构体。
tagWNDk+0xFC处的值
调用SetWindowLong时,Windbg调试结果:
第一次调用SetWindowLongW,由于此时tagWNDk3.pExtraBytes为tagWNDk1的内核桌面堆基址偏移,所以执行完成后,实际将tagWNDk1.pExtraBytes修改为tagWNDk1的内核桌面堆基址偏移,同时返回tagWNDk1.pExtraBytes的原始值。
第二次调用SetWindowLongW,将tagWNDk1.cbWndExtra改为0xFFFFFFF,因为后续我们需要利用nIndex来实现越界写,从而构造相对地址写原语,所以这里将cbWndExtra改为0xFFFFFFF,来绕过对nIndex大小的判断。
第三次调用SetWindowLongPtrA,修改tagWNDk2.dwStyle的值,使其具有WS_CHILD属性
调用完成后,此时的tagWNDk2.dwStyle已经具有WS_CHILD属性。
第四次调用SetWindowLongPtrA,目的是为了替换tagWND中的spMenu为我们申请的0xA0大小的堆空间的地址,也就是fakeSpMenu。如果nIndex为-12时,在xxxSetWindowData中会先判断tagWNDk.dwStyle是否具有WS_CHILE属性,然后才会重新设置tagWNDk+0x98和tagWND+0xA8的值,所以这也是为什么第三次调用SetWindowLongPtrA时,要重新设置tagWNDk2.dwStyle的值
win32kfull!xxxSetWindowData
Windbg调试结果
原始spMenu地址
第五次调用SetWindowLongPtrA,目的是将Max窗口的dwStyle改回原来的属性,因为接下来会调用封装好的MyRead64函数来实现任意地址读,MyRead64函数中会调用GetMenuBarInfo,配合前面填充的fakespMenu,实现任意地址读。GetMenuBarInfo函数最终会调用xxxGetMenuBarInfo,xxxGetMenuBarInfo中会再次检查WS_CHILD,所以这里需要将WS_CHILD改为不具有WS_CHILD的属性。
win32kfull!xxxGetMenuBarInfo
Windbg结果
上面执行过5次SetWindowLong系列函数后,此时已经将窗口2的spMenu替换为fakeSpMenu,并且也获得了原始的spMenu地址。
接下来利用封装好的读原语和泄露的spMenu地址进行内核内存读取,取得SYSTEM进程和EXP进程的TOKEN地址。
MyRead64读原语,在此函数中,实际是利用GetMenuBarInfo函数,和构造好的spMenu实现内核任意地址读。
GetMenuBarInfo最后会调用到内核中的xxxGetMenuBarInfo,并且会进行多次校验,判断spMenu中相关偏移的值,然后从*(QWORD *)(rgItems)处取得要读取的内核地址,最后从此地址加0x40处开始,取0x10个字节。
win32kfull!xxxGetMenuBarInfo
构造的能通过xxxGetMenuBarInfo校验的fakeSpMenu
*(SpmenuK) == pSelf
pSelf
(pSelf+0x28) == cItems
*(rgItems)
MyRead64中,这行负责填充想要读取的内核地址
调用6次MyRead64后的结果
经过6次Read,取的EXP进程的EPROCESS地址。
此时开始遍历EPROCESS链表,找到SYSTEM进程的EPROCESS结构地址,接着分别获得EXP进程和SYSTEM进程的TOKEN地址
Windbg结果如下
获取到TOKEN地址后,最后再调用两次SetWindowLongPtrA,第一次是将EXP进程的TOKEN地址写入窗口2的tagWNDk2.pExtraBytes内,第二次是将SYSTEM进程的TOKEN写入EXP进程的TOKEN地址内。
第一次调用SetWindowLongPtrA
windbg调试结果
第二次调用SetWindowLongPtrA,实现TOKEN替换。
windbg调试结果
接着就是以SYSTEM权限创建进程
可以看到,获得了SYSTEM权限
接下来,就开始修复被我们更改过的内核结构体,避免在释放的时候出错,导致蓝屏。
修复结构体的流程和更改结构体时基本一致,这里就不再详述,最后修复完成的结果如下图所示。
win32kfull.sys 10.0.19041.1466
通过上面的分析,可以得出如下利用流程
CVE-2021-1732:
`win32kfull!xxxCreateWindowEx -> win32kfull!xxxClientAllocWindowClassExtraBytes -> nt!KeUserModeCallback -> 用户层HOOK函数 `
CVE-2022-21882:
`win32kfull!xxxSwitchWndProc -> win32kfull!xxxClientAllocWindowClassExtraBytes -> nt!KeUserModeCallback -> 用户层HOOK函数 `
CVE-2021-1732的补丁打在了xxxCreateWindowEx函数中,但仍然可以通过xxxSwitchWndProc进行利用,导致补丁被绕过。
那对于CVE-2022-21882该如何修复?
如果补丁打在xxxSwitchWndProc,那其实和1732的补丁没什么区别,还是存在绕过风险,例如xxxMenuWindowProc、xxxTooltipWndProc都存在调用xxxClientAllocWindowClassExtraBytes的路径,所以这次微软直接在xxxClientAllocWindowClassExtraBytes上做了修复。当回调返回后,立刻检查tagWNDk.dwExtraFlag,如果dwExtraFlag & 0x800不为0 ,那说明当前的用户层回调可能被HOOK,存在漏洞利用,所以xxxClientAllocWindowClassExtraBytes直接返回错误。
win32kfull!xxxClientAllocWindowClassExtraBytes
[1] CVE-2022-21882 Win32k 特权提升漏洞
[2] CVE-2021-1732 Windows10 本地提权漏洞复现及详细分析
[3] Microsoft Windows被在野利用的提权漏洞(CVE-2021-1732)的分析报告
[4] CVE-2021-1732: win32kfull xxxCreateWindowEx callback out-of-bounds
[5] CVE-2022-21882 分析