-
-
CVE-2021-40449 内核提权详细分析
-
发表于: 10小时前 123
-
CVE-2021-40449
在 CVE-2021-1732 中,使用了 KeUserModeCallback 回调用户态导致被劫持,在本CVE中同样也是因为使用GDI驱动回调用户态导致被提权。所以不论什么时候进行跨域调用危害性都是特别大的,一定要做好各种保护......
漏洞简介
漏洞编号: CVE-2021-40449
漏洞产品: windows & win32kfull & gdi
测试环境: Windows 10 17763
Vulnerability: 漏洞核心不仅仅只是在于 ResetDC 本身,而是 GDI 打印路径中的用户态驱动回调(UMPD callback)形成了信任边界缺口:在 ResetDC 触发的 DC 重建过程中,内核需要依据新的 DEVMODE 重建 DC / PDEV / SURFACE,由于打印机驱动大量实现于用户态(UMPD),内核会通过 PDEV->apfn[] 的函数表回调到用户态驱动例程(典型如 UMPDDrvResetPDEV,以及创建流程中的 DrvEnablePDEV 等)。该回调发生在 旧 DC 已清理、新 DC 尚未完全绑定/一致性未恢复 的关键窗口期内;攻击者可通过伪造/劫持 PDEV 的回调函数指针或利用回调期间的对象状态不一致(例如提前释放、替换内部指针/句柄、破坏引用计数或结构字段),制造 Use-After-Free / 对象混淆 / 任意函数指针调用 等内核态异常控制流,进而获得稳定的读写原语。利用链中借助可控的大池块(ThNm)布置伪造结构(RTL_BITMAP),然后调用 RtlSetAllBits 位图操作把目标内核地址范围写成全 1,从而将 _TOKEN.Privileges(Present/Enabled)置满以开启全部特权,最终配合 token 操作/进程句柄能力完成本地提权到 SYSTEM。
利用效果: 本地提权

Exp
ly4k/CallbackHell: Exploit for CVE-2021-40449 - Win32k Elevation of Privilege Vulnerability (LPE)
Kernel/Windows/CVE-2021-40449 at master · mowenroot/Kernel
GDI
GDI(图形设备接口,Graphics Device Interface)是Windows操作系统中的一个图形子系统,它为应用程序提供了绘制图形和处理图形输出的功能。GDI提供了一系列API,允许开发者绘制线条、矩形、圆形、文字以及处理位图、图像等。GDI本身并不涉及硬件操作,而是通过设备驱动程序与显示设备(如显示器、打印机等)交互。
GDI的作用是为开发者提供一种抽象的图形渲染方式,程序员不需要直接与硬件交互,就可以使用高级图形接口进行绘制。应用程序通过GDI向操作系统发送请求,操作系统再通过设备驱动程序与硬件进行通信,从而实现图形的显示或打印。
GDI的核心组成
- 设备上下文(Device Context, DC) GDI中的设备上下文是与绘图设备(显示器\打印机)相关的结构,它包含了绘图时所需的信息,如当前的绘图颜色、字体、线条宽度等。通过设备上下文,应用程序可以在不同的设备上进行绘图。
- GDI对象 包括画笔(Pen)、画刷(Brush)、字体(Font)、位图(Bitmap)等,这些是GDI操作图形时使用的基本对象。它们定义了图形的各种属性,例如颜色、线型、填充样式等。
- 绘图函数 GDI提供了一系列函数来绘制线条、圆形、矩形等形状,并支持文本和位图的渲染。例如,
LineTo函数可以绘制线条,Rectangle可以绘制矩形,TextOut可以绘制文本。
- 打印和显示支持 GDI也负责与打印机交互,使得应用程序可以通过GDI将内容输出到打印设备。它可以处理从显示器到打印机的图形渲染,确保一致的输出效果。
Demo
写个Demo帮助快速了解GDI中打印机的基本使用。
“枚举打印机 → 打开默认打印机 → 获取 DEVMODE → 创建打印 DC → 打印 → ResetDC 修改设备状态 → 继续打印 → 结束作业”
1、利用 EnumPrintersA 偏移所有本地打印机,并打印输出。
2、利用 GetDefaultPrinterA 获取默认打印机名称 printerName ,printerName 很重要后续操作都会通过打印机名称来打开,获取上下文句柄等
3、通过 OpenPrinterA 打开打印机设备,通过 DocumentPropertiesA 来获取 DEVMODE(打印设备的状态描述块:页面方向、分辨率等)。
4、CreateDCA 创建上下文(DC),正常的打印流程 StartDocA -> StartPage -> TextOutA -> EndPage。但是在Demo中加了 ResetDCA 用来更改了页面方向,然后继续打印。
在本CVE中重点为 ResetDCA ,ResetDCA 的作用为修改 DEVMODE ,后续会详细展开。通过这个Demo 会打印两张,因为修改了DEVMODE会导致页面方向不一致。


#include <windows.h>
#include <winspool.h>
#include <stdio.h>
#pragma comment(lib, "Winspool.lib")
#pragma pack(16)
#define CLOSE printf("\033[0m\n");
#define RED printf("\033[31m");
#define GREEN printf("\033[36m");
#define BLUE printf("\033[34m");
#define YELLOW printf("\033[33m");
#define COLOR_GREEN "\033[32m"
#define COLOR_RED "\033[31m"
#define COLOR_YELLOW "\033[33m"
#define COLOR_BLUE "\033[34m"
#define COLOR_DEFAULT "\033[0m"
#define showAddr(var) fprintf(stderr, COLOR_GREEN "[*] %s -> %p\n" COLOR_DEFAULT, #var, var);
#define logu(fmt, ...) fprintf(stderr, "[$] " fmt "\n" , ##__VA_ARGS__)
#define logd(fmt, ...) fprintf(stderr, COLOR_BLUE "[*] %s:%d " fmt "\n" COLOR_DEFAULT, __FILE__, __LINE__, ##__VA_ARGS__)
#define logi(fmt, ...) fprintf(stderr, COLOR_GREEN "[+] %s:%d " fmt "\n" COLOR_DEFAULT, __FILE__, __LINE__, ##__VA_ARGS__)
#define logw(fmt, ...) fprintf(stderr, COLOR_YELLOW "[!] %s:%d " fmt "\n" COLOR_DEFAULT, __FILE__, __LINE__, ##__VA_ARGS__)
#define loge(fmt, ...) fprintf(stderr, COLOR_RED "[-] %s:%d " fmt "\n" COLOR_DEFAULT, __FILE__, __LINE__, ##__VA_ARGS__)
#define die(fmt, ...) \
do { \
loge(fmt, ##__VA_ARGS__); \
loge("Exit at line %d", __LINE__); \
exit(1); \
} while (0)
#define debug(fmt, ...) \
do { \
loge(fmt, ##__VA_ARGS__); \
loge("debug at line %d", __LINE__); \
getchar(); \
} while (0)
int main(void)
{
DWORD flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS;
PRINTER_INFO_2A* pi = NULL;
CHAR* printerName = NULL;
HANDLE hPrinter = NULL;
BYTE* buffer = NULL;
DEVMODEA* dm = NULL;
DOCINFOA di = { 0 };
DWORD returned = 0;
BOOL bFlag = FALSE;
DWORD needed = 0;
LONG lNeeded = 0;
HDC hdc,hdc2 ;
hdc = hdc2 = NULL;
// 遍历所有本地打印机
// 第一次:获取所需缓冲区大小
EnumPrintersA(
flags,
NULL, // 本机
2, // PRINTER_INFO_2
NULL,
0,
&needed,
&returned
);
if (needed == 0) {
loge("[!] No printers found or EnumPrintersA failed");
goto error;
}
buffer = (BYTE*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, needed);
if (!buffer) {
loge("[!] HeapAlloc failed");
goto error;
}
//第二次:真正枚举
if (!EnumPrintersA(
flags,
NULL,
2,
buffer,
needed,
&needed,
&returned))
{
loge("[!] EnumPrintersW failed: %lu\n", GetLastError());
goto error;
}
pi = (PRINTER_INFO_2A*)buffer;
logu("=== Printers: %lu ===\n\n", returned);
for (size_t i = 0; i < returned; i++)
{
printf("[Printer %lu]\n", i);
printf(" Name : %s\n", pi[i].pPrinterName);
printf(" Driver : %s\n", pi[i].pDriverName);
printf(" Port : %s\n", pi[i].pPortName);
printf(" Attr : 0x%08lx\n", pi[i].Attributes);
printf("\n");
}
// 获取默认打印名称
bFlag = GetDefaultPrinterA(NULL, &needed);
if (!needed ) {
loge("[!] GetDefaultPrinterA failed");
goto error;
}
printerName = (CHAR*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, needed);
if (!GetDefaultPrinterA(printerName, &needed)) {
loge("[!] GetDefaultPrinterA failed");
goto error;
}
logu("Default printer: %s", printerName);
// 打开设备
if (!OpenPrinterA(printerName, &hPrinter, NULL)) {
loge("OpenPrinterW");
goto error;
}
// 获取 DEVMODE
lNeeded = DocumentPropertiesA(NULL,hPrinter,printerName,NULL, NULL, 0);
if (lNeeded <= 0) {
loge("DocumentPropertiesW(size query)");
goto error;
}
dm = (DEVMODEA*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (SIZE_T)lNeeded);
lNeeded = DocumentPropertiesA(NULL, hPrinter, printerName, dm, NULL, DM_OUT_BUFFER);
if (lNeeded != IDOK) {
loge("DocumentPropertiesW(get) failed, return=%ld\n", lNeeded);
goto error;
}
logu("Successfully obtaining information DEVMODEA");
// hdc 上下文句柄,
hdc = CreateDCA("WINSPOOL", printerName, NULL, dm);
memset(&di, 0, sizeof(di));
di.cbSize = sizeof(di);
di.lpszDocName = "Mowen Job";
logi("StartDoc");
// 开始打印任务
if (StartDocA(hdc, &di) <= 0) {
loge("StartDocA");
goto error;
}
if (StartPage(hdc) <= 0) {
loge("StartPage(Page1)");
EndDoc(hdc);
goto error;
}
TextOutA(hdc, 200, 200, "Page 1:MOWEN", 12);
TextOutA(hdc, 200, 260, printerName,strlen(printerName));
EndPage(hdc);
logu("Change DEVMODE");
// 更改 DEVMODE
dm->dmFields |= DM_ORIENTATION;
dm->dmOrientation = DMORIENT_LANDSCAPE;
lNeeded = DocumentPropertiesA(NULL, hPrinter, printerName, dm, dm, DM_IN_BUFFER | DM_OUT_BUFFER);
if (lNeeded != IDOK) {
loge("DocumentPropertiesW(get) failed, return=%ld\n", lNeeded);
EndDoc(hdc);
goto error;
}
hdc2 = ResetDCA(hdc, dm);
if (!hdc2) {
loge("ResetDCA");
EndDoc(hdc);
goto error;
}
hdc = hdc2;
if (StartPage(hdc) <= 0) {
loge("StartPage(Page1)");
EndDoc(hdc);
goto error;
}
TextOutA(hdc, 200, 200, "Page 2:MOWEN", 12);
EndPage(hdc);
EndDoc(hdc);
logi("EndDoc");
error:
if (dm) {
HeapFree(GetProcessHeap(), 0, dm);
}
if (printerName) {
ClosePrinter(printerName);
HeapFree(GetProcessHeap(), 0, printerName);
}
if (hdc) {
DeleteDC(hdc);
}
if (buffer){
HeapFree(GetProcessHeap(), 0, buffer);
}
system("pause");
return 0;
}ResetDC
简单一句话 ResetDC ≈ 在“打印进行中”重建内核 GDI 对象
ResetDC 在重置上下文的时候不销毁 HDC,但:
- 释放旧的 DC 内部对象
- 使用新的 DEVMODE
- 重建内核侧 DC / PDEV / SURFACE
ResetDCA 调用链 gdi32full!ResetDCA -> gdi32full!NtGdiResetDC 然后会调用到 win32kfull!NtGdiResetDC 进行重置工作

GreResetDCInternal
win32kfull!NtGdiResetDC 没啥具体实现主要在 win32kfull!GreResetDCInternal。

win32kfull!GreResetDCInternal
该函数目的:在不销毁 HDC 的前提下,使用新的 DEVMODE 重建底层 DC / PDEV / SURFACE,并把“新 DC”无缝替换到旧 HDC 上。
1、通过 hdc 获取内核态 DC 对象 dco,使用 DCOBJ::DCOBJ(hdc) 从 hdc → dco,并做一些 check。
2、从 dco 中取出当前 PDEV,**PDEV(Physical Device)**是 DC 与具体设备驱动之间的桥梁
HDC (用户态句柄) ↓ PDC (内核 DC 对象:绘制状态) ↓ PDEV (物理设备抽象) ↓ SURFACE (真正的绘制/输出目标) ↓ 显示驱动 / 打印驱动
3、调用 XDCOBJ::bCleanDC 清理旧 DC 资源,并且引用必须为一个,多个会无法重置。

4、新的 DEVMODE调用 hdcOpenDCW 重新创建 newDC ,会创建全新的 DC + PDEV + SURFACE ,并迁移旧 DC 状态,这里使用了 PDEV中的指针函数用来初始化用户态驱动状态。

这里的 v19 为 UMPDDrvResetPDEV :让“用户态打印驱动”根据新的 DEVMODE,重新初始化内核侧 PDEV 所对应的设备状态。

最后使用 HmgSwapLockedHandleContents 交换 hdc,这样在用户的句柄来说是无感的,用原来的 hdc 还能对原有设备操作。
5、完成一些清理操作:删除旧 DC 的底层对象,为新 DC 重新绑定 Surface 和驱动回调。

通过上面的分析 ResetDC 可以发现打印机和显示设备不一样,很多打印驱动是用户态实现(UMPD),内核并不知道驱动私有结构等内部关系,所以 PDEV 的“重置逻辑”必须交给用户态驱动来完成,而很明显这个函数在WIN10 1809中调用可以被用户劫持。
UMPDDrvResetPDEV 代表了一次“内核对象生命周期中途,主动让用户态代码介入重建核心内核对象(PDEV)”的过程。该回调发生在旧 DC 已被清理、新 DC 尚未完全绑定的状态窗口内,一旦用户态驱动在回调期间破坏对象一致性(提前释放等异常操作),就可能导致内核安全受到威胁。正如最开始说的所以不论什么时候进行跨域调用危害性都是特别大的,一定要做好各种保护......
利用手法V1.0
经过上面的分析我们已经可以写出基本poc了
1、劫持打印机驱动的函数表 UMPDDrvResetPDEV 篡为能使内核崩溃的任意地址。

成功崩溃,但是我们想要的提权并不是简单的崩溃。

hdcOpenDCW
在 win32kfull!GreResetDCInternal 中使用 hdcOpenDCW 来创建新的 hdc,win32kbase!hdcOpenDCW中分为两种模式一个是显示器还有一个是打印机,在创建打印机 DC 的流程如下。
1、通过 di_1->pDriverPath (DRIVER_INFO_2W)加载 UMPD LDEV wrapper

2、通过PDEVOBJ::PDEVOBJ初始化 PDEV,在调用 GreCreateDisplayDC 用创建的 PDEV 创建 DC。

PDEVOBJ::PDEVOBJ
在初始化 PDEV 时,可以追溯到 ppdev->apfn[] 初始化的过程

而后会使用 PDEVOBJ::EnablePDEV 进行激活

而 PDEVOBJ::EnablePDEV 也是一个回调用户

这里回调函数为 ccd_DrvEnablePDEV ,同样的我们也可以劫持这个函数。

那其实可劫持的地方还有很多,利用多点劫持就可以打一套组合拳,并且这样的设计理念在不做好保护的情况下会让存在回调用户的API全部处理危险的状态,这里以 ResetDC 举例仅仅是方便提权。
RtlSetAllBits 利用原语
RtlSetAllBits 是一个非常经典的 “利用原语” (Exploit Primitive)。它的作用是将任意地址的内存位全部置为 1。


调用 RtlSetAllBits(ptr) 时,内核会做类似如下的操作:
- 读取
ptr->Buffer指向的地址。
- 读取
ptr->SizeOfBitMap确定长度。
- 将
Buffer指向的内存区域的所有位都设置为 1 (即写入0xFFFFFFFF...)。
这里 buffer 可以通过 ThNm 进行预测。

提前把 token 地址布置在 ptr->Buffer 上,并合理控制 SizeOfBitMap,通过 ThNm 大堆块预测出地址,后续调用 RtlSetAllBits 把 Present、Enabled 全部置 1 开启全部权限。
dt nt!_TOKEN ffffa501`80c4c980

dt nt!_SEP_TOKEN_PRIVILEGES 0xffffa501`80c4c980+0x40

- Present(token 拥有哪些特权)
- Enabled(当前启用哪些特权)
- EnabledByDefault(默认启用哪些特权)

利用手法V2.0
1、获取当前进程的 token 地址,在线程中申请大块,通过 ThNm 预测到申请的内核地址 FakeTokenAddr。
2、劫持打印机驱动的函数表 UMPDDrvResetPDEV 篡为 MyHookFunc,MyHookFunc 主要在调用原来函数的情况下, 进行 ResetDCA释放了原有 handle 的资源,并马上使用堆喷占用原有资源的空间。

3、堆喷进行劫持 ppdev->pfnUnfilteredBitBlt 为 FakeTokenAddr,UMPDDrvResetPDEV 为 RtlSetAllBits,完成提权。

参考连接
ly4k/CallbackHell: Exploit for CVE-2021-40449 - Win32k Elevation of Privilege Vulnerability (LPE)
奇安信攻防社区-Microsoft CVE-2021-40449漏洞分析与利用
CVE-2021-40449 Win32k提权漏洞及POC分析-先知社区