0x00背景

蔓灵花(BITTER)组织针对我国政府部门、科研机构发起攻击使用的Windows内核提权漏洞,编号为CVE-­2021­-1732,此漏洞利用Windows操作系统win32kfull.sys一处用户态回调,破坏函数正常执行流程,造成窗口对象扩展数据的属性设置错误,最终导致内核空间的内存越界读写。

0x01漏洞原理

Windows窗口创建(CreateWindowEx)过程中,当遇到窗口对象tagWND有扩展数据时(tagWND.cbwndExtra != 0),会通过nt!KeUserModeCallback回调机制调用用户态ntdll!_PEB.kernelCallbackTable(offset+0x58)里的函数:user32!_xxxClientAllocWindowClassExtraBytes,从而在用户态通过系统堆分配器申请(RtlAllocateHeap)扩展数据的内存。
image.png

内核窗口对象tagWND的扩展数据有两种保存方式:

  1. 保存于用户态系统堆,用户态系统堆申请的扩展数据内存指针直接保存于tagWND.pExtraBytes。
  2. 保存于内核态桌面堆:函数NtUserConsoleControl调用会通过DesktopAlloc在内核态桌面堆分配内存,计算分配的扩展数据内存地址到桌面堆起始地址的偏移,保存在tagWND.pExtraBytes中,并修改tagWND.extraFlag |= 0x800

win32kfull!NtUserCreateWindowEx创建窗口时会判断tagWND->cbWndExtra(窗口实例额外分配内存数),该值不为空时调用win32kfull!xxxClientAllocWindowClassExtraBytes函数分配内存返回分配地址。
CVE-­2021­-1732 - 图3
win32kfull!xxxClientAllocWindowClassExtraBytes函数中,通过使用用户回调,进行用户态函数调用。KeUserModeCallback用户模式回调调用KernelCallbackTable中ApiNumber为0x7B的函数,即User32!_xxxClientAllocWindowClassExtraBytes
image.png
User32!_xxxClientAllocWindowClassExtraBytes函数中,调用RtlAllocateHeap申请堆内存,并通过调用User32!NtCallbackReturn将返回内核函数,而申请的用户模式内存地址和大小即为KeUserModeCallback第四和第五参数。拿到获得的地址,写入(tagWND+58)+0x128字段。
CVE-­2021­-1732 - 图5
在利用样本中,存在另外一个关键的函数win32u!NtUserConsoleControl。此函数经过系统调用进入内核中,调用win32kfull!NtUserConsoleControl函数。后者内部调用win32kfull!xxxConsoleControl函数。
win32kfull!xxxConsoleControl函数中,会将(tagWnd+0x28)+0x128处的值修改为一个offset,计算方式为offset = DesktopAlloc值-原本存在的pointer值,同时,设置(tagWnd+0x28)+0xE8处的值,加上0x800标志,可以理解为一个flag,意味着当前extra data地址的寻址方式变为offset方式。
CVE-­2021­-1732 - 图6
在poc中,我们选择在销毁窗口时触发漏洞, win32kfull!xxxFreeWindow 函数会判断上述flag是否被设置,若被设置,则代表对应的内核结构中存储的是offset,调用RtlFreeHeap函数释放相应的内存;若未被设置,则代表对应的内核结构中存储的是内存地址,调用xxxClientFreeWindowClassExtraBytes借助用户态回调函数进行释放。
在xxxClientAllocWindowClassExtraBytes回调函数内,可以借助NtCallbackReturn控制返回值,回调结束后,会用返回值覆写之前的offset,但并未清除相应的flag。在poc中,我们返回了一个用户态堆地址,从而将原来的offset覆写为一个用户态堆地址(fake_offset)。这最终导致win32kfull!xxxFreeWindow在用RtlFreeHeap释放内核堆时出现了越界访问。

  • RtlFreeHeap所期望的释放地址是RtlHeapBase+offset;
  • RtlFreeHeap实际释放的地址是RtlHeapBase+fake_offset;

CVE-­2021­-1732 - 图7

0x02漏洞利用

Init()

在Main函数中调用Init()来完成前期的初始化工作,里面包括获取HMValidateHandle的地址,对USER32!xxxClientAllocWindowClassExtraBytes进行Hook

HMValidateHandle()

首先通过IsMenu函数搜索未导出的函数HmValidateHandle,该函数可用于获取一个应用层窗口句柄对应的内核句柄对象在应用层的内存映射。
image.png
image.png
看出已导出函数IsMenu第一个Call调用的是HmValidateHandle,在代码中先导出Ismenu的函数地址,然后搜索第一个E8(call)根据后面的偏移算出HmValidateHandle函数的地址。
函数所在的地址 = call指令所在地址+ 偏移 +表示地址的字节长度

Hook()

image.png 使用readgsqword(0x60)来获取64位下的PEB,readfsqword(0x30)可以获取32位下的PEB
使用readgsqword(0x30)可以获取64位下的TEB,readfsqword(0x18);可以获取32位下的TEB
PEB+0x58是KernelCallbackTable,根据第一个参数ApiNumber来调用表中的函数

  1. KeUserModeCallback (
  2. IN ULONG ApiNumber,
  3. IN PVOID InputBuffer,
  4. IN ULONG InputLength,
  5. OUT PVOID *OutputBuffer,
  6. IN PULONG OutputLength
  7. )

表中第0x7B个为User32!_xxxClientAllocWindowClassExtraBytes
User32!_xxxClientAllocWindowClassExtraBytes函数中,调用RtlAllocateHeap申请堆内存,并通过调用User32!NtCallbackReturn将返回内核函数,而申请的用户模式内存地址和大小即为KeUserModeCallback第四和第五参数。拿到获得的地址,写入(tagWND+58)+0x128字段。
得到这个函数的地址后,我们将其替换为自定义的_Fake_USER32_xxxClientAllocWindowClassExtraBytes()函数。

NormalWindows&MagicWindows

image.png
在Init里注册两种函数,一种是cbWndExtra为0x20的正常函数,一种是cbWndExtra为随机生成的用于漏洞利用的函数。
创建十个正常的窗口,然后将句柄作为参数使用HMValidateHandle()获取这10个窗口句柄对应的内核窗口对象在应用层的映射。然后保存了0,1的窗口对象的应用层映射内存,其偏移0x8的位置为对应的内核对象在内核地址中的偏移。
image.png
使用VirtualQuery我们获取内存页信息
循环查询这十个内存页的最小基址和大小,并保存起来。
image.png
之后依次将2-10个窗口对象通过函数DestroyWindow释放,并通过函数NtUserConsoleControl将第0个窗口对象的扩展内存寻址设置为offset类型。
image.png
创建Magic窗口记作窗口2,这时magicClass窗口类中对应的cbwndExtra字段和hook函数中的一致,因此窗口2创建的过程中会触发漏洞函数xxxClientAllocWindowClassExtraBytes并最终通过内核回调进入到我们设置的hook函数中。
使用调试工具得到我们生成的随机数既窗口2对应的cbwndExtra为12a5。
win10中tagWND对象的符号已经被删除,其内容也发生了相当的变化,以下为通过分析之后还原出的tagWND的重要结构:
CVE-­2021­-1732 - 图15
pwnd,位于tagWND偏移0x28,其中比较重要的是
0x98 spmenu 窗口对应的menu菜单对象
0xc8 cbwndExtra 窗口对应扩展内存的大小
0xe8 Extra flag 窗口对象扩展内存的寻址标记,支持指针寻址,和偏移寻址,默认为指针寻址
0x128 pExtraBytes 保存扩展内存对应的位置,根据Extra flag为指针或offset偏移
tagWND偏移0x18+0x80的位置为对应的内核基地址FFFFFF06 81200000,配合上对应的内核偏移就可以计算出对应的tagWND对象pwnd在内核中的偏移。

_Fake_USER32_xxxClientAllocWindowClassExtraBytes()

image.png

在Hook函数里,要使用NtUserConsoleControl来改变窗口的Flag标记位,但是要使用窗口的句柄,此时窗口还没有创建完成,所以要使用一点小技巧来得到窗口句柄:
在野样本中使用的利用HMValidateHandle获取窗口对象内核结构映射到用户堆的内存后,使用VirtualQuery函数,获取内存信息,然后多次重复后,找到窗口内存映射过程中,最小的基地址,通过暴力内存搜索,根据窗口对象tagWND的成员设置特殊值,查找目标窗口值,再根据结构体成员偏移,结构体第一成员head中存在句柄值,获取窗口句柄。

GethWndFromHeap()

image.png
使用之前得到的基地址开始搜索Magic创建时使用的随机数,随机数位于0xc8,所以找到后结构体的位置就是对应的位置减去0xc8
获取到窗口句柄后就使用NtConsoleControl修改窗口2的寻址为偏移寻址,再将窗口0的结构体使用NtCallBackReturn传回到内核,是窗口2的pExtraBytes是窗口0的tagWND结构体地址。
之后通过xxxSetWindowLong设置窗口2的扩展内存时,实际的操作地址将是0窗口内核对象的pwnd。至此可以通过窗口2调用xxxSetWindowLong来将窗口0的cbwndExtra字段设置为0xfffffff,以此获取越界写入的能力。

  1. SetWindowLongW(__hWnd2, 0xC8, 0xFFFFFFF);

ReadQword()

image.png
计算窗口0扩展内存的起始地址到tagWND1的偏移再加0x18在通过SetWindowLongPtrA设置窗口1的dwStely字段
下面SetWindowLongPtrA设置的参数为-12(0xFFFFFFF4),即为设置子窗口的新标识符。但是需要注意这里注明了该窗口不能是顶级窗口
CVE-­2021­-1732 - 图19
而当函数xxxSetWindowLongPtr对应的第二个参数不为偏移,而是特殊的负数标记id时,将会进入到函数xxxSetWindowData函数中处理,也就是我们这里的情况
如下所示为对应的-12时的处理逻辑,可以看到这里会检测对应窗口的类型是否为WS_CHILD,如果是则将value(这里指向了我们构造的fakespmenu)设置到对应窗口内核对象的pwnd偏移0x98位置也就是spmenu。
再使用GWLP_ID时会保存原有的spmenu对象,保存下来用于循环EPROCESS链提权。
通过窗口1调用SetWindowLongPtrA将spmenu设置为fakespmenu(伪造的menu对象),用于实现任意地址读,这里将窗口1的dwStely修改为WS_CHILD将确保spmenu能进行设置,当fakespmenu设置成功之后,还会将该dwStely的类型还原,因为只有在原dwStely中,窗口1才能通过调用GetMenuBarInfo依赖fakespmenu进入到特定的错误代码的代码分支,以实现具体的读取操作,总结一句话就是通过窗口0的越界写能力将窗口0设置为错误类型,并附加错误的菜单对象,以此来实现任意地址读取。
image.png

构造fakespmenu

image.png
CVE-­2021­-1732 - 图22
CVE-­2021­-1732 - 图23
image.png
借助窗口1的fakespmenu,配合GetMenuBarInfo实现任意地址读取,这里首先第一次调用GetMenuBarInfo以获取对应var_OffsetrcBarleft偏移,之后第二次调用GetMenuBarInfo才是用于读取对应地址的数据
GetMenuBarInfo的函数原型如下所示
CVE-­2021­-1732 - 图25
具体参数如下所示,这里漏洞利用时idObject为0xFFFFFFFD,对应的item为1,最终结果则通过第四个参数返回
CVE-­2021­-1732 - 图26
第四个参数为MENUBARINFO,如下所示
CVE-­2021­-1732 - 图27
其中的rcBar为一个矩形结构
CVE-­2021­-1732 - 图28
这里通过idObject为0xFFFFFFFD可以看到在内核中对应函数为xxxGetMenuBarInfo,其核心为-3这个逻辑代码块。
该逻辑实际上是通过窗口内核对象处获取对应的spmenu,这里窗口1对应的spmenu指向了我们恶意构造的fakespmenu,并将该段内存映射到内核,之后通过var_OffsetrcBarleft字段中的数据和窗口内核对象的pwnd指定偏移0x58/0x5c处的数据做计算,并将结果返回对应的pmbi.rcBar这个rect矩形结构,而这个过程中由于fakespmenu中的数值是攻击者完全可控的,而pwnd指定偏移0x58/0x5c默认为0,这就导致通过这个操作可以进行任意地址读取操作,这里需要注意的是实际上dwstelye=WS_CHILD,才能设置对应的spmenu,而设置了spmenu,dwstelye=WS_CHILD的窗口正常情况下并不会进入到以下的代码分支,但是因为利用中在设置了窗口1的spmenu之后,又通过窗口0的越界写恢复了窗口1的dwStely,从而可以进入以下的错误代码分支,如下所示依次计算rect的四个坐标
读取函数中会尝试两次调用GetMenuBarInfo
第一次调用GetMenuBarInfo以获取计算时var_OffsetrcBarleft的偏移。

  1. v2 = LocalAlloc(0x40, 0x200);
  2. _mem_zeroinit_new200h = v2;
  3. for (int i=0;i<0x200/4;i++)
  4. {
  5. *(DWORD*)v2 = i * 4;
  6. v2 = (char*)v2 + 4;
  7. }
  8. *(ULONG_PTR*)_mem_zeroinit_8 = (ULONG_PTR)v2-0x200; //var_OffsetrcBarleft地址
  9. GetMenuBarInfo(__hWnd1, -3, 1, &_tagMenuBarInfo);
  10. error = GetLastError();
  11. v12 = _tagMenuBarInfo.rcBar.left;// 窗口菜单栏左上角的x坐标。
  12. _tagMenuBarInfo_rcBar_left = _tagMenuBarInfo.rcBar.left;

首先第一次读取如下所示,此时传入的pmbi如下所示
CVE-­2021­-1732 - 图29
30是第一个cbSize,后面接着的是rcet结构,按顺序是left、top、right、bottom
xxxGetMenuBarInfo中进入-3的代码逻辑,这里会首先判断对应dwStely,如果之前不将窗口0的dwStely恢复(回复前为4c),则不会进入之后的代码逻辑,之后获取fakespmenu对象,通过该fakespmenu,调用SmartObjStackRefBase::operator将其映射到内核中。如下所示可以看到对应的返回的fffffe0dd36df9e0指向了var_pmenu,之后就是后续构造的var_fakerect。
CVE-­2021­-1732 - 图30
判断GetMenuBarInfo第三个参数idItem是否大于poi(poi(var_fakerect+028)+0x2c),这里idItem为1,poi(poi(var_fakerect+028)+0x2c)在fakespmenu构造的时候将其设置为0x10,检测通过,之后获取var_fakeract偏移0的内容,并保存到返回的pmbi+0x18,依次检测var_fakeract偏移0x40,0x44处是否为0,这里利用代码在构造fakespmenu时也依次将这两个值域进行了设置。
接下来进行坐标计算
获取poi(poi(var_fakerect+0x58))处的var_OffsetrcBarleft,即下图中000002804f910150
CVE-­2021­-1732 - 图31
可以看到此时第一次的var_OffsetrcBarleft保存指针指向的数据是通过攻击者手动构造的
之后依次计算返回pmbi.rcBar的left; top; right; bottom;
Left=poi(var_OffsetrcBarleft+0x40)+ poi(poi(tagWND1+0x28)+0x58),即0x40+0=0x40
Right=left+ poi(var_OffsetrcBarleft+0x48) = 0x40+0x48 = 0x88
top=poi(var_OffsetrcBarleft+0x44)+ poi(poi(tagWND1+0x28)+0x5c),即0x44+0=0x44
Bottom = top + poi(var_OffsetrcBarleft+0x4c) =0x44+0x4c =0x90
内核返回的pmbi:image.png
也就是说pmbi.rcBar返回的矩形实际上是通过poi(var_OffsetrcBarleft)+0x40处0x10长度的内存数据(0x40的偏移由idItem=1决定,如果等于2应该为0x40+0x60=0xa0,所以利用中默认都通过idItem=1调用GetMenuBarInfo)配合窗口内核对象的pwnd+0x58/0x5c两个字段生成,由于这里pwnd+0x58/0x5c默认为0,因此直接依赖于poi(var_OffsetrcBarleft)+0x40处的0x10内存数据,而根据公式可以发现,pwnd+0x58/0x5c为0的情况下,left,top数据实际上就是poi(var_OffsetrcBarleft)+0x40/ poi(var_OffsetrcBarleft)+0x44这连续8个字节的内容,由于poi(var_OffsetrcBarleft)指向内容为攻击者可控,只需将其值设置为des-0x40(0x40的偏移根据idItem而不同),即可实现对des地址数据的读取。
第一次读取测试成功后返回的left正好就是var_OffsetrcBarleft的偏移
这也就是为什么第一次调用时var_OffsetrcBarleft指向内存如此构造的原因,实际上每一个4字节内存都是一个偏移,Left=poi(var_OffsetrcBarleft+0x40)+ poi(poi(tagWND1+0x28)+0x58), poi(poi(tagWND1+0x28)+0x58)=0,因此left中一定返回的是对应的偏移。
CVE-­2021­-1732 - 图33
进入第二次调用将目标读取地址减去pmbi.rcBar.left中返回的偏移值ffffff0680828050-0x40
= ffffff0680828010

此时更新过var_OffsetrcBarleft值后整体的fakespmenu内存如下所示var_OffsetrcBarleft指向了目标读取地址-0x40的位置。
CVE-­2021­-1732 - 图34
内核进入xxxGetMenuBarInfo,检验对应窗口的dwStely,映射对应的fakespmenu内存,如下所示left读取此时获取了目标地址指向内容的低四位。
Left=poi(var_OffsetrcBarleft+0x40)+ poi(poi(tagWND1+0x28)+0x58)= 0x8385a690+0=0x8385a690

CVE-­2021­-1732 - 图35
将目标地址保存的第四位内容保存到left中
获取对应的right,Right=left+ poi(var_OffsetrcBarleft+0x48) = 0x8385a690+0= 0x8385a690
top=poi(var_OffsetrcBarleft+0x44)+ poi(poi(tagWND1+0x28)+0x5c) = 0xffffff06+0 = 0xffffff06,正好是目标读取地址的高四字节。
Bottom = top + poi(var_OffsetrcBarleft+0x4c) =0xffffff06+0 =0xffffff06。
返回的pmbi:
image.png
通过left+top即可获取对应的目标地址内容。

  1. (ULONG_PTR(_tagMenuBarInfo.rcBar.top) << 32)+left

WriteQword()

之前已经通过在窗口2使用xxxSetWindowLong,将窗口0的cbwndExtra字段设置为0xfffffff,以此获取越界写入的能力。
image.png
窗口0通过越界写 调用SetWindowLongPtrA修改窗口1的pExtraBytes为目标写入地址也就是当前的Token值
此时通过窗口1直接调用xxxSetWindowLongPtr,并将参数index设置为0,将直接完成对当前pExtraBytes(当前进程token)地址的写入操作

提权

  1. LONG_PTR ptagDesktop = ReadQword(old_spmenu + 0x50); // tagMENU
  2. if (!ptagDesktop)
  3. {
  4. //Clear();
  5. return 0;
  6. }
  7. LONG_PTR rpdeskNext = ReadQword(ptagDesktop + 0x18); // tagMenu.tagDesktop(tagDESKTOP)
  8. if (!rpdeskNext)
  9. {
  10. //Clear();
  11. return 0;
  12. }
  13. tagWin32Heap_ = ReadQword(rpdeskNext + 0x80); // tagDesktop.pheapDesktop(tagWIN32HEAP)
  14. if (!tagWin32Heap_)
  15. {
  16. //Clear();
  17. return 0;
  18. }
  19. LONG_PTR ptagThreadInfo = ReadQword(ptagDesktop + 0x10);// tagMenu.head.pti(tagTHREADINFO)
  20. if (!ptagThreadInfo)
  21. {
  22. //Clear();
  23. return 0;
  24. }
  25. LONG_PTR pKThread = ReadQword(ptagThreadInfo); // tagTHREADINFO.pEThread(_ETHREAD)
  26. if (!pKThread)
  27. {
  28. //Clear();
  29. return 0;
  30. }
  31. LONG_PTR pEProcess = ReadQword(pKThread + 0x220); // KTHREAD.EProcess
  32. if (!pEProcess)
  33. {
  34. //Clear();
  35. return 0;
  36. }
  37. LONG_PTR pEProcessOrigin = pEProcess;

获取到System的Token后,使用任意写将自己进程的Token替换为System的Token 既可以完成提权。

参考文献

【1】https://paper.seebug.org/1574/
【2】https://saturn35.com/2021/03/16/20210316-1/#more
【3】https://ti.dbappsecurity.com.cn/blog/index.php/2021/02/10/windows-kernel-zero-day-exploit-is-used-by-bitter-apt-in-targeted-attack-cn/
【4】https://ti.qianxin.com/blog/articles/elevation-of-privilege-bug%20(CVE-2021-1732)-analysis/-analysis/)
【5】https://www.yuque.com/posec/public/qvzr6g