
在 Windows 11 Insider 版本 10.0.28020.2149 上,win32k 桌面堆的用户态映射在偏移 0x100 处暴露了一个原始的内核会话池指针。任何拥有桌面的进程都能读取它。这包括低完整性进程、AppContainer、零能力的 LPAC,以及一个与浏览器渲染进程完全一样的低完整性 AppContainer。仅凭这一个泄露的 QWORD,你就可以还原内核桌面堆基址,并在此之上借助 gSharedInfo,计算出桌面上每个窗口、菜单和类对象的精确内核虚拟地址。
这是一个 KASLR 绕过,属于信息泄露。它本身并不是代码执行。我想先说明这一点,因为夸大一个漏洞是让漏洞分析文章变得不可读的最快方式。它的本质是:从一个本不该被触及的位置产生的、非常干净的信息泄露。
CVE-2026-50416。这篇文章记录了该漏洞、概念验证,以及真正让我警觉的部分:内核指针在跨越哪些沙箱边界后依然暴露。
每个加入桌面的进程,都会在其地址空间中映射一份只读的桌面堆。内核将窗口和菜单对象写入这个堆,用户态从中读取,这是正常且必要的。而不必要的是:该映射的偏移 0x100 处包含一个指向会话池的活动内核指针。在它对用户态可见之前,没有人对它进行净化处理。Windows 已经知道如何净化这个堆中的内核指针——它使用 0x6000000000 哨兵值来处理其他字段,唯独这个被遗漏了。
win32k 是拥有窗口、菜单、光标、钩子以及整个图形对象世界的内核组件。win32k 的大量状态按桌面存放在一个名为桌面堆的结构中。为了加快窗口操作,内核将这块堆的一部分以只读共享节的形式映射到每个加入桌面的进程中。你的进程无需系统调用,就能直接从该映射中读取窗口的文本或样式。正是这种共享的假象,让 GUI 显得即时响应。
从用户态找到它既简单又有文档可查。TEB 中有一个 ClientInfo 结构,桌面堆的用户态地址就位于 ClientInfo[5]。代码如下:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID* clientInfo = (PVOID*)((BYTE*)teb + 0x800);
BYTE* desktopHeap = (BYTE*)clientInfo[5];
这就是全部诀窍。目前还没有漏洞,这部分是设计使然。
漏洞在于这个堆偏移 0x100 处的内容。
ULONG64 leaked = *(ULONG64*)(desktopHeap + 0x100);
在测试的构建版本上,这个 QWORD 保存着类似 0xFFFFA804DDE00040 的值。这是一个规范的内核地址,八字节对齐,指向会话池,正好位于这同一个桌面堆内核侧 0x40 字节处。
在相信它之前,我做了常规的合理性检查。
所以这是一个真实的、持久的、贯穿整个启动会话的内核地址,不是垃圾数据,也不是过期的旧值。属性四和五合在一起,正是 KASLR 随机化地址的特征:在一次启动内恒定不变。而这恰恰是 KASLR 应当隐藏的东西。
好奇的话可以告诉你,我实际测试会话中的值是 0xFFFFC600DCC00040。形态相同,行为相同。你的会不同,因为 KASLR。这某种程度上正是关键所在。
单个泄露的地址不错,但当它能转化为任何特定对象的地址时,才真正有用。
有两个事实在起作用。
第一,泄露的值指向内核桌面堆内 0x40 字节处。因此内核桌面堆基址就是泄露值减去 0x40。
kernel desktop heap base = leaked minus 0x40
第二,user32 导出一个名为 gSharedInfo 的结构。它提供全局句柄表(aheList)以及单个句柄表项的大小(HeEntrySize,x64 下为 0x20)。每个 HWND 实际上就是该表中的一个索引。取 HWND 的低 16 位,定位到对应表项,表项的第一个 QWORD 就是该窗口对象在桌面堆内的偏移。
合在一起:
offset = gSharedInfo.aheList[HWND & 0xFFFF].offset
kernel addr = kernel desktop heap base plus offset
这就是给定 HWND 所对应的窗口对象的精确内核虚拟地址。不是猜测,不是喷射,就是真实地址。
概念验证创建了六个不同类别的窗口(STATIC、BUTTON、EDIT、LISTBOX、SCROLLBAR、COMBOBOX)并解析了全部六个地址。它还会扫描整个堆,发现 0x100 之外还有另外六七个未净化的内核指针,所以 0x100 只是最可靠的那个,而不是唯一的。
下面这部分把它从"有趣"提升到了"真正令人担忧"。
我写了第二个程序,在权限逐步收紧的上下文中生成子进程,并询问每个子进程能否读取同一个指针。这些上下文按它们"本应能做的事越来越少"排序:
CreateDesktop 创建。泄露,但因拥有自己的桌面堆而值不同。上下文一至五都返回了相同的内核地址,因为它们共享默认的桌面堆。上下文六返回了不同的地址,因为那是不同的堆,但技巧完全一样。
第五个值得仔细看。一个以低完整性运行、位于 AppContainer 内、持有零能力的进程,在 Windows 上已经是用户态进程能锁到的极限了。这就是浏览器放置渲染进程的沙箱。这个沙箱能读取内核地址。
这件事让人刺痛是有原因的。浏览器的整个纵深防御模型都假设:即使攻击者通过另一个引擎漏洞在渲染进程内获得代码执行,沙箱也能控制住损害并隐藏内核。KASLR 是这种隐藏的重要一环。而这个泄露深入沙箱内部,无需额外的系统调用、无需提权,就把内核地址交给了攻击者。沙箱完美地履行了自己的职责,信息却还是从它脚下漏了出去——穿过一个被沙箱模型视为良性的共享节。
我一直等着意外的转折。想必你得先创建一个窗口,或者调用某个沙箱会注意到的 GUI 系统调用吧。不。
在这个构建版本上,桌面堆映射在 user32.dll 加载之前就已经存在于进程地址空间中了。验证程序在加载 user32 前后各读了一次 desktop_heap[0x100],两次读到的都是同一个内核指针。没有 CreateWindow,没有 GetDesktopWindow,什么都没有。任何与桌面关联的进程——包括无头服务和后台工作进程——一启动就能读到它。
渲染进程验证正是依赖这一点。它在低完整性的 AppContainer 中启动一个无能力的子进程,让它只加载 user32 而别无其他,子进程在启动瞬间就读到了这个指针。逐字输出:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
IL=0x1000 表示低完整性。AC=1 表示位于 AppContainer 内。NoWindowCreated 就是字面意思:没有创建任何窗口。
既然已经在里面了,我就把扫描器对准这个堆,看看还有什么别的东西。可不止是指针。
其他进程的窗口标题。扫描器在堆中发现了来自其他进程的二十个独特的 UTF16 标题,每个都与 EnumWindows 交叉核对,确认属主 PID 确实拥有该标题的窗口。Chrome 标签页、Discord、资源管理器、Spotify、托盘窗口。这个堆就像一块公告板,展示着桌面上每个人在做什么,而任何沙箱进程无需一次 IPC 就能读取。
进程 ID。堆中有超过六百个 DWORD 值被证实为真实运行的 PID,每个都通过两种方式确认:OpenProcess 成功,以及该 PID 出现在 EnumWindows 列表中。也就是说,沙箱进程可以悄无声息地枚举桌面上谁开着窗口。
更多内核指针。每次运行能发现六到十个,随桌面活动而变化,不仅仅是 0x100 处的那一个。
有一件事它确实不暴露——我特意检查过:密码编辑控件的文本。我创建了一个带 ES_PASSWORD 的 EDIT,把文本设为 SecretPassword123,然后搜索堆。没有。Windows 会在桌面堆中遮蔽密码字段内容。很好。小小的慰藉。
所以这个堆会泄露地址和窗口标题,但至少保住了你的密码。能在哪里找到胜利,我就从哪里拿吧。
KASLR 的存在是为了让内核利用变得概率化。没有地址信息,win32k 的释放后使用就会退化成盲目的池喷射。你灌满内核分配器,希望替换对象恰好落在悬垂指针所指的位置,然后祈祷。在现代池实现上,这个成功率很低,而且越来越低。
现在把这个泄露交给攻击者。他们知道被释放窗口对象的确切内核地址,可以在那个精确地址上构造替换分配,并精确控制悬垂指针引用的内容。盲目破坏变成了确定性破坏。这就是干净 KASLR 绕过的真正价值:它是一个无关内存破坏漏洞的"可靠性倍增器",而不是一个独立漏洞。
这个泄露具体侵蚀的缓解措施:
gSharedInfo 得知每个窗口对象的精确偏移,然后直接计算内核地址。一次读取就能拿到内核地址的对象:窗口对象(tagWND)、菜单对象(tagMENU)、类对象(tagCLS),以及桌面堆上任何可通过句柄表到达的对象。一次读取,整个桌面尽收眼底。
PoC 目录中有一个 compile.bat,它会找到 Visual Studio 并构建你选择的任意目标。
compile.bat then choose a number from the menu
各目标按展示内容的多少排序:
kaslr_bypass_poc.exe。主程序。逐步展示泄露过程,计算内核基址,解析实时窗口地址,扫描额外指针,并打印完整链路。先运行这个。kaslr_sandbox_proof.exe。在低 IL、AppContainer、LPAC、低 IL 加 AppContainer 以及备用桌面上生成子进程,然后报告每个是否泄露以及结果是否一致。supporting_proof_no_window.exe。证明在任何窗口存在之前泄露就有效。supporting_proof_no_caps_lpac.exe。证明在零能力的 AppContainer 和 LPAC 中也能泄露。supporting_proof_sensitive_data.exe。证明跨进程数据暴露(标题和 PID),以及密码文本不会暴露。supporting_proof_exploitability.exe。解析六个窗口类的内核地址,展示缓解措施的失效。supporting_proof_remote_trigger.exe。渲染进程等价上下文。三个快速检查来让你确信它是真的:
一和二确认它是一个稳定、共享、真实的地址。三确认它是 KASLR 随机化的。合在一起,就是该漏洞。
预期。 用户态桌面堆映射绝不应暴露原始内核指针。桌面堆元数据中的任何内核指针在用户态可见之前都应被净化。
实际。 偏移 0x100 暴露了一个内核会话池指针。每个拥有桌面的进程都能读取它,包括低完整性、AppContainer 和 LPAC 进程。在测试会话中,每个上下文都返回了 0xFFFFC600DCC00040。
最廉价的修复与 Windows 在这个堆中其他地方的做法一致:在桌面堆头部的内核指针到达用户态映射之前对其进行净化,即对其他字段使用的同一个 0x6000000000 哨兵处理。
更强的修复是:如果用户态实际上并不需要头部页面,就彻底停止通过用户态映射暴露该页面。对于微软选择哪个方案,我没有强烈意见。但我有强烈意见的是:原始内核指针不应距离一个零能力渲染进程只有一次 __readgsqword 加一次指针解引用之遥。
我总是不由自主地回到上下文五。一个没有能力的低完整性 AppContainer,本应是"没有任何有趣的东西能逃出来"的盒子。桌面堆映射是沙箱模型很久以前就判定为可以安全保留映射的那类共享资源——因为它是只读的,也因为读取窗口文本是无害的。这两点至今依然成立。变的是:一个头部字段把那扇无害的只读窗口,变成了一条直通会话池的窥视孔。
沙箱没有失败。失败的是沙箱底层的假设。这是两种不同的失败,而第二种更难预见,这大概就是没人发现它的原因——直到你读到偏移 0x100。