找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3624

积分

0

好友

504

主题
发表于 5 小时前 | 查看: 1| 回复: 0

对 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)。利用上通常先把相邻窗口对象的关键字段(如 cbWndExtraspmenu 等)打坏/扩展读写范围,将 OOB 放大为稳定的 任意读 + 任意写,最后通过遍历 EPROCESS->ActiveProcessLinks 找到 PID 4 的 SYSTEM Token 并替换当前进程 Token,实现本地提权到 SYSTEM。

Windows调试与进程监控界面

1. EXP 项目地址

https://github.com/mowenroot/Kernel/tree/master/Windows/CVE-2021-1732

2. 环境搭建

Windows 10 1809 系统镜像下载站点:
https://hellowindows.cn/

Windows 10 1809 版本镜像选择

虚拟机 vm 搭建的时候添加低权限用户,其他默认。
虚拟机安装向导界面

安装完后设置一些基本配置。
虚拟机处理器配置

下载 dControl v2.1 (完全禁用 Windows Defender)
https://www.52pojie.cn/thread-1853644-1-1.html

并关闭杀毒,防止 Exp 被 Defender 删了。
关闭 Windows Defender 的工具界面

3. 编译 exp

下载 exp 源码:

https://github.com/mowenroot/Kernel/tree/master/Windows/CVE-2021-1732

使用 Visual Studio 2019 创建一个新项目,选择 Windows 桌面向导:
VS2019 新建项目,选择Windows桌面向导

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

源文件处右击,添加一个现有项,选择刚下载的 CVE-2021-1732_Exploit.cpp:
在解决方案资源管理器中添加现有源文件

选择 Debug X64:
VS 编译配置选择 Debug x64

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

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

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

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

将上诉所有修改应用后,右击项目,然后生成。
在解决方案资源管理器中生成项目

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

4. EXP 执行

下载进程资源管理器 - Sysinternals | Microsoft Learn 然后拷贝 procexp 和 Exp 到虚拟机中。

右键以管理员身份运行 ProcessExplorer,在上方空白处右键 -> Select Columns,勾选上 Integrity Level,就能看到进程的权限了。
Process Explorer 中选择显示 Integrity Level 列
Process Explorer 进程列表

拍摄快照(方便恢复),双击运行 ExploitTest.exe,查看进程权限。
Exp 运行后,Process Explorer 显示进程为 Medium 完整性

让程序继续执行,再看进程权限,已提升为 system。
Exp 执行后,cmd.exe 进程权限提升为 System

如果蓝屏了,就恢复快照。

5. 动态调试

下载 dbgview
https://pan.baidu.com/share/init?surl=gyhd2MNyGgOklDVO1xQ8Bw?passwd=aaaa

Microsoft Store 中的 WinDbg 应用

下载 VirtualKD:
https://github.com/4d61726b/VirtualKD-Redux/releases/tag/2024.3

把 VirtualKD  拷贝到试验机,并管理员运行 vminstall.exe 进行安装。
VirtualKD-Redux 安装目录中的文件

安装选项,然后会自动重启。
VirtualKD-Redux 安装程序界面

按 F8 选择禁用驱动签名强制,开机即可。
Windows 启动管理器选择禁用签名强制
高级启动选项菜单

然后在主机上打开 vmmon64.exe,并执行 Run Debugger
VirtualKD Monitor 界面,运行调试器

会弹出该窗口(假设 Break 是灰的且 Win 卡住了,那么需要点击一下 Go 继续运行 Win)。
WinDbg 内核调试器连接成功界面

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

IDA反汇编代码:窗口扩展内存写入逻辑

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

IDA反汇编代码:创建窗口时的内存分配

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

IDA反汇编代码:HMAllocObject 函数细节
HEAP_ZERO_MEMORY 宏定义

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

创建窗口扩展内存

创建扩展内存空间在 win32kfull!xxxCreateWindowEx

IDA反汇编代码:创建窗口扩展内存流程

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

IDA反汇编代码:cbWndExtra 判断操作符重载

然后调用函数 xxxClientAllocWindowClassExtraBytes(ptagWNDk->cbWndExtra) 进行申请内存。

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

IDA反汇编代码:内核回调调用过程

KernelCallback 表,可以在 user32.dll 中寻找到。

UserClientDllInitialize 初始化的 PEB 的时候使用 NtCurrentPeb()->KernelCallbackTable = apfnDispatch;

IDA反汇编代码:KernelCallbackTable 初始化

这里的 123 项为 __xxxClientAllocWindowClassExtraBytes

KernelCallbackTable 函数指针表

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

IDA反汇编代码:用户态回调函数实现

现在知道窗口的扩展内存 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)。

IDA反汇编代码:ConsoleControl 函数修改标志位逻辑

利用分析

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

创建窗口的时候并没有初始化对应的窗口扩展标志位,后调用了用户态的函数来创建窗口扩展内存,返回的窗口扩展地址写回 pExtraBytes,这时扩展内存还是为用户态的内存。

但是我们可以通过 user32!ConsoleControl 函数对窗口扩展标志位的 0x800 修改变为内核偏移模式,会设置 pExtraBytes 为内核桌面堆的偏移值。

利用手法v1.0

那按照普通堆喷的手法应该是这样操作的:
1、申请一堆窗口 (CreateWindowEx),并修改窗口标志位 (NtUserConsoleControl),做到内核偏移模式,后进行批量释放。
2、然后申请一个窗口,这个时候因为标志位没初始化,本来模式应为用户空间堆模式,现劫持为内核偏移模式。
3、进行越界读写操作。

但是并没有这么简单就能操作,在默认情况下 pExtraBytes 是为用户空间内存的地址,这会导致在内核偏移模式下,偏移量巨大,而且使用可控的 offset 是四字节,并无法有效控制偏移。

IDA反汇编代码:扩展内存地址计算逻辑

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

借用 iamelli0t 师傅的图来直观感受:
劫持回调后的利用流程图

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

IDA反汇编代码:窗口标志位初始化逻辑

这里就触发思考,在 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 返回不了我想控制的值。

tagWNDK 内核结构体定义

HMValidateHandle

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

IDA反汇编代码:HMValidateHandle 函数实现

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

那现在我们可以通过 user32!HMValidateHandle 泄露出来内核窗口句柄就可以拿到 KernelDesktopHeapBaseOffset 偏移值。

利用手法v2.1

进一步更新利用手法:
1、申请 50 个窗口A (CreateWindowEx,cbWndExtra=0x20)并保存 hWndhWndKernel(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 为想要读取的地址。

IDA反汇编代码:GetMenuBarInfo 函数信息泄露点

伪造 spMenu 并不难,但是还有一点是需要控制 Wnd->spMenu 为我们伪造的菜单才能读取里面的信息。

xxxSetWindowLongPtr() -> xxxSetWindowData() 中当 nIndex-12(GWL_ID) 的时候并且 dwExtraFlagWS_CHILD 的时候可以替换 WND->spMenu 为传入的指针,并且返回原来旧的 spMenu,这里我们即可以伪造 spMenu 又可以泄露原有 spMenu,后续进一步利用原有 spMenu 泄露里面的 _EPROCESS 达到提权的目的。

IDA反汇编代码:SetWindowLongPtr 中替换 spMenu 的逻辑

利用手法 latest

1、申请 50 个窗口A (CreateWindowEx,cbWndExtra=0x20)并保存 hWndhWndKernel(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篡改,修改 dwExtraFlagWS_CHILD
5、伪造 spMenu,使用 SetWindowLongPtr 进行替换 hWnd[0]->spMenu,后续利用读原语获取原来hWnd[0]->OrgSpMenu中的 _EPROCESS,获取到SYSTEMTOKEN
6、继续利用窗口B的扩展内存写,修改 hWnd[0]pExtraBytes 为当前进程的 _EProcess,并控制模式为用户态。
IDA反汇编代码:写入扩展内存的最终逻辑
7、对 hWnd[0] 使用 SetWindowLong 等Api时,就会写入当前进程的 _EProcess,把之前获取的 SystemToken 替换进去即可完成提权。

成功提权后,进程权限为 System

结语

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




上一篇:从YARN到K8s:Spark生产任务迁移实战与踩坑记录
下一篇:外延技术详解:半导体制造中的晶体生长原理、工艺与产业应用
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-27 21:31 , Processed in 0.487978 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表