CVE-2021-1732完全攻略
前言
CVE-2021-1732 是蔓灵花(BITTER)APT 组织在某次被披露的攻击行动中使用的 0day 漏洞,该高危漏洞可以在本地将普通用户进程的权限提升至最高的 SYSTEM 权限。受到该漏洞影响的 Windows 版本众多[1],原始利用代码经过少量修改后甚至可以在(当时的)最新版 Windows 10 20H2 64 位全补丁环境进行提权。漏洞的利用样本最早在 2020 年 12 月 10 号就被安恒威胁情报中心捕获到[2],在其与 MSRC 的通力合作下,今年 2 月初,MSRC 将漏洞修复。
目前在 github 上公布的可用 EXP 主要有两个版本,分别由 Kernel Killer[3] 和 KaLendsi[4] 编写,本文将使用前者的 EXP 来做分析和调试(Windows 版本为 Windows 10 Version 1809 for x64)。其本人也在看雪论坛发布了 EXP 开发的相关文章[5](与 github 仓库中的 pdf 内容相同),本文会对 EXP 作者文章的技术细节进行补充,并对 EXP 代码进行近乎逐行的分析、注释。
作为第一次接触 Windows 内核漏洞利用的萌新(之前也只有些许 Windows 驱动开发经验),我将假设读者也和我一样初次尝试 Windows 内核漏洞利用复现,我会用最详尽的方式带你从零认知到完全理解 CVE-2021-1732。如果你正打算入门 Windows 内核,那么这篇文章将会给你提供一些静态分析及动态调试的技巧,如果你是这方面的老师傅,也欢迎对我说错的地方批评指正。总之,要是能在某些方面帮到你的话,就再好不过了。
漏洞综述
漏洞成因
用户态进程 p 在调用 CreateWindowEx 创建带有扩展内存的 Windows 窗口时,内核态图形驱动 win32kfull.sys 模块的 xxxCreateWindowEx 函数会通过 nt!KeUserModeCallback 回调机制调用用户态函数 user32!_xxxClientAllocWindowClassExtraBytes,其向内核返回用户态创建的窗口扩展内存。该返回值如何解释,由窗口对应 tagWND 结构体的 dwExtraFlag 字段规定。如果 dwExtraFlag 包含 0x800 属性,则返回值被视作相对内核桌面堆起始地址的偏移。攻击者可以 hook user32!_xxxClientAllocWindowClassExtraBytes 函数,通过一些手段使得 dwExtraFlag 包含 0x800 属性,然后直接调用 ntdll!NtCallbackReturn 向内核返回一个任意值。回调结束后,dwExtraFlag 不会被清除,未经校验的返回值直接被用于堆内存寻址(桌面堆起始地址 + 返回值),引发内存越界访问。随后攻击者通过一些巧妙的构造及 API 封装,获得内存越界读写能力,最后复制 system 进程的 Token 到进程 p 完成提权。
总的来说,漏洞是由 win32kfull!xxxCreateWindowEx 函数内一处由用户态回调导致的 flag 位设置与对应偏移设置不同步所导致的。
漏洞特点[2]
- 攻击目标为最新版 Windows 10 操作系统
- 在野样本攻击的是当时最新版 Windows10 1909 64 位操作系统(在野样本的编译时间为 2020 年 5 月)
- 在野样本适配了从 Windows10 1709 到 Windows10 1909 多个版本,且会只在 Windows10 1709 及以上版本中运行利用代码
- 原始利用代码经过少量修改后可在(当时的)最新版 Windows10 20H2 64 位全补丁环境进行提权
- 漏洞质量高,利用手法精湛,稳定性好,动态检测难度大
- 在野样本借助漏洞绕过了最新版 Windows 10 系统的内核地址空间布局随机化(KASLR)
- 本次漏洞不同于以往的 Win32k 漏洞,漏洞类型不是 UAF,整个利用过程不涉及堆喷射和内存重用,Type Isolation 缓解机制对其无效。在野样本在打开 DriverVerifier 验证器的情况下依然可以正常提权,无法通过开启内核池追踪检测到,动态检测难度大
- 在野样本的任意地址写入采用了漏洞特性结合 SetWindowLong 系列函数的手法,令人眼前一亮
- 在野样本借助 GetMenuBarInfo 实现任意地址读取,这种手法此前未被公开过,这体现出开发者精湛的利用编写水平
- 在野样本在构造出任意地址读写原语后,采用 DataOnlyAttack 的方式替换了当前进程的 Token,目前的内核缓解机制无法防御此类攻击
- 在野样本的漏洞利用成功率几乎为 100%
- 在野样本在完成利用后,将相关内核结构全部还原,整个过程不会对系统造成蓝屏影响,工作稳定
- 使用谨慎,隐蔽性好
- 在野样本在进行漏洞利用前对特定杀毒软件进行了检测
- 在野样本对当前操作系统版本进行了判断,低于 Windows 10 1709 版本的系统不会调用漏洞利用函数
- 在野样本从 2020 年 5 月完成编译,到 2020 年 12 月被发现,中间至少存活了 7 个月,这说明使用者在使用该漏洞时相当谨慎,间接体现出捕获此类隐蔽性样本的难度
受影响的系统版本
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
Windows 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 1809 for x64
漏洞复现
制作 Win10 1809 虚拟机
首先自然是需要一个 Win10 操作系统镜像,在 MSDN 工具站 上找到这个镜像并下载:
使用 VMware 创建一个新的虚拟机:
注册码不用填,选择一个要安装的版本,并另外注册一个非 admin 用户:
完成配置之前,需要注意,不论你的电脑配置有多好,请将处理器数和核心数都设置为 1,以排除多核复杂性对后面动态调试造成影响:
内存的话给个 2G 就行了,内存给得越大,快照的拍摄和还原就会越慢。之后就是等待 Win10 安装完毕。
编译 exp
使用 Visual Studio 2019 创建一个新项目,选择 Windows 桌面向导,下一步:
取个项目名 ExploitTest:
应用程序类型选择桌面应用程序,并勾选空项目:
从 Kernel Killer 的 Github 仓库[3] 获取 CVE-2021-1732_Exploit.cpp,在 VS 侧面的源文件处右击,添加一个现有项,选择刚下载的 CVE-2021-1732_Exploit.cpp:
这里切换成 Debug x64:
双击打开 CVE-2021-1732_Exploit.cpp,然后 项目 -> ExploitTest 属性,再次确认配置的是 Debug x64:
将 C/C++ -> 优化 -> 优化 设置为 禁用:
将 C/C++ -> 代码生成 -> 运行库 设置为 多线程调试(MTd),将运行库静态链接到可执行文件中,否则在虚拟机中运行时可能会报找不到 dll 的错。
将 链接器 -> 调试 -> 生成调试信息 设置为 生成经过优化以共享和发布的调试信息(/DEBUG:FULL),这是为了后面用 ida 加载 pdb 时有符号。
将 链接器 -> 高级 -> 随机基址 设置为 否(/DYNAMICBASE:NO),固定基址 设置为 是(/FIXED),这是为了动调的时候方便下断点。
应用后选择上方的 生成 -> 生成解决方案,生成好后就能在项目所在的文件夹下看到一个 x64 文件夹:
进入其中的 Debug 文件夹,就能看到生成的 exe 和 pdb 了:
提权复现
将 ExploitTest.exe 拷贝到虚拟机中,再拷贝一个 64 位的 ProcessExplorer 到虚拟机桌面:
右键以管理员身份运行 ProcessExplorer,在上方空白处右键 -> Select Columns,勾选上 Integrity Level,这样就能看到进程的权限了:
拍摄快照(这里务必拍摄快照,方便之后回退)后,双击运行 ExploitTest.exe,查看进程权限:
在命令行界面按任意键让程序继续执行,再看进程权限,已提升为 system:
可能一次不能成功(也有可能直接蓝屏),这个时候恢复一下快照,再重新尝试运行即可。
漏洞原理分析
Windows 桌面程序编程
这个部分是为没有进行过/不太熟练 Windows 桌面应用开发的读者而写的,我会用尽可能少的篇幅来让读者快速上手桌面开发 API 以及一些相关的结构体。如果你已经知晓了结构体 WNDCLASSEX 的 cbWndExtra 字段的作用,以及其与 GetWindowLong/SetWindowLong 系列 API 配合的含义,那么你可以选择跳到下个部分去。
和 2.2 节 操作相同,使用 Visual Studio 2019 创建一个桌面应用程序的空项目,切换成 Debug x64,在左侧源文件处右击,添加一个新建项,命名为 main.cpp,将下面的代码复制进去:
1 |
|
运行起来,你便得到了一个标题是 Hello World 的窗口:
在窗口中右键会弹出一个对话框:
我来解释一下这个程序是怎么工作的。首先,桌面应用对于程序员而言的入口点从常见的 main 变成了 WinMain(这里的 wWinMain 是 unicode 版 WinMain,表明程序中使用的字符为 unicode 编码)。其次,需要有个概念,那就是我们平常看到的应用程序窗口,都是某个窗口类的”实例”(注意,这里的类不是 C++ 类的概念),Windows 使用一个结构体来管理 行为/风格 相似一类窗口,其定义[6]如下:
1 | typedef struct tagWNDCLASSEXW { |
从窗口类的定义可以看出,它包含了一个窗口的重要信息,如窗口风格、窗口过程、显示和绘制窗口所需要的信息,等等。可以使用 VS 自带的工具 Microsoft spy++ 来实际感知窗口类的存在,比如计算器对应的窗口类是 Windows.UI.Core.CoreWindow:
所以想要创建一个窗口”实例”,需要先向系统注册一个窗口类,这就是为什么开始的代码要这样写:
1 | WNDCLASSEX wndclass = { 0 }; // 创建窗口类结构体 |
现在我们就能用这个窗口类来创建看得见、摸得着的窗口了。创建窗口使用 CreateWindowEx[7] 函数,通过下面的代码,我们就能成功创建一个可见的窗口并得到它的窗口句柄:
1 | /* 创建窗口 */ |
创建了窗口后,如果后面什么代码都不写,那程序就直接退出,创建的窗口也立马被销毁了,这个不能交互的桌面应用对用户来说毫无意义。为了给创建出的窗口注入灵魂,最后往往会加一个 while 主消息循环:
1 | /* 消息循环 */ |
我们常常能听到一些名词,如事件驱动、Windows 消息处理机制等,这些概念集中体现在上面这个 while 循环中。为什么在窗口中右键,会弹出一个对话框?那是因为鼠标右键按下被视作了一个事件,当这个事件在窗口中发生时,系统会去调用窗口所属窗口类的 WNDCLASSEX.lpfnWndProc 回调函数来响应,这个函数中写了弹出对话框的代码,因此才会有这样的效果。
那么问题来了,为什么我按鼠标左键它不会弹出对话框?照你这个说法,鼠标左键按下也是一个事件,系统不也会调用同一个回调 WNDCLASSEX.lpfnWndProc,那行为应该相同(即弹出对话框)才对啊?诚然,不论是左键还是右键按下,该窗口的 WNDCLASSEX.lpfnWndProc 都会被调用,而结果我们也清楚 —— 只有右键按下才会弹出对话框,这个函数是怎么做到区分左/右键按下的呢?
事实上,操作系统会把每个到来的事件包装成一个消息结构体(MSG 结构体,不是本文关注点,这里就不列出它的各个字段了),这个结构体记录了事件发生在哪个窗口(窗口句柄)、事件对应的消息编号(每种事件都对应了一个唯一的编号)、事件发生时所处的坐标等。系统将这个 MSG 结构体放入线程的消息队列,当线程调用 GetMessage 时,就能从队列中取出这个结构体,再通过 DispatchMessage 根据 MSG 结构体中的窗口句柄,向对应窗口递送该消息(具体体现就是调用那个窗口的 lpfnWndProc)。TranslateMessage 函数用于做一些虚拟键消息转换,与我们所说的内容关系不大,可以忽略掉这个函数。
现在就可以解释为什么按下左键没有弹出对话框了,来看本例代码中的窗口过程函数:
1 | /* 窗口类的窗口过程函数(负责消息处理) */ |
左键按下和右键按下是不同的事件,它们的消息编号不同,所以当回调 MyWndProc 先后被调用时,参数 message 的值就不同(分别是 WM_LBUTTONDOWN 和 WM_RBUTTONDOWN)。在函数实现中,通过一个 switch 语句来判断消息的类型,针对不同的消息,采取不同的相应措施。比如上面的代码,我们判断当消息是右键按下时就弹个对话框,其他的消息使用默认方式处理(这种窗口中左键按下的默认处理方式就是什么也不做)。
有了上面的基本概念后,我们来拓展聊聊 WNDCLASSEX 结构体的 cbClsExtra 和 cbWndExtra 字段,之前注释里也写了,它们分别表示窗口类的扩展内存大小和窗口的扩展内存大小,那扩展内存有什么用?二者又有什么区别呢?
先说说什么是窗口类的扩展内存。在应用程序注册一个窗口类时,可以让系统分配一定大小的内存空间,作为该窗口类的扩展内存,之后属于该窗口类的每个窗口都共享这片内存区域,每个窗口都可以通过 Windows 提供的 API 来读写这片扩展内存,如 GetClassLong、GetClassLongPtr、SetClassLong、SetClassLongPtr 等(Ptr 后缀是为了兼容 32 和 64 位),以此实现同窗口类的窗口间通信,而 cbClsExtra 字段就记录了这片内存的大小。
同理,当窗口创建时,可以让系统分配一定大小的内存空间,作为该窗口的扩展内存,这就是窗口的扩展内存。这片内存每个窗口独享,也可以通过 API 来读写(GetWindowLong、GetWindowLongPtr、SetWindowLong、SetWindowLongPtr),该机制提供了一种窗口数据暂存的方式,这片内存的大小由 cbWndExtra 字段记录。
还是通过一个实例来感受 cbWndExtra 字段的意义:
1 |
|
运行起来,按下左键:
按下右键:
好了,关于 Windows 桌面编程就先聊到这儿吧,花了不少的篇幅来引出 cbWndExtra 字段,是因为该字段不为 0(窗口具有扩展内存)正是 CVE-2021-1732 漏洞利用的导火索。
tagWND 结构体
与每个窗口类对应一个结构体类似,Windows 使用 tagWND 结构体来描述每个窗口,这个结构体在加载了官方 pdb 文件的 Win7 win32k.sys 模块可以找到[8]。可能是由于泄露了太多对开发者无用的内核符号,导致 win32k 被五花八门的漏洞利用手段玩坏了,Win7 往后,微软去掉了 pdb 文件中很多内核符号,其中就包括 tagWND。所以目前,我们只能通过参考 Win7 及以前的符号,并结合 API 逆向分析来推测 Win10 中 tagWND 各字段的含义。
值得庆幸的是,已经有人在这方面做了很多工作了。在前辈们的经验总结下[8][9][10],我们可以得知,在 Win10 中,对于每个窗口,系统为用户层和内核层各维护了一个 tagWND 结构体,用户层的 &tagWND + 0x28 处的 8 字节为一个指针,指向内核层 tagWND 结构体。后文将使用 tagWND/tagWNDk 来表示 用户层/内核层 tagWND 结构体,ptagWND/ptagWNDk 来表示 用户层/内核层 tagWND 结构体指针。
下面列出 tagWND 结构体中与漏洞相关的字段(一个”Tab 缩进 + 偏移量”表示一次父级的值加偏移后访存):
1 | ptagWND(user layer) |
后面的分析在用到 tagWND 时,可以翻回这个部分进行查阅。
以结果为导向
本 CVE 的 POC 所达到的效果,就是可以在用户态调用 SetWindowLong 来造成一次内核桌面堆的越界写。SetWindowLong 实际调用 user32!SetWindowLongW,其中又调用了 win32u!NtUserSetWindowLong:
之后通过系统调用进入内核态,调用 win32kfull!NtUserSetWindowLong,并最终调用 win32kfull!xxxSetWindowLong,传入根据窗口句柄找到的 tagWND 结构体地址(ptagWND)、写入的扩展内存的偏移(nIndex)、要写入的值(value):
调用栈我也贴在这里:
进入 xxxSetWindowLong 后,在 59 行获得了内核 tagWND 结构体指针:
从 117 行可以看出 nIndex 的值必须小于 ptagWNDk->cbWndExtra(窗口扩展内存大小,该值在注册窗口类时指定):
由 157、158、162 行可知当 ptagWNDk->dwExtraFlag & 0x800 != 0
时,内核桌面堆起始地址 + pExtraBytes + nIndex 处的 4 字节会被赋值成 value:
POC 就是通过控制 pExtraBytes 为任意值来实现桌面堆越界写的。从上图的 160 行也可以看出,当 ptagWNDk->dwExtraFlag & 0x800 == 0
时,pExtraBytes 就解释为一个可写内存的地址,直接通过 pExtraBytes + nIndex
来寻址。
故我们发现,tagWNDk 实际上使用两种模式来保存窗口扩展内存的地址:
- dwExtraFlag & 0x800 == 0:在用户空间系统堆中,pExtraBytes 解释为扩展内存在用户空间堆中的地址指针
- dwExtraFlag & 0x800 != 0:在内核空间桌面堆中,pExtraBytes 解释为该扩展内存起始地址相对于内核桌面堆基址的偏移量
下一部分就将介绍正常情况下两种模式对应的 pExtraBytes 是如何被赋值的。
两种模式下 pExtraBytes 正常赋值流程
模式 1 - 在用户空间系统堆中(直接寻址模式)
该模式下,tagWNDk.pExtraBytes 在调用 CreateWindowEx 创建窗口的过程中被赋值。前半部分的调用链没有什么信息量:
1 | [用户态] |
调用栈:
xxxCreateWindowEx 506 行调用 win32kbase!HMAllocObject 创建了一个 tagWND 结构体并返回其指针:
521 行设置 ptagWNDk->pExtraBytes 初值为 0(*(ptagWND + 0x28) 为 ptagWNDk):
从 821、822 行可以看出,当 ptagWNDk->cbWndExtra 不为 0 时,会调用 win32kfull!xxxClientAllocWindowClassExtraBytes 来设置 ptagWNDk->pExtraBytes:
821 行的不等号重载(0xA1 - 0x79 = 0x28):
win32kfull!xxxClientAllocWindowClassExtraBytes 实现:
阅读代码后,不难发现:
- 22 行:通过 nt!KeUserModeCallback[11] 回调记录在 PEB.KernelCallbackTable 表中第 123 项 的用户层函数,该项是 user32!_xxxClientAllocWindowClassExtraBytes 函数的指针
- 26 行:user32!_xxxClientAllocWindowClassExtraBytes 返回信息的长度应该为 0x18 字节
- 29 行:存储返回信息的地址需小于 MmUserProbeAddress(0x7fffffff0000)
- 31 行:返回信息的第一个指针类型指向在用户态申请的用户堆空间
- 34 行:调用 ProbeForRead 验证申请的用户堆地址 + 长度是否小于 MmUserProbeAddress(0x7fffffff0000)
- 32、35 行:xxxClientAllocWindowClassExtraBytes 返回用户堆空间地址
user32!_xxxClientAllocWindowClassExtraBytes 函数:
该回调函数所做的事情就是调用 ntdll!RtlAllocateHeap 申请 cbWndExtra 大小的用户堆空间,并将申请到的堆地址作为返回信息的第一个 8 字节,调用 ntdll!NtCallbackReturn 修正堆栈后重新返回内核层执行。
win32kfull!xxxClientAllocWindowClassExtraBytes 返回后,ptagWNDk->pExtraBytes 就会被赋值为申请到的用户空间堆地址:
以上过程用 iamelli0t 博客[12]的一张图来总结:
模式 2 - 在系统空间桌面堆中(offset 间接寻址模式)
在该模式下想要赋值 pExtraBytes,须在用户态调用未公开的 user32!ConsoleControl(或 win32u!NtUserConsoleControl),调用栈:
win32kfull!NtUserConsoleControl 函数:
由上图可知,要想调用 xxxConsoleControl,需满足:
- 14 行:第一个参数(功能号)不大于 6
- 16 行:第三个参数(参数信息的长度)不大于 0x18
win32kfull!xxxConsoleControl 根据传入的功能号进行不同的操作,一共有 6 种功能(功能号为 1 - 6),第 6 个功能才会赋值 pExtraBytes:
由上图分析可得,功能 6 调用 DesktopAlloc 在内核空间桌面堆中分配窗口扩展内存,计算已分配的扩展内存地址到内核桌面堆基址的偏移量,并将偏移量保存到 tagWNDk.pExtraBytes,最后修改 tagWNDk.dwExtraFlag |= 0x800
。
POC 攻击手法及难点解决
经过上面的分析我们已经知道:
- 使用 CreateWindowEx 创建窗口的过程中内核会回调用户层函数 user32!_xxxClientAllocWindowClassExtraBytes,由它代为申请用户空间堆,内核用这个地址赋值 pExtraBytes 后,并未重新设置 dwExtraFlag(
tagWNDk.dwExtraFlag &= ~0x800
) - 使用 user32!ConsoleControl 的第 6 个功能,除了能赋值 pExtraBytes,还能设置
tagWNDk.dwExtraFlag |= 0x800
- 调用 SetWindowLong 写窗口扩展内存时,如果
dwExtraFlag & 0x800 != 0
,则使用 offset 间接寻址方式写桌面堆
在 POC 中,攻击者对 user32!_xxxClientAllocWindowClassExtraBytes 进行挂钩,在钩子函数中手动调用 win32u!NtUserConsoleControl,将 pExtraBytes 的解释方式从模式 1 修改为模式 2,然后调用 ntdll!NtCallbackReturn 向内核返回一个能过读写检查的可控值,用于设置 tagWNDk.pExtraBytes。最后调用 SetWindowLong 写附加空间时,就能实现基于内核空间桌面堆基址的可控偏移量越界写。
还是借用 iamelli0t[12] 的图来直观感受:
设想是美好的,实践起来还会遇到细节上的问题 —— 上个部分所提到的 win32kfull!xxxConsoleControl 功能 6 需要传入窗口句柄:
可攻击需要在 CreateWindowEx 过程里调用 user32!ConsoleControl,此时 CreateWindowEx 还没有返回窗口句柄 HWND,这就需要我们来分析 CreateWindowEx 是怎么创建的窗口句柄。其实在 3.4.1 节 我提了一下 xxxCreateWindowEx 506 行调用 win32kbase!HMAllocObject 创建了一个 tagWND 结构体并返回其指针:
窗口句柄就是在这个函数中创建并赋值到 tagWND 结构体中的,该函数首先通过 DesktopAlloc 从内核桌面堆申请存储 tagWNDk 的空间:
然后选出一个窗口句柄[13]并存储到 &tagWNDk + 0:
此外,结合上两张图可以发现 &tagWNDk + 8 保存了 tagWNDk 相对于桌面堆基址的偏移。
幸运的是, user32!_xxxClientAllocWindowClassExtraBytes 之前,win32kbase!HMAllocObject 就已经在 win32kfull!xxxCreateWindowEx 中被调用了,我们要是能把创建的窗口句柄泄露出来就可以补全 POC 链了,问题就转化为如何泄露 tagWNDk 的内容。
这就不得不提起 Windows 内核利用领域使用了 10 年的一项技术 —— 通过未公开函数 user32!HMValidateHandle 泄露内核信息[14],只要把窗口句柄传递给这个函数,它就会返回 tagWNDk 在用户空间的只读映射指针(HMAllocObject 创建了桌面堆类型句柄后,会把tagWNDk 对象放入到内核模式到用户模式的映射内存中)。此外,HMValidateHandle 函数的地址可以由 user32!IsMenu 的第一个 call 计算[14]。
那么泄露窗口句柄的难点就迎刃而解了,直接来看完整的 POC 思路:
- 将 PEB.KernelCallbackTable 的第 123 项替换成自定义挂钩函数的指针
- 创建一些窗口(都属于窗口类 1),并通过 user32!HMValidateHandle 泄露这些窗口对应 tagWNDk 在用户空间的地址
- 销毁在步骤 2 中创建的部分窗口,使得桌面堆能回收这些窗口对象所占用的空间。再使用与窗口类 1 cbWndExtra 不同的窗口类 2 创建一个新窗口,这个新窗口的 tagWNDk 对象可能会使用之前释放掉的空间。因此,通过在自定义挂钩函数中使用窗口类 2 的 cbWndExtra 搜索先前泄露的 tagWNDk 对象用户空间地址,便可以找到新窗口的 tagWNDk 在用户空间的地址,读取第一个 8 字节即可泄露窗口句柄。
- 在自定义挂钩函数中调用 user32!ConsoleControl 来修改新窗口
tagWNDk.dwExtraFlag |= 0x800
- 在自定义挂钩函数中调用 ntdll!NtCallbackReturn 将可控的虚假偏移量分配给新窗口的 tagWNDk.pExtraBytes
- 调用 SetWindowLong 将数据写入内核空间桌面堆基址 + 可控偏移量的地址,这可能会导致超出堆范围的内存访问冲突
这些步骤都会在 04 部分 体现。
EXP 利用手法及难点解决
对于内核漏洞利用,攻击目标通常是获得 System 令牌,常见的方法如下:
- 利用漏洞在内核空间中获得任意地址读写的原语
- 泄露一些内核对象的地址,通过 EPROCESS 链找到 System 进程
- 将 System 进程的令牌复制到攻击进程以完成权限提升
我们所面临的困难主要是步骤 1 —— 如何利用”在内核空间桌面堆基地址 + 可控偏移量计算出的地址中写数据”的机会来获取内核空间任意地址读写的原语。本部分将注重逻辑分析,具体实施细节在 04 部分 。
任意地址写
由 3.3 节 的分析,调用 SetWindowLong 时,传递的 nIndex 必须小于 tagWNDk.cbWndExtra,若是能把该值改大,就能轻松造成内存访问越界。参考 3.5 节,tagWNDk + 8 的地方保存着该 tagWNDk 相对于桌面堆基址的偏移。结合这两点,可以构造如下的内存布局:
首先通过漏洞将 tagWNDk2.pExtraBytes 设置为 offset 模式寻址(dwExtraFlag |= 0x800),并将其赋值为 tagWNDk0 相对于桌面堆基址的偏移(*(&tagWNDk0 + 8)),于是窗口 2 的扩展内存变成了 tagWNDk0 所在的空间。对窗口 2 调用 SetWindowLong,nIndex 为 cbWndExtra 在结构体中的偏移(0xC8),就能修改到 tagWNDk0.cbWndExtra 了,我们把它改成 0xFFFFFFFF,cbWndExtra 过小的限制就解除了!为了能通过窗口 0 的扩展内存写到窗口 1 的 tagWNDk,还需要提前使用 win32u!NtUserConsoleControl 来让窗口 0 也进入 offset 寻址模式。
现在对窗口 0 调用 SetWindowLongPtr,nIndex 为窗口 0 扩展内存与窗口 1 tagWNDk 的偏移 + pExtraBytes 在结构体中的偏移(0x128),修改 tagWNDk1.pExtraBytes 为任意值。又因为 tagWNDk1.pExtraBytes 处于直接寻址模式,再对窗口 1 调用 SetWindowLongPtr 就能实现任意地址写了。
任意地址读
EXP 中使用 user32!GetMenuBarInfo[15] 函数与伪造的 tagMENU 结构体进行内核读取,一次可以读取16个字节,这种巧妙的手法此前未被公开过。该 API 最终会调用 win32kfull!xxxGetMenuBarInfo,并传入 4 个参数 ptagWND,idObject,idItem,&mbi[16]:
结合 3.2 节:
1 | ptagWND(user layer) |
需要注意,spMenu->spMenuk->pSelf 是一个指向 spMenu 自身的指针。
分析 xxxGetMenuBarInfo 关键部分:
95 行的等号重载:
可以得出分割线往上检查了:
- 对于 GetMenuBarInfo 参数
- 87 行:第二个参数 idObject 应为 -3
- 98、99 行:0 <= 第三个参数 idItem <= ptagWND->spMenu->unknown1->cItems
- 对于 tagWNDk 结构体
- 89 行:dwStyle 不能包含 WS_CHILD 属性
- 对于 tagMENU 结构体
- 105 行:unknown2 与 unknown3 不能为 0
分割线往下就是利用部分了,由 110 - 112、122 - 130、159 行可知,如果伪造的 tagMENU 结构体中 rgItems->unknown 为欲读取的地址 - 0x40,那么就能从 GetMenuBarInfo 第四个参数 pmbi 获得欲读取地址开始的 16 个字节(当然还需要减去一些已知值)。因此,我们需要伪造的 tagMENU 结构体大概长这样:
构造好了怎么修改 ptagWND->spMenu 呢?Kernel Killer 在他的 EXP 中选择使用 SetWindowLongPtr 自带的功能来修改。其实 SetWindowLong 系列函数除了能写窗口附加空间,如果参数 nIndex 给的是负数,它们还能用于设置 tagWND 的一些字段,这些功能都是公开的,可以在微软开发文档[17] 查到:
当 nIndex 为 GWLP_ID 时,win32kfull!xxxSetWindowLongPtr 还会调用 win32kfull!xxxSetWindowData,在其中设置 ptagWND->spMenu 为用户给定的值,并返回 spMenu 旧值(注意:dwStyle 应带有 WS_CHILD 属性):
就算不知道 SetWindowLongPtr 有这样的功能,也能改到 ptagWND->spMenu。同 3.6.1 节 的理,对窗口 0 调用 SetWindowLongPtr,通过越界写同样能将窗口 1 的 tagWNDk.spMenu 改为自定义的值,这种情况下,SetWindowLongPtr 依旧会返回修改前的旧值:
无论使用哪种方式,现在我们获得了任意地址读的能力!
泄露内核对象地址
泄露地址的工作在上一部分已经完成了一半 —— 泄露出了旧 spMenu 的地址,由 3.2 节 又有:
1 | ptagWND(user layer) |
那么泄露当前进程 EPROCESS 地址的方式就不止一种了:
- 通过三次任意地址读,达到
**(__int64 **)(*(__int64 *)(spMenu + 0x18) + 0x100)
的效果(EXP 使用) - 通过四次任意地址读,达到
*(__int64 *)(**(__int64 **)(*(__int64 *)(spMenu + 0x50) + 0x10) + 0x220)
的效果(攻击样本使用)
提升进程权限
知道当前进程的 EPROCESS 地址后,遍历 EPROCESS->ActiveProcessLinks 链表[18],找到 pid 为 4 的进程(System 进程),将其 Token 复制到当前的攻击进程,即可完成提权。
下面是将用到的 EPROCESS 字段及其偏移量:
1 | pEPROCESS |
EXP 阅读
这个部分我将按照 EXP 的程序执行流,把 03 部分 完整地串起来,各个技术点的实施细节将在这里展露无遗。
准备工作
169 - 176 行(WinMain 入口)为本窗口程序创建了一个控制台,并将程序的标准输入、输出重定向到这个控制台,这就是为什么运行 EXP 时会有个命令行界面:
1 | UNREFERENCED_PARAMETER(hPrevInstance); // 告诉编译器,已经使用了参数,不必警告 |
178 - 187 行通过未公开的 ntdll!RtlGetNtVersionNumbers 函数获得 Windows 版本信息(主次版本及 OS 内部版本号),并输出到控制台:
1 | typedef void(WINAPI* FRtlGetNtVersionNumbers)(DWORD*, DWORD*, DWORD*); |
189 - 198 获取一些未公开函数的地址,以便后面使用:
1 | g_fNtUserConsoleControl = (FNtUserConsoleControl)GetProcAddress(GetModuleHandle(L"win32u.dll"), "NtUserConsoleControl"); // win32u!NtUserConsoleControl(其实用 user32!ConsoleControl 效果一样) |
其中 HMValidateHandle 函数地址通过 user32!IsMenu 的第一个 call 计算[14]:
1 | bool FindHMValidateHandle(FHMValidateHandle *pfOutHMValidateHandle) |
200 - 204 行将 KernelCallbackTable 的 123、124 项替换成自己的挂钩函数:
1 | DWORD dwOldProtect = 0; |
第 123 项的 hook 函数等到下面用到时再贴出来。第 124 项的 hook 应该是作者为了调试 EXP 而加的,其 hook 函数原封不动地调用了原函数:
1 | NTSTATUS WINAPI MyxxxClientFreeWindowClassExtraBytes(PVOID pInfo) |
内存布局
206 - 221 行注册了两个窗口类,两个窗口类的主要区别是 cbWndExtra 的大小:
1 | ATOM atom1, atom2 = 0; |
223 - 287 行通过创建/销毁窗口,正式进行内核桌面堆布局:
1 | ULONG_PTR dwpWnd0_to_pWnd1_kernel_heap_offset = 0; |
KernelCallbackTable 的 123 项之前被替换成自定义的 hook 函数:
1 | NTSTATUS WINAPI MyxxxClientAllocWindowClassExtraBytes(unsigned int* pSize) |
至此布局完毕,桌面堆长这样:
任意地址读的实现及封装
289 - 306 行使得我们具有任意地址读的能力:
1 | SetWindowLong(hWnd2, g_cbWndExtra_offset, 0x0FFFFFFFF); // 将 tagWNDk0.cbWndExtra 改为很大的值,窗口 0 的扩展内存可以越界写 |
EXP 中通过函数 ReadKernelMemoryQQWORD 封装任意地址读功能,参数 pAddress 为要读的地址 p,ululOutVal1 和 ululOutVal2 存储读出来的 16 字节,其中 ululOutVal1 = *(__int64 *)p
,ululOutVal2 = *(__int64 *)(p + 8)
,实现如下:
1 | void ReadKernelMemoryQQWORD(ULONG_PTR pAddress, ULONG_PTR &ululOutVal1, ULONG_PTR &ululOutVal2) |
至于为什么要减去那四个值,再来回顾一下 xxxGetMenuBarInfo 中是怎么赋值 mbi 的:
泄露进程 EPROCESS 地址
312 - 320 行使用了 3.6.3 节 第一种方法来泄露:
1 | ULONG_PTR ululValue1 = 0, ululValue2 = 0; |
进程权限提升
322 - 347 行遍历 EPROCESS->ActiveProcessLinks 链表,找到 System 进程,将其 Token 复制到当前进程:
1 | ULONG_PTR pSystemEProcess = 0; |
扫尾工作
350 - 374 行恢复了被修改的各结构体字段,防止蓝屏的发生:
1 | g_dwpWndKernel_heap_offset2 = *(ULONG_PTR*)((PBYTE)pWnd2 + g_dwKernel_pWnd_offset); // tagWNDk2 相对于桌面堆基址的偏移 |
376 - 388 行释放剩余的资源,EXP 至此结束:
1 | DestroyWindow(g_hWnd[0]); |
动态调试
双机调试环境搭建[20]
确保你有一个 Win10 1809 的虚拟机,如果没有,可以参考 2.1 节。
接着从 github 下载最新版 VirtualKD-Redux[21]:
解压后将 target64 文件夹复制到虚拟机内,在虚拟机里以管理员身份运行文件夹中的 vminstall.exe,点击 Install 按钮:
弹出的警告无视掉,然后重启虚拟机(选是会自动重启)。启动界面会让你选择启动项,选到我们新加的启动项按下 F8,选择禁用驱动程序强制签名并按下回车:
等待进入桌面后(建议重新拍摄一个快照),在宿主机中运行 VirtualKD-Redux 文件夹下的 vmmon64.exe,设置 WinDbg 调试器的路径(如果没有安装 WinDbg,可以参考微软官方文档[22]):
点击 Run debugger 就会弹出 WinDbg(以后再调试都会自动弹出),按 WinDbg 上方的暂停按键就可以断下来了:
下方命令行输入 g,虚拟机就可以继续运行了,后面再想中断,可以按 WinDbg 上方的暂停按键。
最后是设置 WinDbg 使用的符号路径[23],需要添加一个 _NT_SYMBOL_PATH 系统环境变量,值为 srv*path
to\your\local\folder*https://msdl.microsoft.com/download/symbols
,两个星号中间部分替换为你的本地文件夹(WinDbg 将自动从微软符号服务器下载 pdb 文件到这个文件夹中):
重启 WinDbg 后生效。
IDA 加载 pdb 文件
以 ExploitTest.exe 为例,使用 IDA 加载 ExploitTest.exe 后,选择上方的 File -> Load file -> PDB file,选择 pdb 路径:
加载完毕后,左侧函数窗口就能看到符号了:
调试某一进程
以 ExploitTest.exe 为例,在虚拟机中运行 ExploitTest.exe ,WinDbg 中按下暂停后首先找到进程的 EPROCESS 地址:
1 | kd> !process 0 0 ExploitTest.exe |
使用 .process 指令与找到的 EPROCESS 地址切换到该进程的地址空间:
1 | kd> .process /i /p ffffc90f5867c080 |
然后 g 运行一下,WinDbg 会切换进程并断在 ExploitTest.exe 进程中:
1 | kd> g |
最后重新加载符号:
1 | kd> !sym noisy |
可以看到加载了的模块都有对应的 pdb 了:
现在就可以:
- 根据 IDA 上看到的地址来下断点(之前编译的时候已经关闭了随机基址,参考 2.2 节)
- 加载源码进行调试
对于第二种调试方法,选择 WinDbg 上方 File -> Open Source File,加载 CVE-2021-1732_Exploit.cpp,即可在源码窗口下断点:
获取 pdb 文件
对于调试过程中被调试进程已经加载了的模块,可以通过 .reload /f
指令来下载(上一部分已提及)。想要获得未加载模块的 pdb 文件,可以先在虚拟机中找到该模块,将其复制到宿主机中,再通过 WinDbg 同级目录下的 symchk.exe 来下载。
以 win32kfull.sys 为例,该驱动位于 C:\Windows\System32 目录下:
拷贝到宿主机后,找到 WinDbg 所在目录,使用 symchk 并指定 win32kfull.sys 路径:
之后就能在符号缓存目录下找到 pdb 了:
exp 关键点动态调试
挂钩 user32!_xxxClientAllocWindowClassExtraBytes
202 行下断点,运行到此处,先查看原 KernelCallbackTable[123] 表项,其指向 user32!_xxxClientAllocWindowClassExtraBytes:
步过后,该项被改为我们的挂钩函数:
内存布局情况
运行到 286 行,在 win32kfull!xxxCreateWindowEx+1182
下个断点,r15 为 tagWND2 的地址(IDA 查看函数偏移可以在 Options -> General 中勾选 Function offsets):
访问 *(*(&tagWND + 0x18) + 0x80)
得到桌面堆基址 0xffff892a81000000:
通过 EXP 的 g_pwnd 数组 0、1 两项可以获取到 tagWNDk0、tagWNDk1 相对于桌面堆的偏移:
故 tagWNDk0 地址为 0xffff892a81030bc0,tagWNDk1 地址为 0xffff892a81033b10,tagWNDk2 地址为 0xffff892a81033c60。继续运行到执行流返回 EXP 的 287 行,现在窗口 0 和窗口 2 的 pExtraBytes 均处于 offset 间接寻址模式,来看看他们的扩展内存在哪里:
可以看到,窗口 0 的扩展内存处于较低的地址,窗口 2 的扩展内存语义上指向了 tagWNDk0 ,这样的内存布局正符合我们的期望:
泄露 EPROCESS 地址
运行到 293 行,在 win32kfull!xxxSetWindowLongPtr
下断点,第一个参数为 tagWND0 的地址,保存在 rcx 寄存器:
运行到 306 行,同样在 win32kfull!xxxSetWindowLongPtr
下断点,第一个参数为 tagWND1 的地址,同样保存在 rcx 寄存器:
tagWND1 原来的 spMenu:
继续执行直到执行流返回 309 行,tagWND1.spMenu 就被修改为指向我们伪造的 tagMENU 结构体了:
接着 EXP 会通过三次 GetMenuBarInfo 来泄露进程 EPROCES 地址,让程序运行到读取完毕的 320 行,验证地址的正确性:
权限提升
让程序执行到 339 行,验证找到的 System 进程 EPROCESS 地址:
可以得知 System Token 为 0xffff9209cb20604a,且此时 tagWNDk1.pExtraBytes 处于直接寻址模式:
当前进程原来的 Token 为 0xffff9209d2acc067:
执行到 350 行(使用任意地址写能力修改当前进程 Token 结束后),再查看当前进程的 Token:
成功更换令牌,实现提权。
补丁分析
由于官网[24]上的补丁包我打不上,索性就用已经打满补丁的(4 月的包也更新了)Windows10 20H2 x64 宿主机来看吧,补丁打在了 win32kfull!xxxCreateWindowEx:
结语
完结撒花,感谢你耐心的阅读!如果前面的每个部分都细看了,那么相信现在你已经对 CVE 2021-1732 了若指掌了,恭喜你!同时也特别感谢 Kernel Killer 的 EXP 开发文档[5] 和 iamelli0t 的漏洞分析博客[12],这两篇文章数次拯救我于水深火热(大脑短路)之中。除此之外,我还推荐一篇奇安信威胁情报中心发的文章[10],其作者详细分析了在野攻击样本,满篇的动态调试弥补了本文动调方面的不足。
其实一开始我还想拿一个版块来写 KaLendsi[4] 的 EXP 分析,奈何本人精力有限,这篇不到 16000 字的 CVE 分析已经耗费了我大量的心血,虽然这可能与我第一次做 Windows 内核漏洞利用的分析有关(笑)。从有写这篇文章的想法,到拙作收笔,期间的时空跨度很大,时间上,各种参考文献的查找、阅读就花掉了半个月,剩下半个月一半时间在静态分析各个模块和调试 EXP,另一半用来没日没夜地写作;空间上,随着宛如甘霖的 5.1 假期到来,我从呆了一年的学校回到了心心念念的家中……总之,如果你阅读了 KaLendsi 的 EXP,劳烦你告知我他的做法,谢谢!而如果你有心去阅读 KaLendsi 的 EXP,本文已经给你提供了足够的能力,也希望你读懂后能与我交流 ~
最后我想说,即使校对了 3 遍,我还是不能打包票 —— 本文不存在笔误,毕竟我在各参考文献中就发现了不少的错误,这一点望读者海涵。如果我哪里写错而误导了你,请务必告知我,届时求轻喷 555
参考资料
[1] MSRC: Windows Win32k 特权提升漏洞公告
[2] 0Day攻击!首次发现蔓灵花组织在针对国内的攻击活动中使用Windows内核提权0Day漏洞(CVE-2021-1732)
[3] Github: k-k-k-k-k/CVE-2021-1732
[4] Github: KaLendsi/CVE-2021-1732-Exploit
[5] [原创]CVE-2021-1732 Microsoft Windows10 本地提权漏洞研究及Exploit开发
[6] WNDCLASSEXA structure (winuser.h)
[7] CreateWindowExW function (winuser.h)
[8] Win10 tagWnd partial member reverse (window hidden, window protected)
[9] Part 18: Kernel Exploitation -> RS2 Bitmap Necromancy
[10] Microsoft Windows提权漏洞 (CVE-2021-1732) 分析
[11] [原创]KeUserModeCallback用法详解
[12] CVE-2021-1732: win32kfull xxxCreateWindowEx callback out-of-bounds
[13] Windows源代码阅读之 句柄算法
[14] A simple protection against HMValidateHandle technique
[15] GetMenuBarInfo function (winuser.h)
[16] MENUBARINFO structure (winuser.h)
[17] SetWindowLongPtrW function (winuser.h)
[19] 使用AllocConsole在Win32程序中调用控制台调试输出
[20] 使用VMware + win10 + VirtualKD + windbg从零搭建双机内核调试环境
[21] Github: VirtualKD-Redux/release
[22] 下载 Windows 调试工具
[23] 使用符号服务器