对 Windows 内核一直挺感兴趣的,打算先复现 CVE 并熟悉各种模块。在 CVE-2021-1732 中,真正的高危点并非单纯的未初始化内存,而是 win32k 通过 KeUserModeCallback 主动执行用户态回调函数 这一设计。KernelCallback 机制使内核在关键对象构造和状态迁移阶段依赖用户态返回的数据,一旦回调入口或回调协议的完整性被破坏(如 KernelCallbackTable 可被劫持、返回数据语义校验不足),将导致内核执行流和对象状态被用户态间接控制。这类漏洞的危害在于其结构性信任失效,而非单一实现缺陷,所以 KernelCallback 路径本身是极具攻击价值的研究方向。
这类漏洞好似 WEB 中前端给后端传值,我抓包修改了值,后端无条件信任了传入的值,导致漏洞产生,所以在这种类似接收参数的接口中一定要做好校验,无论是内核还是WEB,都不要轻易相信任何传来的数据。
漏洞核心不只是“未初始化内存”本身,而是 win32k 的 KernelCallback 信任边界失效:在创建窗口并分配 WndExtra 的流程中,内核会通过 KeUserModeCallback 调用用户态回调(如 xxxClientAllocWindowClassExtraBytes,函数指针来自 PEB->KernelCallbackTable),并将用户态 NtCallbackReturn 返回的数据写回内核对象字段。攻击者在回调窗口期内调用 NtUserConsoleControl 切换窗口的关键标志位(常见描述为 0x800 的 ConsoleWindow 相关语义),导致同一个字段(如 tagWND 中保存 WndExtra 的值)在后续被内核 按“offset(相对 kernel desktop heap base 的偏移)”而非“pointer(用户态指针)”解释。
当攻击者再通过 NtCallbackReturn 返回可控数值时,就会出现 字段值与解释语义不同步(out-of-sync) 的类型/语义混淆,最终在 kernel desktop heap 相关地址计算中产生 越界读写(OOB R/W)。利用上通常先把相邻窗口对象的关键字段(如 cbWndExtra、spmenu 等)打坏/扩展读写范围,将 OOB 放大为稳定的 任意读 + 任意写,最后通过遍历 EPROCESS->ActiveProcessLinks 找到 PID 4 的 SYSTEM Token 并替换当前进程 Token,实现本地提权到 SYSTEM。

1. EXP 项目地址
https://github.com/mowenroot/Kernel/tree/master/Windows/CVE-2021-1732
2. 环境搭建
Windows 10 1809 系统镜像下载站点:
https://hellowindows.cn/

虚拟机 vm 搭建的时候添加低权限用户,其他默认。

安装完后设置一些基本配置。

下载 dControl v2.1 (完全禁用 Windows Defender)
https://www.52pojie.cn/thread-1853644-1-1.html
并关闭杀毒,防止 Exp 被 Defender 删了。

3. 编译 exp
下载 exp 源码:
https://github.com/mowenroot/Kernel/tree/master/Windows/CVE-2021-1732
使用 Visual Studio 2019 创建一个新项目,选择 Windows 桌面向导:

应用程序类型选择桌面应用程序,并勾选空项目:

源文件处右击,添加一个现有项,选择刚下载的 CVE-2021-1732_Exploit.cpp:

选择 Debug X64:

右击项目属性,关闭优化:

代码生成 -> 运行库选择 多线程调试(/MTd),将运行库静态链接到可执行文件中,否则在虚拟机中运行时可能会报找不到 dll 的错。

链接器 -> 调试 -> 生成经过优化以共享和发布的调试信息 (/DEBUG:FULL),为了后面用 ida 加载 pdb 时有符号。

链接器 -> 高级 -> 随机基址 设置为否(/DYNAMICBASE:NO),固定基址 设置为是(/FIXED),这是为了动调的时候方便下断点。

将上诉所有修改应用后,右击项目,然后生成。

在项目文件夹中的 X64 -> Debug 文件夹,就能看到生成的 exe 和 pdb 了。

4. EXP 执行
下载进程资源管理器 - Sysinternals | Microsoft Learn 然后拷贝 procexp 和 Exp 到虚拟机中。
右键以管理员身份运行 ProcessExplorer,在上方空白处右键 -> Select Columns,勾选上 Integrity Level,就能看到进程的权限了。


拍摄快照(方便恢复),双击运行 ExploitTest.exe,查看进程权限。

让程序继续执行,再看进程权限,已提升为 system。

如果蓝屏了,就恢复快照。
5. 动态调试
下载 dbgview
https://pan.baidu.com/share/init?surl=gyhd2MNyGgOklDVO1xQ8Bw?passwd=aaaa

下载 VirtualKD:
https://github.com/4d61726b/VirtualKD-Redux/releases/tag/2024.3
把 VirtualKD 拷贝到试验机,并管理员运行 vminstall.exe 进行安装。

安装选项,然后会自动重启。

按 F8 选择禁用驱动签名强制,开机即可。


然后在主机上打开 vmmon64.exe,并执行 Run Debugger。

会弹出该窗口(假设 Break 是灰的且 Win 卡住了,那么需要点击一下 Go 继续运行 Win)。

Win10 设置永久禁用驱动程序强制签名。

6. tagWND 结构
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
7. 利用分析
窗口扩展内存
在用户态使用 SetWindowLong 可以设置扩展内存的数据,最终会在 win32kfull!xxxSetWindowLong 函数中实现,该函数目的 -> 把 dwvalue 写到窗口的扩展内存中,并返回旧值。这里有两种模式,一直是直接用户态空间,还有一种是对内核态空间偏移的内核模式注意这里的 pExtraBytes 为一个内核桌面堆的一个偏移值,原先并不是内存地址(相对于 KernelDesktopHeapBase 的偏移语义)。控制标志位为 ptagWNDK->dwExtraFlag & 0x800。

创建窗口可以使用函数 CreateWindowEx 最终会到 win32kfull!xxxCreateWindowEx 使用 win32kbase!HMAllocObject 创建内存后使用 tagWND->ptagWNDk->dwExtraFlag &= ~0x40000000u; 进行初始化,这里只是部分初始化,会存在脏数据影响的情况。

win32kbase!HMAllocObject 中使用 RtlAllocateHeap 进行创建空间,这里 flags 为 0,不会清空内存即这里的 ptagWNDK 没有初始化空,但是 ptagWND 是被初始化了在 调用 Win32AllocPoolZInit 手动使用 memset 进行初始化操作。


经过上面分析,不难发现 tagWND->ptagWNDk->dwExtraFlag 标志位存在脏数据干扰的情况,先看看窗口扩展内存是怎么申请的,然后尝试需要寻找能控制这个标志位的函数。
创建窗口扩展内存
创建扩展内存空间在 win32kfull!xxxCreateWindowEx 中

先使用重载方法 tagWND::RedirectedFieldcbwndExtra<int>::operator!= 进行判断 ptagWNDk->cbWndExtra != 0,这里的 cbWndExtra 在注册窗口类的时候可控。

然后调用函数 xxxClientAllocWindowClassExtraBytes(ptagWNDk->cbWndExtra) 进行申请内存。
该函数先 call KernelCallback[123] 进行用户态的内存申请,然后判断返回长度是否为 0x18,并 check 是否可读。

KernelCallback 表,可以在 user32.dll 中寻找到。
UserClientDllInitialize 初始化的 PEB 的时候使用 NtCurrentPeb()->KernelCallbackTable = apfnDispatch;

这里的 123 项为 __xxxClientAllocWindowClassExtraBytes,

__xxxClientAllocWindowClassExtraBytes 函数实现,就是通过 RtlAllocateHeap 申请用户态内存,但是这里使用 flags 为 8,会内存置零,然后经过 NtCallbackReturn 显式返回内核。内核通过 KernelCallback 进入内核态必须通过 NtCallbackReturn 函数返回。

现在知道窗口的扩展内存 ptagWNDk->cbWndExtra 是怎么申请的了,默认情况下是用户态空间的内存,并不是内核空间地址。先寻找有没有 Api 更改扩展内存的标志位(ptagWNDk->dwExtraFlag)。
修改窗口扩展标志位
在 win32kfull!xxxConsoleControl 中最后的逻辑为:
1、判断了一些标志位和 cbWndExtra 长度。
2、如果原本为内核偏移模式,那扩展内存地址(ptagWNDk->cbWndExtra)直接基于 KernelDesktopHeapBase+ pExtraBytes 的偏移,注意这里的 pExtraBytes 为一个内核桌面堆的一个偏移值,原先并不是内存地址(相对于 KernelDesktopHeapBase 的偏移语义)。
3、如果原本为用户内存寻址模式,使用 DesktopAlloc 创建新的桌面内核空间,然后拷贝原先的数据并计算到 KernelDesktopHeapBase 的偏移值写到 pExtraBytes (相对于 KernelDesktopHeapBase 的偏移语义)。
4、最后会更新扩展内存的值,并更改模式。也就是说不管之前是什么模式,最终都会更改为内核桌面偏移的模式 (KernelDesktopHeapBase + pExtraBytes + offset)。

利用分析
经过上面创建窗口扩展内存的分析,以上过程用 iamelli0t 师傅博客的一张图来总结:

创建窗口的时候并没有初始化对应的窗口扩展标志位,后调用了用户态的函数来创建窗口扩展内存,返回的窗口扩展地址写回 pExtraBytes,这时扩展内存还是为用户态的内存。
但是我们可以通过 user32!ConsoleControl 函数对窗口扩展标志位的 0x800 修改变为内核偏移模式,会设置 pExtraBytes 为内核桌面堆的偏移值。
利用手法v1.0
那按照普通堆喷的手法应该是这样操作的:
1、申请一堆窗口 (CreateWindowEx),并修改窗口标志位 (NtUserConsoleControl),做到内核偏移模式,后进行批量释放。
2、然后申请一个窗口,这个时候因为标志位没初始化,本来模式应为用户空间堆模式,现劫持为内核偏移模式。
3、进行越界读写操作。
但是并没有这么简单就能操作,在默认情况下 pExtraBytes 是为用户空间内存的地址,这会导致在内核偏移模式下,偏移量巨大,而且使用可控的 offset 是四字节,并无法有效控制偏移。

所以我们之前分析扩展窗口空间是怎么创建就很重要了,扩展窗口空间使用 call KernelCallback[123] 从内核态进入用户态进行用户态空间的申请,如果这里的 KernelCallback 表被我们所篡改了,原本 RtlAllocateHeap -> NtCallbackReturn 的流程,我们可以 hook 为 NtUserConsoleControl(先修改模式) -> NtCallbackReturn(&hijack_offset,0x18,0),那这样原本 pExtraBytes 为用户态内存的地址就可以被 hook 为想要的任意偏移。这样在使用 SetWindowLong 设置扩展内存的时候,模式为内核偏移模式,偏移为我们劫持的任意值,就可以完成任意地址写。
借用 iamelli0t 师傅的图来直观感受:

那到这里你就可以发现,如果 dwExtraFlag 在原位置正常初始化了,我们仍然可以利用,就是因为初始化的步骤在前,而 pExtraBytes 申请用户态空间在后,我们可以使用 win32u!NtUserConsoleControl 函数来进行劫持。所以如果 dwExtraFlag 初始化不在 call KernelCallback[123] 之后我们仍然是可以利用的。

这里就触发思考,在 CVE-2021-1732 中,真正的高危点并非单纯的未初始化内存,实际在初始化空间的时候 dwExtraFlag 会被重置,而是 win32k 通过 KeUserModeCallback 主动执行用户态回调函数 这一设计。KernelCallback 机制使内核在关键对象构造和状态迁移阶段依赖用户态返回的数据,一旦回调入口或回调协议的完整性被破坏(如 KernelCallbackTable 可被劫持、返回数据语义校验不足),将导致内核执行流和对象状态被用户态间接控制。这类漏洞的危害在于其结构性信任失效,而非单一实现缺陷,所以 KernelCallback 路径本身是极具攻击价值的研究方向。
这类漏洞好似 WEB 中前端给后端传值,我抓包修改了值,后端无条件信任了传入的值,导致漏洞产生,所以在这种类似接收参数的接口中一定要做好校验,无论是内核还是WEB,都不要轻易相信任何传来的数据。
利用手法v2.0
所以经过上面的分析,我们可以这样操作完成任意地址写:
1、申请 50 个窗口A (CreateWindowEx,cbWndExtra=0x20),保留窗口0和1其他全部进行批量释放。
2、hook KernelCallback[123] 为我们自定义的劫持流,可以为这样:修改模式(NtUserConsoleControl(hwnd,...))->劫持返回参数为窗口0到KernelDesktopHeapBase偏移(NtCallbackReturn(&hijack_offset,0x18,0))。
3、申请一个窗口B,这个时候创建扩展内存空间申请被我们hook返回了一个窗口0到KernelDesktopHeapBase偏移值
4、对窗口B进行扩展内存写(SetWindowLong),这样修改的就是窗口0的内核内存,可以完成越界写。
但是上面利用的时候会出现一个问题,KernelDesktopHeapBaseOffset(窗口0到KernelDesktopHeapBase偏移)是属于内核上下文中的,我们只有 _TAGWND_USER(CreateWindowEx返回的hWnd) 用户态下的窗口句柄,没有内核模式中的窗口句柄,无法知道任何窗口的 KernelDesktopHeapBaseOffset 偏移,也就是 NtCallbackReturn 返回不了我想控制的值。

HMValidateHandle
user32!HMValidateHandle 函数会把一个用户态的 HANDLE,解析后返回对应的内核对象指针。这本质上属于一种逆向工程技巧,用于绕过内核对象访问限制。

通过调试也可以进一步验证返回的 ptagWNDK,第一个8字节为 hwnd 句柄。

那现在我们可以通过 user32!HMValidateHandle 泄露出来内核窗口句柄就可以拿到 KernelDesktopHeapBaseOffset 偏移值。
利用手法v2.1
进一步更新利用手法:
1、申请 50 个窗口A (CreateWindowEx,cbWndExtra=0x20)并保存 hWnd、hWndKernel(user32!HMValidateHandle),保留窗口0和1其他全部进行批量释放,利用 hWndKernel[0] 获取窗口0到 KernelDesktopHeapBase 偏移。
2、hook KernelCallback[123] 为我们自定义的劫持流,可以为这样:
a. 利用 hWndKernel[i]->cbWndExtra 匹配 cbWndExtra=0x1234 的窗口B,这里匹配上的 hWndA 就是新建的窗口
b. 修改模式 (NtUserConsoleControl(hWndA,...))
c. 劫持返回参数为窗口0到KernelDesktopHeapBase偏移(NtCallbackReturn(&hijack_offset,0x18,0)))
3、申请一个窗口B(cbWndExtra=0x1234,和窗口A能区分开就行),这个时候创建扩展内存空间申请被我们hook了,会执行2的步骤
4、对窗口B进行扩展内存写(SetWindowLong),这样修改的就是窗口0的内核内存,可以完成越界写。
借用 in1t 师傅文章中的图片:

现在只能完成内核桌面堆上的任意地址写,也只有特定句柄泄露的内核地址,还没有能力进行内核任意地址读的能力,所以想要提权的话,我们还需要泄露任意内核地址的能力。并且想要窃取 system 进程的 token 得 pEPROCESS 来完成。
回顾最开始的 ptagWND 结构体,pEPROCESS 有两处可以获取。
ptagWND(user layer)
0x10 unknown
0x00 pTEB
0x220 pEPROCESS(of current process)
0x28 ptagWNDk(kernel layer)
0x00 hwnd
0x90 spMenu(analyzed by myself)
0x00 hMenu
0x18 unknown0
0x100 unknown
0x00 pEPROCESS(of current process)
读原语
可以使用 GetMenuBarInfo 函数,进行菜单的信息泄露。在 xxxGetMenuBarInfo 中,不为 WS_CHILD 的情况下,是特别好控制的数据返回,可以伪造 spMenu 并构造 spMenuk->pSelf->rgItems.unknown_for_exploit 为想要读取的地址。

伪造 spMenu 并不难,但是还有一点是需要控制 Wnd->spMenu 为我们伪造的菜单才能读取里面的信息。
在 xxxSetWindowLongPtr() -> xxxSetWindowData() 中当 nIndex 为 -12(GWL_ID) 的时候并且 dwExtraFlag 为 WS_CHILD 的时候可以替换 WND->spMenu 为传入的指针,并且返回原来旧的 spMenu,这里我们即可以伪造 spMenu 又可以泄露原有 spMenu,后续进一步利用原有 spMenu 泄露里面的 _EPROCESS 达到提权的目的。

利用手法 latest
1、申请 50 个窗口A (CreateWindowEx,cbWndExtra=0x20)并保存 hWnd、hWndKernel(user32!HMValidateHandle),保留窗口0和1其他全部进行批量释放,利用 hWndKernel[0] 获取窗口0到 KernelDesktopHeapBase 偏移。
2、hook KernelCallback[123] 为我们自定义的劫持流,可以为这样:
a. 利用 hWndKernel[i]->cbWndExtra 匹配 cbWndExtra=0x1234 的窗口B,这里匹配上的 hWndA 就是新建的窗口
b. 修改模式 (NtUserConsoleControl(hWndA ,...))
c. 劫持返回参数为窗口0到KernelDesktopHeapBase偏移(NtCallbackReturn(&hijack_offset,0x18,0)))
3、申请一个窗口B (cbWndExtra=0x1234,和窗口A能区分开就行),这个时候创建扩展内存空间申请被我们hook了,会执行2的步骤
4、对窗口B进行扩展内存写 (SetWindowLong),这样修改的就是窗口0的内核内存,对窗口0篡改,修改 dwExtraFlag 为 WS_CHILD。
5、伪造 spMenu,使用 SetWindowLongPtr 进行替换 hWnd[0]->spMenu,后续利用读原语获取原来hWnd[0]->OrgSpMenu中的 _EPROCESS,获取到SYSTEM的TOKEN。
6、继续利用窗口B的扩展内存写,修改 hWnd[0] 的 pExtraBytes 为当前进程的 _EProcess,并控制模式为用户态。

7、对 hWnd[0] 使用 SetWindowLong 等Api时,就会写入当前进程的 _EProcess,把之前获取的 SystemToken 替换进去即可完成提权。

结语
通过深入分析 CVE-2021-1732,我们可以清晰地看到,现代操作系统内核的复杂性带来了新的攻击面。KernelCallback 这种机制在提供灵活性的同时,也引入了信任边界问题。对于开发者而言,在设计类似的内核与用户态交互接口时,必须对回调函数的入口和数据返回进行严格的验证。对于安全研究人员来说,理解这类漏洞的底层逻辑,不仅能帮助我们更好地进行漏洞分析和渗透测试,也能为发现和防御新型内核漏洞提供思路。如果你想深入研究 Windows 内核和相关的内存管理机制,欢迎到云栈社区与更多技术爱好者交流探讨。