一、漏洞信息
1.背景
2021年2月10日,安恒公开披露了其在2020年12月份捕获到的一个Windows内核提权0day漏洞的相关信息,漏洞编号为CVE-2021-1732。此漏洞为Windows Win32k组件驱动模块win32kfull.sys中存在的代码逻辑bug,由某APT组织在进行定向攻击时,被检测和发现。微软在2021年2月的更新中已将此漏洞修复。
2. 漏洞简述
漏洞由win32kfull!xxxCreateWindowEx中的xxxClientAllocWindowClassExtraBytes回调函数引起,导致内核结构成员及其对应标志的设置不同步。
win32kfull!xxxCreateWindowEx创建具有WndExtra成员的窗口时,它将调用win32kfull!xxxClientAllocWindowClassExtraBytes触发回调,该回调将返回用户模式User32!_xxxClientAllocWindowClassExtraBytesh函数以分配WndExtra成员。攻击者hook此回调函数后,在自定义回调函数中,调用NtUserConsoleControl并传入当前窗口的句柄,这会将内核tagWND成员(指向WndExtra区域)更改为offset值,并设置相应的标志以指示该成员现在为偏移量(相对寻址方式)。之后,攻击者可以在自定义回调中调用NtCallbackReturn并返回任意值。返回内核模式,返回值将覆盖先前的offset成员,但不会清除相应的标志。之后,内核代码将未经检查的Offset值直接用于堆内存寻址。
- 漏洞名称:Windows Win32k权限提升漏洞
- 漏洞编号:CVE-2021-1732
- 漏洞类型:设计缺陷
- 漏洞影响:本地权限提升
- CVSS评分:7.8
- 利用难度:Exploitation Detected
- 基础权限:普通用户权限
3.漏洞影响
Windows Server, version 20H2 (Server Core Installation)
Windows 10 Version 20H2 for ARM64-based Systems
Windows 10 Version 20H2 for 32-bit Systems
Windows 10 Version 20H2 for x64-based Systems
Windows Server, version 2004 (Server Core installation)
Windows 10 Version 2004 for x64-based Systems
Windows 10 Version 2004 for ARM64-based Systems
Windows 10 Version 2004 for 32-bit Systems
Windows Server, version 1909 (Server Core installation)
Windows 10 Version 1909 for ARM64-based Systems
Windows 10 Version 1909 for x64-based Systems
Windows 10 Version 1909 for 32-bit Systems
Windows Server 2019 (Server Core installation)
Windows Server 2019
Windows 10 Version 1809 for ARM64-based Systems
Windws 10 Version 1809 for x64-based Systems
Windows 10 Version 1809 for 32-bit Systems
Windows 10 Version 1803 for ARM64-based Systems
Windows 10 Version 1803 for x64-based Systems
Windows 10 Version 1803 for 32-bit Systems
二、漏洞复现
1. 环境搭建
1 | 靶机:Windows10 1909(18363.1316) windows10 (19042.746) |
2. 复现过程
windows10 20H2(19042.746)执行样本文件,蓝屏。
Windows10 1909(18363.1316),样本文件开始执行时为中完整性(Medium)。
执行完利用程序后,变为系统级完整性(System)。
三、漏洞分析
安恒给出的利用样本分析已经相当详细,有分析基础的师傅们可以之间去看安恒的分析,本篇分析是我第一次详细的分析0day漏洞的利用,学到挺多知识,仅做记录。
1. 基础分析
- 漏洞文件:win32kfull.sys(10.0.18362.1316)
- 漏洞函数:win32kfull!xxxClientAllocWindowClassExtraBytes
2. 静态分析
1. 漏洞函数分析
win32kfull!xxxCreateWindowEx函数中,调用win32kfull!xxxClientAllocWindowClassExtraBytes函数,返回值来填充*(tagWnd+0x28)+0x128字段。此字段,可理解为窗口的extra数据地址,因为传入后一个函数的参数为*(tagWnd+0x28)+0xC8字段,此字段为tagWND->cbWndExtra(ULONG),代表当前窗口中的额外字节大小,此值可由窗口类结构体WNDCLASSEX成员cbWndExtra来进行设置。
win32kfull!xxxClientAllocWindowClassExtraBytes函数中,通过使用用户回调,进行用户态函数调用。
KeUserModeCallback用户模式回调调用KernelCallbackTable中ApiNumber为0x7B的函数,即User32!_xxxClientAllocWindowClassExtraBytes ,而KeUserModeCallback原型如下:
1 | KeUserModeCallback ( |
因此,我们看到,传入InputBuffer为v12即size_t* Length,InputLength为4,正是上层传入的cbWndExtra。
User32!_xxxClientAllocWindowClassExtraBytes函数中,调用RtlAllocateHeap申请堆内存,并通过调用User32!NtCallbackReturn将返回内核函数,而申请的用户模式内存地址和大小即为KeUserModeCallback第四和第五参数。拿到获得的地址,写入*(tagWND+5*8)+0x128字段。
在利用样本中,存在另外一个关键的函数win32u!NtUserConsoleControl。此函数经过系统调用进入内核中,调用win32kfull!NtUserConsoleControl函数。后者内部调用win32kfull!xxxConsoleControl函数。
win32kfull!xxxConsoleControl函数中,会将*(tagWnd+0x28)+0x128处的值修改为一个offset,计算方式为offset = DesktopAlloc值-原本存在的pointer值,同时,设置*(tagWnd+0x28)+0xE8处的值,加上0x800标志,可以理解为一个flag,意味着当前extra data地址的寻址方式变为offset方式。
当User32!_xxxClientAllocWindowClassExtraBytes函数被Hook为自定义函数,如下所示:
首先调用win32u!NtUserConsoleControl函数将窗口的extradata地址修改为offset,设置flag|=0x800,即寻址方式变为offset方式。接着使用ntdll!NtCallbackReturn函数,返回一个fake_offset值,当后续内核代码使用到此fake_offset值可造成内存越界访问,当访问到内存为无效内存时,可造成BSOD。
但是存在一个问题,回调函数中,窗口并未创建完成,我们无法拿到当前窗口句柄,更别提后续调用NtUserConsoleControl来修改flag。
这里有两种解决方案,第一种是在在野样本中使用的利用HMValidateHandle获取窗口对象内核结构映射到用户堆的内存后,使用VirtualQuery函数,获取内存信息,然后多次重复后,找到窗口内存映射过程中,最小的基地址,通过暴力内存搜索,根据窗口对象tagWND的成员设置特殊值,查找目标窗口值,再根据结构体成员偏移,结构体第一成员head中存在句柄值,获取窗口句柄。
第二种,在kk师傅分享的exploit中,我们可以利用类似堆喷的思想,创建多个窗口,使用不公开的HMValidateHandle获取窗口内核对象tagWND映射的用户堆部分,我们知道了这些用户窗口对象的内存后,销毁部分窗口,但是释放的窗口的内存依旧存在,我们在重新创建窗口时,优先使用这些释放的窗口对象的内存,由于窗口对象第一成员为窗口的句柄,我们在成员设置一些特殊值,查找内存即可,定位到新创建的窗口的句柄。
分析到这里,我们可以在18362.1316构造一个crash poc,流程如下所示:
3. 动态分析
此次调试获取HWND的方式为第一种,即通过VirtualQuery查询mbi后,搜索内存方式,另一种方式如前所述,更好理解。
首先获取两个函数地址。
通过IsMenu函数获取HMValidateHandle函数地址。
通过gs:[60h]获取PEB地址,访问PEB+0x58成员,即KernelCallbackTable地址,table的第0x7b成员即我们的目标回调函数User32!_xxxClientAllocWindowClassExtraBytes。
Hook这个地址后,则转为我们的自定义回调函数。
生成一个magic_value,这里random值模上0xFF+0x1234或1的奇数值,此时为0x12c1。后续创建两个窗口类,一个WndClassExW.cbWndExtra为正常值0x20,另一个WndClassExW.cbWndExtra为magic_value。
创建正常窗口。
使用HMValidateHandle获取窗口对象内核结构映射的用户模式地址,第一成员为_THRDESKHEAD Head结构,查看下面的成员我们可以知道,第一成员指向的内存为窗口句柄。
1 | kd> dt _THROBJHEAD |
1 | VirtualQuery(v6, &Buffer, 0x30i64); |
通过VirtualQuery函数,我们获取内存页信息,第二参数mbi结构如下。
1 | typedef struct _MEMORY_BASIC_INFORMATION { |
循环多次创建后,我们拿到用户堆窗口对象申请内存的最小基地址以及RegionSize大小。
销毁部分窗口,便于内存分配到释放窗口附近。
创建magic窗口。由于其设置里magic_value,自然进入我们自定义中的可控部分。
调用GethWndFromHeap获取magic窗口句柄。方法一,通过我们之前获取的最小基地址,搜索内存,查找magic_value,定位到magic窗口对象映射的用户模式内存。
当一个Region的内存查询完毕或者找到magic值,判断当前内存0x18处即tagWND.ExStyle是否为我们创建窗口时设置的0x8000000。当不为此值时,则说明此处内存并不是我们的目标内存,或者是目标内存,但是需要对齐结构体。前一种情况继续外层循环,开始之前,调整我们的region_size为前一次循环从本次基地址到当前搜索到内存地址的偏移大小;后一种情况直接拿对齐的地址访问偏移即可拿到我们的目标句柄值。
多次循环后,搜索到magic值,也就定位到我们的magic窗口句柄。
接着在自定义毁掉中,调用NtUserConsoleControl设置magic窗口的flag以及offset值。
设置参数条件断点。
1 | ba e1 win32kfull!NtUserConsoleControl "j (rdx==00007ff7`72716120) ''; 'gc' " |
内核调试中,我们进入win32kfull!xxxConsoleControl函数,设置offset。
1 | win32kfull!xxxConsoleControl+0x325: |
使用bts指令,设置flag标志位0x800。
返回自定义回调,调用NtCallbackReturn函数,第一参数为我们根据magic值申请的进程堆内存地址。
1 | crash_poc!_Fake_USER32_xxxClientAllocWindowClassExtraBytes+0xa1: |
此时地址为0x146cad82fd0,内核调试器返回win32kfull!xxxClientAllocWindowClassExtraBytes函数,返回xxxCraeteWindowEx函数,将此值设置回*(tagWND+0x28)+0x128处。
在poc中,我们执行完成后,DestoryWindow magic窗口,由于内部把我们的pointer当成offset,越界访问内存,BSOD。
1 | 1: kd> kv |
调试器显示crash时堆栈如上所示,与我们的流程一致。
crash_poc地址如下:
1 | https://github.com/Saturn35/POC/tree/main/CVE-2021-1732/crash_poc |
4. 利用思路
构造任意地址读写原语。
普通用户运行exp即可进行本地权限提升。
1.构造任意地址读原语
此条件的达成,在野样本中使用了一个新的方法GetMenuBarInfo,和这里的思路比较相似。
首先我们先来看看GetMenuBarInfo函数。
1 | BOOL GetMenuBarInfo( |
最终调用Win32kfull!NtUserGetMenuBarInfo,参数与用户层相同。此函数中获取窗口对象tagWND地址,传入Win32kfull!xxxGetMenuBarInfo函数。
此函数中,需要越过条件,首先传入第二参数需要为-3,只需在用户代码中向GetMenuBarInfo函数第二参数传入-3即可。同时此窗口的tagWND成员spmenu(tagMENU类型),它所在内存0x98处的地址,我们记为mem_4,mem_4处存储的指针记为mem_200,接着*(*mem_4+0x40)以及*(*mem_4+0x44)需要不为0,也即就是*(mem_200+0x40)和*(mem_200+0x44)处不为0。
接着,v6即传入的第三参数需要为非零值,且后续用此值作为index进行偏移计算,因此此值经推断只能取1,同样只需在用户代码中向GetMenuBarInfo函数第三参数传入1即可。
还有一处没有标出来的需要越过,事实上有三处,正是menubarinfo.rcBar.left行上面的if语句,即需要使tagWND+0x28处也即就是v37内容为0。
接下来重点是如何读,顾名思义,内核任意地址读原语首先需要制定一个需要读的地址,也就是由外部指定的kernel地址,此地址在x64下需要分为高4位和低4位。在我们标出的位置,就是上面提到的另外两处*(tagWND+0x28)+0x58以及*(tagWND+0x28)+0x5C处值需要为0,我们才能做到对目标地址解星号。
解引用时因为访问的分别是偏移为0x40以及0x44处的内存,因此我们在向此处写kerneladdress时需要减去0x40。调试可知,此值为默认情况下menubar.rcBar.left和menubar.rcBar.top的值。
解决了上述条件限制我们即可进行任意地址读。
还有一个问题是如何修改窗口对象的tagWND结构的spmenu指针,此指针类型为tagMENU。在阅读了趋势科技的一篇漏洞分析文章后,发现以下操作可以进行修改。
用户模式代码提供了一种 用任何东西替换目标窗口的spmenu值的方法。win32k函数xxxNextWindow获取目标窗口的spmenu 值,并将其作为指向tagMenu对象的指针。
首先增加窗口样式WS_CHILD(0x4000000000000000),调用SetWindowLongPtr,当第二参数为(GWLP_ID)时,则可设置任意值到tagWND的spmenu成员。而窗口样式成员在x64下位于tagWND的0x18处。
因此,构造读原语的部分代码如下所示。
1 | ///////////////////////////////////////////////////// |
最终构造的内存结构如下所示:
1 | 踩坑,然鹅真实情况并不是这么简单,在MEM_200h_addr的构造上,实际内存是比较复杂的,并不是简单地初始化为0。 |
这一大段128位寄存器到底在干甚么,发生甚么事了?啪的一下,很快啊,调试结果就出来了。
大概意思就是对这0x200大小的内存,每4字节值为当前offset,offset为从地址其实偏移。那么,我们在读原语代码中,需要加上这一步操作。个人在调试过程中,当对此处内存只初始化为0时,一旦执行GetMenuBarInfo函数,便会BSOD,猜测是因为内核的xxxGetMenubarInfo函数因为无法正确初始化而崩溃,而上面调试信息中的内存布局是正常GetMenuBar所需要的布局(仅个人猜测)。
1 | for (int i=0;i<0x200/4;i++) |
而真正的内存布局,如下所示。
2.构造任意地址写原语
同理,任意地址写原语也需要首先需要一个where的地址,我们需要进程提权的话,则需要向目标进程的Token地址。如何获取首先通过进程ID遍历,拿到指定ID后通过EPROCESS+0x360定位Token地址。而进程链如何获取,我们在前面的读原语构造中提到的覆盖spmenu方法,SetWindowLongPtr的返回值正是目标地址处之前的值,也就是泄露出hwnd1的tagWND成员spmenu(tagMENU*结构),通过多次使用读原语,我们即可拿到进程Token地址和值。
1 | kd> dt tagMENU |
此外我们需要一个what值,此值在提权时必然是System进程的Token值。
有了两个参数,我们具体如何实现?
还记得我们前面提到的多个窗口创建嘛,我们留了两个窗口hwnd0和hwnd1。这两个窗口,我们对hwnd0手动调用NtUserConsoleControl,即修改其flag为0x800。此外由于我们使用HMValidateHandle拿到了两个窗口的用户模式窗口对象结构地址,通过计算偏移,即可通过窗口0来访问窗口1的结构成员。
1 | SetWindowLongPtrA(_hWnd0, _hWnd1_tagWND_Offset + 0x128 - _hWnd0_tagWND_Offset, where);// //设置窗口1 的tagWND的extarMemaddr 也就是 写哪 |
首先使用SetWindowLongPtrA通过窗口0设置窗口1的0x128处成员,也就是ExtraDataAddr,设置好了之后再通过SetWindowLongPtrA写窗口1的ExtraData写数据,也就是完成了任意地址写。
1 | v11 = readQWORD(v9 + (unsigned int)_Offset_50); |
3.恢复现场
在进行Token替换后,我们需要对现场进行恢复,防止BSOD。
要恢复的包括hwnd1窗口的spmenu数据 hwnd1的ExtraData寻址范围
hwnd0窗口样式
hwnd0的flag恢复 hwnd0的ExtraDataAddr恢复
释放各种自己申请的堆内存
1 | v26 = _HMValidateHandle(hWnd2, 1i64); |
3. 攻击向量
Local
4.利用演示
构造的exp_poc代码地址如下:
1 | https://github.com/Saturn35/POC/tree/main/CVE-2021-1732/exp_poc |
5.踩坑记录
1.Hwnd1的spmenu偶尔获取不到
究其原因,是因为利用代码的思路深受crash代码的印象,认为循环创建窗口时第一个创建的就是hwnd0,第二个创建的就是hwnd1。然鹅事实上,并不能这么写,必须将tagWND+8处值(cLockObj )小的作为hwnd0,大的作为hwnd1。因为我们多次使用SetWindowLongPtrA进行跨窗口访问时,会使用偏移offset差值来进行值得设置,如果某次执行过程中,出现了hwnd0的cLockObj值大于hwnd1的cLockObj 值,那么传入SetWindowLongPtrA的nIndex变为负值,则写窗口成员失败,因此影响后续的spmenu获取。
2.最大的精彩之处在于CreateWindowEx未返回时如何获取窗口句柄
由于在野样本在自定义回调函数中获取句柄函数存在大量混淆,理解不深刻,后续crash复现的时候,发现拿不到magic窗口的句柄值,则无法触发漏洞,一顿陷入僵局,这里卡了很久。再次感谢下git上的两位大佬给出的两个思路,见前文所述。
3.恢复现场时的操作
主要的问题是对于magic窗口的恢复,在野poc的思路是将magic窗口的tagWND+0x128置为0,而后将magic窗口的tagWND+0xE0置为0;前者很好理解,就是ExtraDataAddress为0,而0xE0处的成员并不清楚是什么,因此个人觉得难以理解;而后KK大佬的思路是通过人工申请内存,将magic窗口tagWND+E8处的flag去掉0x800标志,设置为pointer,而后将申请的内存写入tagWND+128,因此,当销毁窗口时,系统会自动释放此处的内存,同时不会影响hwnd0,但是此方法存在的问题是和问题1有点类似,就是magic窗口的内存offset存在偶然性,并不一定比hwnd0的offset大,因此极端情况下是无法设置magic窗口成员的。因此个人在POC里采用了在野原始poc的思路,至于为何将tagWND0+0xE0处置0,或许有知道的师傅可以解释一二,但是可以肯定的是,必然影响ExtraDataAddress的寻址和释放。
四、缓解措施
请安装2020年2月补丁。
https://www.catalog.update.microsoft.com/Search.aspx?q=KB4601315
五、参考文献
[1]WINDOWS KERNEL ZERO-DAY EXPLOIT (CVE-2021-1732) IS USED BY BITTER APT IN TARGETED ATTACK
[2]Windows Win32k Elevation of Privilege Vulnerability
https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2021-1732
[3]KaLendsi/CVE-2021-1732-Exploit
https://github.com/KaLendsi/CVE-2021-1732-Exploit
[4]k-k-k-k-k/CVE-2021-1732
https://github.com/k-k-k-k-k/CVE-2021-1732
[5]Analyzing CVE-2016-7255 Exploit In The Wild
[6]LPE vulnerabilities exploitation on Windows 10 Anniversary Update Introduction
http://cvr-data.blogspot.com/2016/11/lpe-vulnerabilities-exploitation-on.html