Win32k 的漏洞利用 – CVE-2023-29336 详细分析
原文链接: https://mp.weixin.qq.com/s?__biz=MzAxODM5ODQzNQ==&mid=2247489423&idx=1&sn=f3712506893025290c1cd8f1459d5d32
Win32k 的漏洞利用 – CVE-2023-29336 详细分析
immortalp0ny securitainment 2025-07-12 06:52
Win32k that we lost. In details writeup about CVE-2023-29336
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
引言
本文最初写于两年前,现在终于有时间将其翻译并正式发布。让我们回顾一下在 Win32k 还没有垃圾回收机制时的漏洞利用故事——也许当你读到这篇文章时,Win32k 已经用 Rust 重写了。本次漏洞利用的实现离不开 NumenCyber 的文章,它提供了构建可靠触发机制所需的关键菜单布局信息。所有实验均在较旧的
Windows 10 1607 build 10.0.14393.5850 amd64
系统上进行。
补丁对比
补丁对比文件来自 WinBinIndex。下表展示了我们将要对比的内容。
|
|
|
---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
通过 BinDiff 对 win32kfull 的对比结果非常清晰。只有一个函数被修改。
BinDiff 对比结果
根本原因分析
漏洞函数是
xxxEnableMenuItem
。在该函数内部,存在一个回调函数,它在没有对
MenuItemState
函数返回的
tagMENU
类型对象进行引用计数的情况下被调用。这使攻击者能够达到 Use-After-Free 条件。
xxxEnableMenuItem 补丁前后的反编译代码
在深入技术细节之前,让我们先讨论一下回调函数的一般性问题。回调函数 是一种常见的编程模式,它通过在通用算法(或主逻辑)的特定扩展点执行用户定义的函数来实现可扩展性。这种模式引入了挑战,特别是在共享资源的信任和所有权方面——无论是在回调函数被调用之前还是控制权返回到主执行流之后。当回调函数跨越信任边界时,这些挑战变得更加复杂——例如在内核模式和用户模式之间转换,或者跨网络。
回调函数的工作原理
有几种方法可以缓解这些风险:创建状态的单独副本传递给回调函数(并避免在主逻辑中重用)、使用引用计数、资源锁定等。每种方法在复杂性、性能和安全性方面都有其权衡。
在本例中,
tagMENU
类型支持引用计数。引用计数 是一种防止对象实例在仍在使用时被释放的机制——只要某些代码持有对它的引用。其思想很简单:每当代码开始使用受保护对象时,必须增加引用计数;当使用完毕时,减少引用计数。关键点在于引用计数必须在对象的整个生命周期中保持一致。如果计数器不同步,引用计数将无法提供任何保护。
主要缺点很明显:如果程序员在使用对象之前忘记增加引用计数,那么从系统的角度来看,该代码实际上从未使用过该对象。
当涉及回调函数时,这会打开一个危险的场景。例如,回调函数可以释放对象,然后用完全不相关的对象填充已释放的内存——所有这些都在原始函数恢复执行之前完成。当流程返回到主逻辑并再次接触原始指针时… 💥
这就是典型的 Use-After-Free
漏洞。
现在你可能会问:xxxEnableMenuItem中的回调在哪里
。就在添加引用计数增加的位置之前。现在看到了吗?如果没看到也不用担心——我已经准备了一个快速说明。
微软内部使用 特定 的 命名 前缀来表示其函数,这些前缀通常具有特殊含义。大多数情况下,前缀反映了函数所属的子系统。但也有其他约定。一个特别重要的前缀是 xxx
,它通常表示在该函数内部存在 对用户模式的回调
。
所以是的——
xxxEnableMenuItem
内部确实存在一个回调函数,而在此之前没有进行适当的引用计数就是这个漏洞的核心。
以上就是该漏洞的根本原因分析。现在,让我们继续讨论漏洞利用过程。
触发机制
首先,我们需要了解
tagMENU
结构是什么,以及
MenuItemState
函数的作用。然后找出如何进入用户模式并设置一切以触发漏洞。
什么是 tagMENU?
tagMENU
是 Windows 用于实现 菜单 UI 的内部结构。不幸的是,其内存布局没有官方文档。不过,我们可以从 ReactOS 和 Windows XP 泄露的源代码中找到线索。
在
Windows 10.0.14393.5850 amd64
上逆向工程
tagMENU
的实际布局后,它看起来是这样的:
struct MyTagMenu_Win10x64_98h
{
MyHead_win10x64 head; // 00000000
MyProcDeskHead deskhead; // 0000000C
__int32 fFlags; // 00000028
__int8 gap1[4]; // 0000002C
__int32 cAllocated; // 00000030
__int32 cItems; // 00000034
__int32 cxMenu; // 00000038
__int32 cyMenu; // 0000003C
__int8 gap2[8]; // 00000040
MyTagWnd_win10x64_168h *wnd; // 00000048
MyTagItem_Win10x64_98h *rgItems; // 00000050
__int64 pParentMenusList; // 00000058
__int32 dwContextHelpID; // 00000060
__int32 field_64; // 00000064
__int64 dwMenuData; // 00000068
__int8 gap3[20]; // 00000070
__int64 field_84; // 00000084
__int64 field_8C; // 0000008C
__int32 field_94; // 00000094
};
MenuItemState 函数的作用
MenuItemState
函数负责通过 uID
定位特定的菜单项。在内部,它使用一个名为
MNLookupItem
的递归辅助函数来执行实际的查找操作。
以下是
MNLookupItem
的实现片段:
MNLookupItem 实现
进入用户模式
如前所述,
xxxRedrawTitle
包含对用户模式的回调。但要到达该点,首先必须满足
xxxEnableMenuItem
中的几个检查。
第一个检查是传递给
MenuItemState
的第一个参数的菜单必须是系统菜单。系统菜单通过 API GetSystemMenu 获取。
第二个检查是返回的 v15
变量中的菜单项标识符是否匹配系统保留的 ID。这些标识符通常被系统菜单中的默认项(如“还原”、“移动”、“大小”等)占用。
这里有个技巧:删除默认菜单项没有任何限制。这意味着我们可以删除默认菜单项并插入我们自定义控制的菜单项,将其放置在菜单树中的任意位置。
xxxEnableMenuItem 中的检查
现在,收集所有先决条件后,我们应该创建以下菜单布局:
菜单布局
为什么选择 MenuA 作为 UAF 目标?
你可能会问:为什么选择 MenuA 作为 Use-After-Free 的目标?
让我们回顾一下补丁代码和
MNLookupItem
的实现。
v15
变量持有包含系统保留 ID 的 菜单项
的子菜单指针。这就是为什么 MenuA
被选中 —— 它是
v15
直接引用的子菜单。
关键问题在于:
MenuA
在回调被调用之前没有增加其引用计数。
xxxRedrawTitle 中的用户模式回调
现在让我们看看
xxxRedrawTitle
。该函数中有三个不同的代码路径允许执行进入用户模式:
1. 通过
xxxDrawCaptionBar
深入
xxxCallHook
调用用户模式钩子。这些钩子通过 API SetWindowsHookExA 设置
xxSendMessage
是最简单直接的路径 —— 也是我使用的路径
xxxRedrawTitle
要接收
WM_NCUAHDRAWCAPTION
消息,我们需要为目标 UAF 菜单的窗口创建一个自定义的 WndProc
。该消息将在重绘期间分派到窗口过程,允许我们在回调中执行代码 —— 并在恰当时刻销毁菜单。
释放菜单的代码可以在以下片段中看到:
LRESULT CALLBACK wndproc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch(msg) {
case WM_NCUAHDRAWCAPTION: {
wprintf(L"[?] wndproc: msg=WM_NCUAHDRAWCAPTION wParam=%x lParam=%x\n", wParam, lParam);
for (int i = GetMenuItemCount(g_hMenu_Top) - 1; i >= 0; i--) {
RemoveMenu(g_hMenu_Top, i, MF_BYPOSITION);
}
wprintf(L"[+] 5. Destroy Menu\n");
system("pause");
DestroyMenu(g_hPopupMenu_A); // Here MenuA will be freed
...
break;
}
}
return DefWindowProc(hWnd, msg, wParam, lParam);
}
调试过程
我们的调试目标是 GitHub 上提供的漏洞利用 PoC (Proof of Concept),具体代码可在 GitHub 查看。
首先,我们需要找到 PoC 进程的地址并切换到其上下文:
kd> !process 0 0 poc.exe
PROCESS ffffcc0e6b8f6800 <--> EPROCESS
SessionId: 1 Cid: 0c30 Peb: 5858bd5000 ParentCid: 014c
DirBase: 2cf00000 ObjectTable: ffff94044295ad80 HandleCount: <Data Not Accessible>
Image: poc.exe
kd> .process /i /r ffffcc0e6b8f6800
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
kd> g
由于 Win32k 并未映射到系统进程 (system process) 中,我们需要执行上述命令来切换到正确的会话上下文 (session context)。Win32k 仅映射到处理 GUI 的进程中。关于系统进程内部映射的更多细节,可以参考微软的这篇经典文章 article。
切换上下文后,建议重新加载符号 (reload symbols):
kd> .reload
在
win32kfull!xxxEnableMenuItem
设置断点并继续执行:
kd> bp /p ffffc70664bac680 win32kfull!xxxEnableMenuItem
kd> g
当断点命中后,单步执行直到到达
win32kfull!MenuItemState
函数调用。最后一个参数将包含指向 MenuA
实例的指针 —— 这就是我们 UAF (Use-After-Free) 漏洞的目标对象。
kd> p
rax=ffffa0817b949ab0 rbx=fffffa8ac064c6b0 rcx=fffffa8ac064c6b0
rdx=000000000000f010 rsi=0000000000000002 rdi=000000000000f010
rip=fffffac7010196ae rsp=ffffa0817b949a50 rbp=ffffa0817b949b80
r8=0000000000000002 r9=0000000000000003 r10=fffffa8ac1b5b870
r11=ffffa0817b949aa8 r12=00000000000204b6 r13=00000000000104ab
r14=0000000000000020 r15=fffffa8ac0629770
iopl=0 nv up ei ng nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000286
win32kfull!xxxEnableMenuItem+0x2a:
fffffac7`010196ae e801010000 call win32kfull!MenuItemState (fffffac7`010197b4)
kd> ? poi(r11-0x38)
Evaluate expression: -104996992148816 = ffffa081`7b949ab0
kd> dq ffffde00877b0ab0 L1
ffffde00`877b0ab0 00000000`000104af <---> Uninitialized value
kd> p
rax=0000000000000000 rbx=ffffd7d74062a5f0 rcx=0000000000000002
rdx=000000000000f010 rsi=0000000000000002 rdi=000000000000f010
rip=ffffd7aa3ac796b3 rsp=ffffde00877b0a50 rbp=ffffde00877b0b80
r8=0000000000000000 r9=ffffde00877b0ab0 r10=ffffd7d74062e890
r11=0000000000000003 r12=00000000000204d0 r13=00000000000104af
r14=0000000000000020 r15=ffffd7d74062cfe0
iopl=0 nv up ei ng nz na pe nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000282
win32kfull!xxxEnableMenuItem+0x2f:
ffffd7aa`3ac796b3 f7432800010000 test dword ptr [rbx+28h],100h ds:002b:ffffd7d7`4062a618=04000101
kd> dq ffffde00877b0ab0 L1
ffffde00`877b0ab0 ffffd7d7`4062cfe0
kd> db ffffd7d7`4062cfe0
\/
ffffd7d7`4062cfe0 73 00 03 00 00 00 00 00-01 00 00 00 00 00 00 00 s...............
ffffd7d7`4062cff0 00 00 00 00 00 00 00 00-70 1d 45 64 06 c7 ff ff ........p.Ed....
ffffd7d7`4062d000 e0 cf 62 40 d7 d7 ff ff-01 00 00 00 00 00 00 00 ..b@............
ffffd7d7`4062d010 08 00 00 00 02 00 00 00-00 00 00 00 00 00 00 00 ................
ffffd7d7`4062d020 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffd7d7`4062d030 00 e8 62 40 d7 d7 ff ff-50 2d 60 40 d7 d7 ff ff [email protected]`@....
ffffd7d7`4062d040 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffd7d7`4062d050 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
在上述内存转储中,我们可以看到与 MenuA
对象关联的引用计数器。它存储在对象起始位置偏移 +0x08
处。当前值为 1,且我们知道该值 不会被递增
—— 这意味着该对象在使用时存在被释放的风险(Use-After-Free 漏洞)。
继续单步执行,直到到达
win32kfull!xxxRedrawTitle
函数调用。PoC (Proof of Concept) 已经设置了所有必要条件 —— 并不复杂,只是基本的 Windows API 使用。
kd> p
rax=ffffa0817b949a80 rbx=fffffa8ac064c6b0 rcx=fffffa8ac064c4a0
rdx=0000000000001000 rsi=0000000000000002 rdi=000000000000f010
rip=fffffac701019758 rsp=ffffa0817b949a50 rbp=0000000000000000
r8=0000000000000000 r9=ffffa0817b949ab0 r10=fffffa8ac06299a0
r11=0000000000000003 r12=00000000000204b6 r13=00000000000104ab
r14=0000000000000020 r15=fffffa8ac0629770
iopl=0 nv up ei pl nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000206
win32kfull!xxxEnableMenuItem+0xd4:
fffffac7`01019758 e853e30000 call win32kfull!xxxRedrawTitle (fffffac7`01027ab0)
在调用后设置硬件断点,并在
win32kfull!DestroyMenu
处设置另一个断点,MenuA
将在此处被释放:
kd> ba e 1 ffffd7aa`3ac7975d
kd> bp /p ffffc70664bac680 win32kfull!DestroyMenu
kd> g
当回调触发并尝试释放菜单时,
win32kfull!DestroyMenu
将会触发断点 (breakpoint)。
kd> g
Breakpoint 2 hit
rax=0000000000000001 rbx=0000000000000000 rcx=ffffd7d74062cfe0
rdx=0000000000000001 rsi=0000000000000000 rdi=0000000000000020
rip=ffffd7aa3ac96d20 rsp=ffffde008616de08 rbp=ffffde008616dec0
r8=0000000000000002 r9=0000000000000040 r10=ffffd7d743d997c0
r11=ffffd7d743d997c0 r12=00000000000204d0 r13=00000000000000ae
r14=00000000000204d0 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000246
win32kfull!DestroyMenu:
ffffd7aa`3ac96d20 48895c2410 mov qword ptr [rsp+10h],rbx ss:0018:ffffde00`8616de18=0000000000000000
单步执行直到到达
win32kbase!HMFreeObject
函数,此时
RCX
寄存器仍指向 MenuA
对象。
rax=0000000000000000 rbx=ffffd7d74062cfe0 rcx=ffffd7d74062cfe0 <------- MenuA
rdx=0000000000000000 rsi=ffffd7d74062e920 rdi=0000000000000000
rip=ffffd7aa3aa3ee20 rsp=ffffde008616ddd8 rbp=ffffde008616dec0
r8=0000000000000080 r9=0000000000000001 r10=0000000000000003
r11=0000000000000001 r12=00000000000204d0 r13=00000000000000ae
r14=00000000000204d0 r15=0000000000000000
iopl=0 nv up ei ng nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000286
win32kbase!HMFreeObject:
ffffd7aa`3aa3ee20 48895c2410 mov qword ptr [rsp+10h],rbx ss:0018:ffffde00`8616dde8=ffffd7d74062e920
继续执行后,你会命中
nt!RtlFreeHeap
,该函数实际执行内存释放操作。此时
R8
寄存器保存着被释放内存块的基地址:
rax=ffffd7d743d99701 rbx=ffffd7d740400ac8 rcx=ffffd7d740600000
rdx=0000000000000000 rsi=0000000000000000 rdi=ffffd7d74062cfe0
rip=ffffd7aa3aa3ef82 rsp=ffffde008616dda0 rbp=ffffde008616de12
r8=ffffd7d74062cfe0 r9=0000000000000001 r10=0000000000000003
r11=0000000000000001 r12=00000000000204d0 r13=00000000000000ae
r14=0000000000000001 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000246
win32kbase!HMFreeObject+0x162:
ffffd7aa`3aa3ef82 ff1500820f00 call qword ptr [win32kbase!_imp_RtlFreeHeap (ffffd7aa`3ab37188)] ds:002b:ffffd7aa`3ab37188={nt!RtlFreeHeap (fffff803`4683bfd4)}
继续执行直到触发回调后的硬件断点 —— 此时我们回到了
win32kfull!xxxEnableMenuItem
函数中,但 MenuA
对象已被释放
。
kd> g
Breakpoint 1 hit
rax=0000000000000001 rbx=ffffd7d74062a5f0 rcx=ffffd7d74062a3e0
rdx=ffffd7d740600820 rsi=0000000000000002 rdi=000000000000f010
rip=ffffd7aa3ac7975d rsp=ffffde00877b0a50 rbp=0000000000000000
r8=ffffd7d740600700 r9=ffffc70664451d70 r10=000000032ca29f71
r11=ffffde00877b0640 r12=00000000000204d0 r13=00000000000104af
r14=0000000000000020 r15=ffffd7d74062cfe0
iopl=0 nv up ei ng nz na pe nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000282
win32kfull!xxxEnableMenuItem+0xd9:
ffffd7aa`3ac7975d 81ff60f00000 cmp edi,0F060h
在触发之前设置的断点后,继续单步执行代码直到到达
win32kfull!MNGetPopupFromMenu
函数调用处。
kd> p
rax=ffffd7d74062a3e0 rbx=ffffd7d74062a5f0 rcx=ffffd7d74062cfe0
rdx=0000000000000000 rsi=0000000000000002 rdi=000000000000f010
rip=ffffd7aa3ac796ca rsp=ffffde00877b0a50 rbp=0000000000000000
r8=ffffd7d740600700 r9=ffffc70664451d70 r10=000000032ca29f71
r11=ffffde00877b0640 r12=00000000000204d0 r13=00000000000104af
r14=0000000000000020 r15=ffffd7d74062cfe0
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000246
win32kfull!xxxEnableMenuItem+0x46:
ffffd7aa`3ac796ca e835e20100 call win32kfull!MNGetPopupFromMenu (ffffd7aa`3ac97904)
RCX
寄存器包含我们之前在调用
win32kfull!MenuItemState
后观察到的相同指针。让我们再次 dump 该对象的内容 —— 这次我们会发现内存已经发生了变化。
kd> db ffffd7d74062cfe0
ffffd7d7`4062cfe0 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
ffffd7d7`4062cff0 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
ffffd7d7`4062d000 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
ffffd7d7`4062d010 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
ffffd7d7`4062d020 41 41 41 41 41 41 40 30-30 30 30 00 00 00 00 00 AAAAAA@0000.....
ffffd7d7`4062d030 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffd7d7`4062d040 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffd7d7`4062d050 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
那么这是如何发生的?我们的回调不仅释放了内存 —— 还将其重新分配为可控数据。
让我们深入探讨下一个问题:如何用可控数据替换内存中已释放的内核对象?
漏洞利用 (Exploit)
堆风水 (Heap Feng Shui)
在深入探讨本漏洞利用中使用的堆风水技术之前,让我们先回顾一些核心概念。
在 Win32k 中,大多数 GUI 相关对象都与特定的 桌面 (Desktop) 相关联。这些对象从 桌面堆 (Desktop Heap)
中分配,该堆在桌面初始化时创建。这个堆由 nt
实现 (
RtlpAllocateHeap
/
RtlpFreeHeap
) 管理。我们可以将桌面堆大致想象为一个按升序排列的空闲内存块列表。每个块都是一个固定大小的虚拟内存块。当进行分配时,从列表中取出一个块;当释放时,该块被返回到列表中。分配器的关键细节:
1.
RtlpAllocateHeap
-
将请求的大小对齐到 16 字节边界
-
在空闲列表中搜索第一个足够大的块
-
如果找到的块比需要的大,则将其分成两部分。左部分返回给调用者,右部分重新插入空闲列表
RtlpFreeHeap
-
将块标记为已释放
-
如果相邻块(左/右)也是空闲的,则可能将它们合并成一个更大的块
从这种行为中,我们可以得出一个简单的结论:相同大小的后续分配和释放几乎总是会重用相同的内存块
。
这种重用行为对于可靠地回收已释放对象至关重要 —— 这也是我们漏洞利用策略的基础。
⚠️ 警告 显然,这是一个过度简化的描述。在许多其他情况下,可能还需要考虑许多分配器行为 —— 如 LFH(低碎片堆)、块头编码、随机选择等。但为了清晰起见,我尽可能简化了这部分内容。
在采取行动之前,我们需要一个计划:
1. 准备桌面堆,特别是空闲块列表。我们希望这个列表以位于可控或可预测地址的块开始,并且大小尽可能接近目标对象的大小。我们将这些候选块称为 “座位 (seats)”
。
-
在准备好的座位中分配目标对象。粗略地说,我们想要将对象 “就座”
到正确的位置。 -
释放目标对象,在已知位置创建一个空洞。
-
用可控对象重新分配所有 “座位”
,以替换已释放的对象。
现在我们有了计划,可以详细讨论每个步骤了。
堆准备通常包括两个步骤:堆规范化 (Heap Normalization)
和 释放选定块 (Freeing Selected Chunks)
。
1. 分配大量内存块以规范化堆布局。目标是使后续分配更加可预测 —— 甚至严格按顺序进行。
- 分配另一批块,并使用适当的 API 有选择地释放其中一些块。大多数情况下,我们希望避免释放相邻块以防止合并。我们的目标是创建孤立的空闲块 —— “座位”
—— 这些块稍后将用于回收目标对象。
在准备任何座位之前,我们显然需要先分配一些东西。但要做到这一点,我们需要回答一个重要问题:我们的座位应该是什么大小?
因为堆分配器 (
RtlpAllocateHeap
) 会选择适合对齐分配大小的最小空闲块。所以我们的座位大小越接近目标对象的大小,该块在重新分配期间被重用的几率就越高。当然,最理想的情况是座位大小正好等于目标对象的对齐大小。
这里是一个计算
tagMENU
块实际大小的好地方。结构体的大小是 0x98
,但
RtlpAllocateHeap
会将其对齐到 0xA0
。
作为复习,这是典型的对齐公式:
(size_t)((~(n - 1)) & ((x) + (n - 1)))
由于没有直接的方法在 桌面堆
中分配任意块,我们需要使用在堆上内部分配 Win32k
对象的 API。我们的目标是:使用大小可控的对象,或者选择大小与目标 (
tagMENU
) 大小密切匹配的对象。幸运的是,Win32k(以及用户模式 Win32 API)提供了许多选项。许多暴露的 API 都会在 桌面堆
中创建对象:
–
CreateWindow
→ 分配
tagWND
CreateMenu
→ 分配
tagMENU
CreateAcceleratorTable
→ 分配
tagACCELTABLE
- …
为了为目标
tagMENU
对象创建空洞,我们将使用由
RegisterClassExW
API 分配的对象 —— 该 API 会创建
tagCLS
结构。
这个对象特别有用,因为我们可以控制它的大小。通过仔细调整
WNDCLASSEXW
结构中的字段 —— 特别是
cbClsExtra
—— 我们可以微调生成的
tagCLS
对象的布局,使其完美匹配
tagMENU
的释放块。
在测试的 Windows 版本 (
10.0.14393.5850
) 上,
cbClsExtra == 0
时
tagCLS
对象的大小为
A0h
,这几乎完美匹配
tagMENU
结构的大小。
然而,关于
tagCLS
对象的创建有一个重要的细节:每个
tagCLS
都必须有一个名称,该名称在
WNDCLASSEXW
结构的
lpszClassName
字段中指定 —— 而且该名称必须是唯一的。
这个名称字符串也分配在同一个 桌面堆
中。因此,
tagCLS
和它的名称可能会分配在相邻的块中。当
tagCLS
被释放时,其关联的名称也会被释放,分配器会将这两个块合并成一个更大的空闲块。
这可能是个问题:生成的空闲块可能不再是
A0h
或
B0h
,而是更大的
D0h
或更多。这打破了我们的假设,即座位将完美匹配目标对象的大小 —— 即使是很小的间隙也可能破坏漏洞利用。
💥 在实际漏洞利用过程中,这种行为偶尔会导致崩溃。
幸运的是,通过大量分配来饱和堆空间可以缓解这个问题,提高完美匹配的几率。但这并不能完全消除问题。
有问题的堆风水
下图从空闲列表 (freelist)的角度展示了相应的情况。
空闲列表转储
现在是时候重点介绍实际分配对象
和创建空洞
的代码了。
这部分漏洞利用基于一个假设:桌面堆 (Desktop Heap)已经被规范化,意味着新的分配将被放置在桌面堆
中的相邻块中。这为我们提供了所需的可预测性,确保释放的对象——空洞——正好位于我们想要的位置。
座位准备
让我们继续下一步。如何用新的 tagMENU 对象填充释放的空洞
。严格来说,这部分并不困难。我们将再次依赖Win32k
API——这次使用
CreatePopupMenu
函数。
但与
tagCLS
的情况类似,有一个值得指出的微妙细节。
tagMENU
对象可能包含_菜单项 (menu items)_,而这些_项_的内存必须被分配。正如你可能猜到的,这些内存与
tagMENU
本身分配在同一个桌面堆
中。
下图展示了项分配的内部过程:
为 tagMENU 项分配内存
下图展示了
CreatePopupMenu
之后的内存布局——特别是菜单项的分配位置:
tagMENU 项分配的内存布局
由于我们之前创建的空洞与周围块是隔离的,
tagMENU
对象本身将被直接分配到这些空洞之一中。然而,菜单项 (rgItems
) 的内存将被分配到其他地方——很可能是在桌面堆
的不同空闲块中。
这种分离很重要。它意味着菜单对象落在我们想要的位置(替换释放的目标),而其内部数据结构不会与相邻内存相交——保持了周围堆的完整性,提高了漏洞利用的可靠性。
在触发(我们之前已经讨论过)之后,易受攻击的
tagMENU
对象被释放。现在是时候用其他完全受我们控制的东西来回收这个释放的内存了。
但是我们应该使用什么类型的对象来替换
tagMENU
呢?
我们将回到使用一个熟悉的结构:
tagCLS
。然而,这次我们不会使用
tagCLS
结构本身——而是将目标对准类名字段
lpszAnsiClassName
。
如前所述,这个字符串分配在同一个桌面堆
上,与结构本身不同,我们可以控制每个字节的任意偏移。这使其成为构建伪造对象布局
的完美候选。
💡 我们不在这里使用完整的 tagCLS 结构的原因是我们无法直接控制其较低的偏移——至少不容易。(想想像 SetClassLongPtr 这样的 API。剧透:我们稍后会使用这个技巧。)
让我们总结一下替换策略。
要回收释放的
tagMENU
块,我们需要创建一个正好
98h
字节的字符串,匹配
tagMENU
的大小。这个字符串也必须是唯一的,以满足Win32k
要求每个
tagCLS
名称唯一的要求。
然后,我们通过
RegisterClassExW
注册新的
tagCLS
时,将该字符串用作
lpszAnsiClassName
字段。
这里还有一个微妙之处:由于
tagCLS
结构本身与
tagMENU
的大小非常相似,我们需要确保它不会意外地落在释放的块中。因此,为了避免冲突并确保名称字符串落在那里,我们使用
WNDCLASSEX
结构的
cbClsExtra
字段来增加
tagCLS
的大小。
💡 你可能会问——为什么使用 lpszAnsiClassName 而不是 WNDCLASSEX 中的其他字符串?例如,lpszMenuName 看起来也是可控的。但这里有个问题:该字符串不是分配在桌面堆上。相反,它是使用 ExAllocatePoolWithQuotaTag 分配的,这将其放入池内存中——这不是我们想要的。
在下图中,你可以看到结果——所有之前的空洞和释放的
tagMENU
都已被来自不同
tagCLS
实例的受控
lpszAnsiClassName
字符串成功回收:
tagCLS.lpszAnsiClassName 替换 MenuA 时的内存布局
在下图中,你可以看到负责用我们控制的数据回收释放的
tagMENU
块的代码。
tagMENU 替换
这是结果。
下面的内存转储显示了回收的对象正好位于
tagMENU
之前所在的位置。你可能从文章开头的调试部分认出了这一点——这是我们在用户模式回调触发释放后看到的类似的被覆盖的内存转储:
对象被替换时的 tagMENU 内存转储
这总结了与堆风水和精确内存布局控制相关的所有内容。
读写操作
该漏洞利用的最终目标是提升权限。有几种方法可以实现这一点,但在本文中,我们将使用一种经典技术:令牌窃取 (Token Stealing)。简单来说,我们希望用系统
进程(PID 4
)的
_TOKEN
替换当前进程
_EPROCESS
结构中的
_TOKEN
。这将使我们的进程继承系统
进程的_完全权限_。
注意操作的含义:这是一个替换
。
替换可以分解为两个基本操作:
– 读取:从系统
_EPROCESS
中读取
_TOKEN
指针
- 写入:将该指针写入我们自己的进程的
_EPROCESS
结构
在本节中,我们将构建读写原语——漏洞利用的基本构建块。
欺骗系统
为了实现写能力,我们将再次依赖熟悉的Win32k
对象:
tagCLS
和
tagWND
。
tagCLS
结构包含一个特别有趣的字段
cbClsExtra
。该字段定义了在
tagCLS
对象之后保留的额外字节数。这个额外空间旨在允许第三方应用程序存储自定义数据。
Windows 公开了两个用于访问此区域的_API_:
– SetClassLongPtr
- GetClassLongPtr
这些函数允许用户模式应用程序根据
cbClsExtra
的值读取和写入
tagCLS
对象之后的内存。
如果我们能够操纵
cbClsExtra
,我们就可以欺骗系统,使其认为对象之后有大量额外内存。从那里,使用
SetClassLongPtr
执行
越界(out-of-bounds)
写入,远远超出原始对象边界——为我们提供了一个强大而灵活的写原语。
那么我们如何实际触发使用释放的对象呢?
我们按照设计使用易受攻击的代码:在执行流从用户模式返回(对象被释放和替换的地方)后,内核继续使用原始指针——现在指向一个完全受控的伪造对象。
这是拼图的最后关键部分。
在
xxxEnableMenuItem
结束时,(现在已过时的)对象作为参数传递给
MNGetPopupFromMenu
函数。该函数通过遍历存储在
tagMENUSTATE
中的两个链表来搜索相应的
tagPOPUPMENU
结构(
tagMENUSTATE
存储在
UserThreadInfo
中。
UserThreadInfo
可通过目标
tagMENU
的父
tagWND
访问)。如果搜索成功,该函数返回一个指向
tagPOPUPMENU
实例的指针。
MNGetPopupFromMenu 代码
从
MNGetPopupFromMenu
返回后,它返回的对象被传递回
xxxEnableMenuItem
,然后作为第一个参数转发给
xxxMNUpdateShownMenu
函数。
在
xxxMNUpdateShownMenu
内部,只访问返回对象的两个字段:
spwndPopupMenu
spmenu
在这个阶段,两者都是只读的。
spwndPopupMenu
字段被传递给
xxxScrollWindowEx
和
xxxInvalidateRect
。
xxxScrollWindowEx
不会修改
spwndPopupMenu
中的任何内容,最终会到达与
xxxInvalidateRect
相同的接收器:调用
xxxRedrawWindow
。
现在是关键部分。
在
xxxRedrawWindow
内部,
spwndPopupMenu
对象被用于写操作。在结构的
+0x120
偏移处执行与常量
02h
的按位OR
操作。
以下是相关反汇编对应的伪代码:
Pseudo code of write operation
这正好给了我们需要的功能。
通过构造一个伪造对象 (fake object)
,使其
spwndPopupMenu
字段指向目标
tagCLS
结构中的
cbClsExtra
字段,我们可以让系统对该值执行OR
操作,操作数为
02h
。结果
cbClsExtra
会变得比原来更大。
这给了我们想要的能力——使用
SetClassLongPtr
在
tagCLS
结构原始边界之外进行写入,将其转化为一个写原语 (write primitive)。虽然这还不是一个完全_任意写 (arbitrary write)_——我们将在下一步解决这个问题。
要将写操作中使用的field_120
指针指向我们伪造的
tagCLS
对象中的
cbClsExtra
字段,我们首先需要知道它的内核地址 (kernel address)
。没有这个地址,我们就无法正确定位写入位置或扩展我们的原语。
为了解决这个问题,我们将使用基于Win32k
内部函数HMValidateHandle的知名且有效的技术。
现在是将我们的相对写原语转化为完全任意写的时候了。
为此,我们需要在桌面堆 (Desktop Heap)上创建特定的内存布局:
– 一个
tagCLS
对象(我们称之为 Manager)放置在两个 tagWND 对象之间
- 左侧的
tagWND
成为我们的LeftGuard
- 右侧的
tagWND
成为我们的RightGuard
为了实现这一点,我们将执行以下操作:
1. 创建一个
tagCLS
,我们将用它来创建
tagWND
对象。我们称这个类为
GuardClass
。
- 使用
CreateWindowEx
和
GuardClass
分配 256 个
tagWND
对象。这些窗口将填充堆并帮助我们找到连续的内存位置。
- 找到三个在内存中连续分配的
tagWND
对象。为了验证它们是否占据相邻的内存块,我们使用HMValidateHandle技术。这使我们能够泄露
tagWND
实例的内核地址,并检查它们是否在桌面堆
中连续放置。
-
第一个将成为 LeftGuard
-
第三个将成为 RightGuard
-
第二个使用 DestroyWindow 释放——创建一个空洞
-
创建一个新的
tagCLS
,其大小等于之前释放的
tagWND
的大小。这个新类将被分配到空洞中,成为我们的Manager
。
- 释放步骤 2 中所有未使用的
tagWND
窗口,只保留LeftGuard
和RightGuard
。
- 使用与Manager
关联的类创建一个新的
tagWND
。这个新窗口为我们提供了一个可以与
SetClassLongPtr
一起使用的句柄,该句柄与中间的
tagCLS
绑定。我们称这个最终窗口为WND Manager
。
tagWND
的大小必须大于
90h
字节(可以通过GuardClass
的
cbwndExtra
字段实现)。这很关键,因为我们稍后将使用此区域绕过Win32k
中的一些内部检查,以完成写原语。
我们应该传递给
spwndPopupMenu
的偏移量是
VA-of-Manager + 63h - 120h
60h
因为这是
cbClsExtra
的偏移量
03h
因为我们想要修改存储的 dword 的最高位
120h
因为这是
field_120
的偏移量
现在是准备伪造对象的时候了,它将允许我们覆盖 Manager 中的 cbWndExtra 字段。
Pseudo code of write operation
原始
tagMENU
占用的内存现在被我们控制的数据替换。我们通过
tagCLS
的
lpszAnsiClassName
字段控制此内存,如
堆风水(Heap Feng Shui)
部分所述。
现在我们准备好继续并完成最终的原语。
任意读原语 (Arbitrary Read)
我们首先实现读原语,因为它将在后续的任意写操作中使用。
该原语利用
GetMenuBarInfo
API 结合我们的 RightGuard
窗口。当使用
OBJID_MENU
(值为 -3)调用时,
GetMenuBarInfo
会检索与窗口关联的菜单信息(具体来说是 tagWND)。
在内部,
GetMenuBarInfo
由
xxxGetMenuBarInfo
支持,它从关联的
tagMENU
对象的
rgItems
字段读取数据。指向该
tagMENU
的指针来自
tagWND
内部的
spmenu
字段。
由于我们已经通过 Manager
获得了相对读写能力,我们可以修改 RightGuard
中的 spmenu 字段,使其指向我们控制的伪造
tagMENU
结构。这使我们能够欺骗
GetMenuBarInfo
从任意地址读取数据。
该技术的实现如下图所示。
Read64 实现
任意写原语 (Arbitrary Write)
为了实现任意写,我们使用来自 Manager 的相同相对读写原语来修改 RightGuard
中的
pcls
指针,使其指向我们控制的伪造
tagCLS
。
然后我们在 RightGuard
上调用
SetClassLongPtr
,它操作这个伪造结构。在内部,Win32k
使用
tagCLS
的
pclsClone
字段来计算目标地址。
SetClassLongPtr 实现
如伪代码所示,最终写入目标应为
VA-of-WriteTarget - A0h
。
A0h
是
_extra
字段的偏移量。写入操作在一个循环中发生,偏移量
00h
处的字段(下一个指针)必须为零,否则循环将继续,可能导致崩溃或破坏其他内存。
为了满足
SetClassLongPtr
中的条件,我们使用读原语检查目标地址(
VA-of-WriteTarget - 0xA0
)是否指向清零的内存区域。如果没有,我们将伪造的
pclsClone
指针向后调整,直到找到指向零的地址。这避免了触发
SetClassLongPtr
内部的链表循环。
为了补偿新的偏移量,我们使用
SetClassLongPtr
的第二个参数,它充当索引(索引值在上面的伪代码中名为 offset
的局部变量)。
该索引在内部被添加以计算最终写入偏移量,因此即使基指针发生了偏移,它也能让我们准确地定位到原始目标。
Write64 实现
最后,我们不应忘记恢复原始的 tagCLS 指针。
结论
希望我能够以清晰和结构化的方式解释完整的漏洞利用过程。如果您有任何问题,请随时联系——我很乐意回答或进一步讨论。
我们故意省略了最后一步——实际的令牌窃取实现。这并不复杂,如果您已经阅读到这里,可以将其视为读者的实践练习。
– win32k
-
writeup
-
exploit
-
windows