ValidateHwnd 不再是一道门:深挖 CVE-2026-54107 的根因一个发生在
win32kfull.sys窗口生命周期管理中的释放后使用漏洞——我是如何发现它的,如何说服自己它是真实存在的,以及 MSRC 流程在研究者视角下到底是什么样子。
快到凌晨 2 点时,目标虚拟机不再响应调试器的心跳,并正好在我花了数周论证其可达的那条指令处停了下来。不是断言失败,也不是池损坏停机——而是一次发生在消息分发路径上的普通访问冲突,解引用了另一个线程已经拆除的对象。
那次断点最终成为 CVE-2026-54107,MSRC 案件编号 11xxxxx,在 2026 年 7 月安全更新中修复,涉及 27 个 Windows 产品。
这篇文章是故事中不受禁运约束的那部分:根因、为什么这个 bug 类别是它现在的样子,以及我推导出结论的思路。具体利用细节将不在此展开。
Win32k 是 Windows 图形子系统的内核态部分。它历史悠久、体量庞大,而且——关键点在于——从那些本应不可信的上下文中可以触达它。正是这最后一条属性,让它尽管经过了二十年的加固、过滤和系统调用限制工作,仍然是永久的研究目标。
在 win32k 中,tagWND 对象(PWND)格外有意思,因为它的生命周期同时由不止一种机制管理。一个窗口:
ValidateHwnd 风格的查找;任何拥有多条独立引用路径和一条共享销毁路径的对象,都值得慢慢地读一遍。这不是漏洞声明,而是一个决定把时间花在哪里的启发式方法。
让我在这个组件上停下来的是导入面。win32kfull.sys 从 ntoskrnl 引入了三种不同的对象引用原语:```c
NTSTATUS ObReferenceObjectByPointer(
void *Object,
uint32_t DesiredAccess,
POBJECT_TYPE ObjectType,
char AccessMode);
NTSTATUS ObReferenceObjectByHandle( HANDLE Handle, uint32_t DesiredAccess, POBJECT_TYPE ObjectType, char AccessMode, void **Object, OBJECT_HANDLE_INFORMATION *HandleInformation);
NTSTATUS ObReferenceObjectByName(/* ... */);
三种进入方式,一条 `ObfDereferenceObject` 退出路径。
这并不意味着代码是错的。它意味着**不变量是分布式的** —— 没有任何单个函数单独拥有*“此对象当前仍存活”*,因此正确性取决于每个调用方对被持有的引用类型及其有效期限达成一致。分布式不变量正是竞态条件滋生之处,因为竞态从来都不是你可以在单个函数里看到的逻辑缺陷。它是跨越两段代码所持有假设中的缺陷。
于是,我开始对每个接触 `PWND` 的函数问的问题不再是*“这段代码正确吗?”*,而是:
> **如果这段函数体恰好执行在两个相隔几条指令的线程上,哪一个才是错的?**
## 3. 根本原因
该缺陷是窗口销毁路径中**引用释放与对象销毁之间的检查时/使用时(TOCTOU)间隙**,并且没有针对并发的句柄验证消费者进行充分同步。
概括其结构:```c
/* Thread A — teardown */
NtUserDestroyWindow(HWND hwnd)
{
PWND pWnd = ValidateHwnd(hwnd);
if (pWnd) {
ObfDereferenceObject(pWnd); /* reference released */
/* <-- race window: object may become reclaimable here */
FreeWindowObject(pWnd); /* teardown proceeds on a pointer
no longer guaranteed live */
}
}
modules 目录包含许多示例模块,你可以将其作为创建新模块的模板。如果你想快速开始编写模块,只需复制 example_module 目录,或查看 Module-Template 模块。
通过将模块存储在主仓库之外,我们可以将它们保存在独立的仓库中,避免每次想要包含新模块时都需要 fork 主工具。你还可以为特定任务定制模块,而不会干扰主代码库。
模块应通过 .env 文件使用 COMPOSE_FILE 变量加载。你可以使用 COMPOSE_PATH_SEPARATOR 来指定不同的分隔符。你还可以通过使用 --project-directory 标志或 COMPOSE_PROJECT_DIRECTORY 环境变量从子目录加载模块。请注意,如果从子目录加载,.env 文件也必须放在该子目录中。
目标是使模块完全独立,但又能够无缝地相互交互。未来,我们计划引入一个模块注册表,让人们可以共享、评分和更新模块。
如果你想创建自己的模块,我们强烈建议阅读 Wiki 中的模块创建指南。
Version 1.0.0 : 第一个主要版本,包含核心框架、初始模块和文档。 Version 1.1.0 : 增加了对远程模块加载的支持,改进了容器编排。 Version 1.2.0 : 引入模块注册表概念,修复错误并提升性能。 Version 1.2.1 : 修复了在 Docker 网络层发现的一个关键漏洞,强烈建议升级。 Version 1.3.0 : 添加了新模块,更好的跨平台兼容性,更新了文档。
有关完整的更改历史记录,请参阅更新日志文件。```c /* Thread B — consumer, concurrent / NtUserMessageCall(HWND hwnd, UINT msg, ...) { PWND pWnd = ValidateHwnd(hwnd); / may resolve a handle whose object is mid-teardown */
if (pWnd->fnid == FNID_BUTTON) /* use-after-free */
...
}
要让这一点变得重要,必须满足两件事,而这两件都成立:
**(a) 这个窗口是真实存在的。** `ValidateHwnd` 是理应让基于句柄的访问变得安全的关卡。如果对一个已经开始销毁的对象,校验仍然能够成功,那么这个关卡就不是关卡——它只是一个建议。
**(b) 被释放的内存可以被攻击者影响。** 校验后紧接着读取的字段包括 `fnid`,它驱动消息分发。基于被回收内存做出的分发决策,正是 *“不可靠的崩溃”* 与 *“安全边界被突破”* 之间的差别。这一区别正是它被定性为 CWE-362、具有提权(EoP)影响,而不是稳定性 bug 的全部原因。
> 观察到的**破坏**是释放后使用(use-after-free);而**根因**是 CWE-362,即以不当同步方式并发使用共享资源。这是两回事,MSRC 在意的是后者。**请报告根因,而不仅仅是表象。**
### 为什么 win32k 竞态在结构上比看起来更难
如果你在其他子系统中挖过竞态 bug,win32k 会令你抓狂,因为它的架构会在三个特定方面与你作对。
**窗口具有线程亲和性。** 窗口属于创建它的线程。该子系统的很多部分都建立在这样一个假设上:拥有窗口的线程才是操作该对象的线程。这意味着那种朴素的“开两个线程调用同一 API”的方法往往不会产生任何重叠——你并不是在制造竞态,而是在排队。要让两条路径真正在同一对象上发生碰撞,你需要弄清楚哪些操作实际在调用方线程上执行,哪些操作会被封送到所有者线程。
**消息分发会部分地串行化你的操作。** Send 和 Post 的行为不同,跨线程与同线程的分发行为又不一样。一些看似存在并发机会的操作,在到达你关心的代码之前,就被悄悄转换成了有序操作。如果你不知道自己的触发器属于哪一类,你就会得出“真正的竞态不可达”的结论——这是一个与“这里没有 bug”看起来一模一样的假阴性。
**临界区藏在调用方里。** 该子系统的许多代码都在一个粗粒度锁下运行,而这个锁是在你盯着看的函数之上很远的地方获取的。这是在 win32k 审计中浪费时间最大的一个来源:一个函数没有任何可见的同步机制,却依然完全安全,因为进入它的每一条路径都已被串行化。**锁覆盖是一种跨过程(interprocedural)属性。** 你必须沿着调用图向上走,而不能只读这个函数。
正是这第三点,使得 *“这个函数里没有锁”* 作为信号几乎毫无价值,也解释了为什么这次狩猎中的大部分工作花在了可达性上,而不是缺陷本身。
## 4. 为什么影响评级会是如此
MSRC 将其评定为 **重要(Important)、权限提升(Elevation of Privilege)、CVSS 8.8、攻击向量为本地、需要认证(authenticated)。** 决定这一评级的有两个属性:
**从低完整性级别可达。** Win32k 消息调用面可以从远低于 SYSTEM 的上下文中触达。这正是它与沙箱逃逸链相关的原因——一个已经在其沙箱内实现代码执行的渲染进程,仍然可以触达这一表面。内核 bug 的严重性主要取决于 *谁能够触达它*,而不是破坏手法有多巧妙。
**影响分发的破坏。** 破坏 `switch` 所依赖的字段,在性质上比破坏一个仅被记录的字段更严重。前者把内存 bug 变成了控制流问题。
我想在这里把一件事说准确,因为我见过首次 CVE 作者过度夸大这一点:**我演示了竞态和释放后使用,但我并没有交付一个武器化的 SYSTEM 级利用。** 沙箱逃逸的表述描述了这类 bug 所属的 *链类别*,以及为什么这一表面有价值——这是关于可达性的论证,而不是声称我构建了一个这样的利用。过度夸大影响是消耗厂商信任的最快方式,而 MSRC 的评定才是真正重要的数字,而不是我的说法。
## 5. 证伪在先——大多数候选都被淘汰了
没人写到的部分:这并非第一个候选,而是活下来的那一个。
我的工作准则是:**一个候选者直到被证明有罪之前,都被视为有罪。** 每个看似有戏的模式,我都会写下一条具体的理由,说明它 *为什么不应* 被利用,然后我会在尝试触发它之前,先去验证这条理由。在这之前被我关闭的候选包括:
- 看起来没有同步、但实际上被上一层函数获取的锁串行化了的路径,
- 所谓“已释放”对象实际上是被缓存而并未释放的路径,
- 确实存在竞态、但低权限用户无法驱动任何调用方触达的路径。
这些每一条都是我 *没有* 发给 MSRC 的发现。这才是重点。研究者的吞吐量不在于他们生成了多少候选,而在于他们能以多快的速度淘汰错误的候选,这样他们就不会在凌晨 2 点还抱着这些候选不放。
**淘汰大多数候选的三个问题:**
1. **在我之上是否有代码持锁?** 这是跨过程的问题,不是局部问题。函数内没有锁说明不了任何问题。
2. **非特权调用方是否真的能同时触达两边?** 两条需要不同权限级别的路径之间的“竞态”不是竞态,而是思想实验。
3. **被释放的内存是否能在我可影响的时间窗口内被回收利用?** 如果从实际效果上看销毁是原子完成的,那就没有值得报告的 bug。
## 6. 验证:静态分析给出假设,调试器给出真相
对 `win32kfull.sys` 的静态分析给了我假设,但它永远不可能直接给我 bug。**竞态条件在反编译器中是看不到的**,因为缺陷不在指令里,而在于交错执行。
### 实验环境
| 角色 | 配置 |
| --- | --- |
| 主机 / 调试器 | Windows 11, WinDbg |
| 目标机 | Windows Server 2022, Build 20348.2159 |
| 分析环境 | Kali Linux + Windows 11 VM |
| 调试传输 | VMware 串口 COM,主机 → 目标内核调试 |
| 静态分析 | Ghidra via GhidraMCP |
| 分类辅助 | AI 辅助对反编译输出进行一遍梳理 |
真正起作用的是三个插桩层。
### 特殊池(Special Pool)和驱动程序验证器(Driver Verifier)
这是任何内核 UAF 调查中最具杠杆效应的一步。默认情况下,被释放的池内存几乎会立即被下一个大小相似的分配所复用——这意味着释放后使用通常 *不会触发异常*。它会读取别人的有效数据,继续执行,然后在几分钟后的某个毫不相干的地方引爆。然后你就会花三天时间去审计一个无辜的函数。
特殊池改变了这一点。每次分配都会获得自己的页面,旁边还有一个守护页(guard page);被释放的页面会被标记为不可访问,而不是被回收复用。结果是,肇事的内存解引用会在 **执行它的那条指令处** 触发异常,而不是在后续的下游代码中:```
!verifier 0x1 win32kfull.sys ; special pool on the target driver
!verifier 0x8 win32kfull.sys ; pool tracking
结合池标签过滤,这就把*“负载下的间歇性 bugcheck”*变成了可复现、可归因的故障。
如果你从这篇文章中只记住一点: 在开始之前启用特殊池(special pool),而不是等到卡住之后。
一旦出现故障,问题就在于你面对的是损坏(corruption)还是生命周期缺陷(lifetime bug)。这两者需要不同的报告。池元数据会给出答案:``` kd> !pool
kd> !pool 2一个被*分配*的块,带有看似合理的标签和垃圾内容,指向**损坏**。一个被*释放*的块,或位于特殊池的不可访问页中的块,指向**生命周期 bug**——某些东西在对象死亡后仍握着指向它的指针。这就是*“攻击者在这里写了内容”*与*“这个对象本不该可达”*之间的区别,也是堆损坏报告与CWE-362报告之间的差别。
在确定是哪一种之前,先交叉检查对象类型。`PWND` 具有可辨识的形状;如果你出错的内存仍带有它的残留,那么你几乎可以肯定遇到的是窗口生命周期问题,而不是随机的覆盖写入。
### 在交错上进行实时内核调试
即使有了特殊池,竞态仍然是调度问题,而且**调试器会改变调度。** 这正是竞态工作最令人沮丧之处:工具扰动它所测量的对象。在销毁路径上设置断点恰恰会序列化你试图重叠的那两个线程,于是这个 bug 就会礼貌地消失。
解决之道是停止试图用断点捕捉竞态,而是:
- **人为加宽窗口**——任何延长引用释放与销毁之间间隔的做法,都会让碰撞在正常调度速率下变得可达,
- **增加碰撞尝试次数而不是精度**——持续运行这两条路径,让概率去发挥作用,
- **使用条件断点和一次性断点**,只在感兴趣的状态出现后才启用,而不是每次进入都中断,
- **在故障发生后确认交错**,通过线程状态和堆栈来确认,而不是试图实时观察它。
一旦你得到故障本身,它并不光鲜——在消息分发路径上解引用了一个 `PWND`,而该对象已在另一个线程上完成了销毁,`!pool` 确认该块是被释放而不是被覆盖:```
kd> !analyze -v
EXCEPTION_CODE: (NTSTATUS) 0xc0000005 - Access violation
FAULTING_MODULE: win32kfull
(根据协同披露约定,偏移量、地址及复现细节不予公开。)
影响并非由一次崩溃来证明,而是由谁能触发这次崩溃来证明。每一次触发运行都是在目标上以标准、非管理员用户账户执行的,因为只有从已具备特权的上下文中才能触达的内核故障属于稳定性缺陷,而非安全缺陷。检查驱动碰撞的进程的完整性级别,是一个只需三十秒的步骤,却决定了你手里是一份赏金案例,还是一个 Windows 反馈中心条目。
关键纪律:我不相信单独一次崩溃。 对竞态而言,一次崩溃只是噪声。让它具备报告价值的是在受控时序下的可重复性——能够说出*“这两条路径、这个顺序、这个时间窗口”*,并得到同样的故障。这正是 MSRC 能够处理的报告与被他们以“无法复现”关闭的报告之间的区别。
我使用 AI 辅助分类来更快地梳理反编译输出,我会直说它擅长什么、不擅长什么。
擅长:覆盖攻击面。 阅读大量 HLIL 并标记*“这些函数在没有可见同步的情况下访问了共享对象”*属于模式匹配,而大规模模式匹配正是这类工具的强项。它将数周的快速浏览压缩成了数天。
不擅长:判断。 它会自信地叙述一条根本不存在的攻击链,断言并未建立的可达性,并为并不存在的缺陷生成一份结构精美的报告。每一个结论都必须先在 WinDbg 中经受人工验证,才有资格接近一份报告。
真正需要警惕的失败模式不是工具出错,而是工具在凌晨两点、你正希望它正确时,错误却说得头头是道。
向 MSRC 提交一个凭空捏造的发现,会消耗他们工程师的真实时间,也会让你损失一个无法快速重建的名声。
从提交到确认之间隔了五周的沉默。那才是考验人的部分。你写下了关于别人内核的主张,却完全不知道你的推理是否成立、是否属于重复报告,甚至不知道它能否在他们的构建上复现。收到确认邮件的那一刻,它才从你脑中的一个理论,变成真实存在的漏洞。
有件小事让我自嘲:在我的时区 7 月 14 日,我催问案件为什么 CVE 还未公开。对方礼貌地回复:西雅图现在还是 7 月 13 日。 微软的发布日历以太平洋时间为准。现在我懂了。
阅读时关注不变量,而非缺陷。 “这段代码在哪里假设了它并未强制保证的事情?” 比 “溢出在哪里?” 能发现更多问题——尤其是在那些成熟、经过大量审计、简单缺陷类别已被挖尽的组件中。
竞态是一个跨函数的论断。 你无法从单个函数中证实或推翻一个竞态。如果你的分析止步于函数边界,你就会产生永远无法定论的候选对象。
你的调试环境就是工作本身。 我在不稳定的串行 COM 链路上损失的时间比实际追踪漏洞还多,而一条故障的分析管线会产生与“这里什么都没有”看起来完全相同的假阴性。我差点因为一个 COM 端口设置而放弃这个目标。
报告根因,而不是崩溃本身。 MSRC 收到过太多崩溃。真正推动案件进展的,是一个关于哪个不变量被破坏、以及为何缺少强制约束的连贯故事。
确认了不等于结束。 从确认到补丁发布之间,还有可复现性跟进、Canary 行为观察和评审。保持跟进。
同样的方法论,不同的攻击面——tcpip.sys、afd.sys、clfs.sys。我宁愿以一系列研究成果被人记住,而不是靠一次侥幸的发现,而实现这一点的唯一方式,就是持续以比生成候选对象更快的速度排除它们。
如果你正处于我一年前的位置——来自 Web 赏金领域、对内核研究好奇、不确定自己是不是能做成这件事的人——你只有去做才能找到答案。选一个驱动程序。接上调试器。慢慢阅读。不断追问:如果它运行两次会怎样。
一切确实就是这样开始的。
作者:Pravin Choudhary(@pr4v1nx)——独立进攻性安全研究员。已按协同披露流程向微软披露。利用细节、偏移量及复现代码有意不予公开。
| 日期 | 事件 |
|---|
| May 7, 2026 | 已提交 — VULN-186460 |
| May 7, 2026 | 案件已开启 — MSRC Case 11xxxxx |
| Jun 11, 2026 | 微软确认行为;赏金评审启动 |
| Jun 27, 2026 | 修复定于 7 月版本发布;分配 CVE-2026-54107(预发布) |
| Jun 30, 2026 | 赏金已发放 — US$8,000,Windows Insider Preview Bounty Program |
| Jul 14, 2026 | 补丁已发布;CVE 已公开 |
| 字段 | 详情 |
|---|
| CVE | CVE-2026-54107 |
| MSRC 案件 | 11xxxxx (VULN-186460) |
| 组件 | win32kfull.sys — 窗口对象生命周期 |
| 类别 | 竞态条件 → 释放后使用 |
| CWE | CWE-362 |
| 影响 | 权限提升 |
| 严重性 | 重要(MSRC) |
| CVSS v3.1 | 8.8(高) |
| 攻击向量 | 本地、已认证 |
| 计划 | Windows Insider Preview Bounty Program |
| 奖金 | US$8,000 |
| 修复版本 | 2026 年 7 月安全更新 |