Pravin761/CVE-2026-54107

GitHub: Pravin761/CVE-2026-54107

一份关于 Windows win32k 内核窗口对象销毁路径中竞态条件漏洞(CVE-2026-54107)的深度根因分析报告,涵盖漏洞机制、发现推理过程与验证方法论。

Stars: 0 | Forks: 0

# 当 `ValidateHwnd` 不再是关卡:CVE-2026-54107 根因分析

CVE-2026-54107 CWE-362 CVSS 8.8 MSRC 11xxxxx Bounty $8,000

临近凌晨 2 点,目标 VM 不再响应调试器的心跳,并恰好停在了我花费数周时间论证其可达的那条指令上。这不是一个断言失败,也不是损坏的内存池停止——而是一个发生在消息分发路径上的普通访问违例,正在解引用一个早已被另一个线程销毁的对象。 那次中断最终成为了 **CVE-2026-54107**,MSRC 案例编号 **11xxxxx**,并在 **2026 年 7 月的安全更新**中跨 27 个 Windows 产品进行了修复。 这篇文章是解除 embargo 后的那一半故事:根因、为什么这类漏洞会存在,以及引导我找到它的推理过程。具体的利用细节不在本文讨论范围内。 ## 目录 - [1. 为什么是 win32k,以及为什么专门针对窗口对象](#1-why-win32k-and-why-window-objects-specifically) - [2. 让我停下脚步的异味](#2-the-smell-that-made-me-stop) - [3. 根因](#3-root-cause) - [4. 为什么影响评级是现在这样](#4-why-the-impact-rating-is-what-it-is) - [5. 证伪优先——大多数候选者都折戟于此](#5-falsification-came-first--most-candidates-died) - [6. 验证:静态分析给出假设,调试器给出真相](#6-verification-static-gives-hypotheses-the-debugger-gives-truth) - [7. 论在内核研究中使用 AI](#7-on-using-ai-in-kernel-research) - [8. 坦诚的 MSRC 时间线](#8-the-msrc-timeline-honestly) - [9. 概览](#9-snapshot) - [10. 我会对刚入门的人说什么](#10-what-id-tell-someone-starting) - [11. 下一步计划](#11-whats-next) ## 1. 为什么是 win32k,以及为什么专门针对窗口对象 Win32k 是 Windows 图形子系统的内核模式部分。它历史悠久、体积庞大,而且——至关重要的是——它可以从那些本应不受信任的上下文中被访问。最后一个属性就是为什么尽管经历了二十年的加固、过滤和系统调用限制工作,它仍然是一个永久性研究目标的原因。 在 win32k 中,`tagWND` 对象 (`PWND`) 异常有趣,因为它的生命周期同时由不止一种机制管理。一个窗口: - **通过句柄**被引用,经由用户句柄表和 `ValidateHwnd` 样式的查找, - **通过指针**被引用,跨嵌套调用和消息分发被持有, - 被父/子、所有者/被所有者以及线程/桌面关系**隐式**引用, - 并且通过一条必须按正确顺序解绑上述所有关系的**销毁路径**被拆除。 任何具有多条独立引用路径和一条共享销毁路径的对象都值得慢慢研读。这不是漏洞声明——而是一种决定在哪里投入时间的启发式方法。 ## 2. 让我停下脚步的异味 让我盯上这个组件的原因是它的导入面。`win32kfull.sys` 从 `ntoskrnl` 引入了三个截然不同的对象引用原语: ``` 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. 根因 缺陷在于窗口销毁路径中**引用释放与对象销毁之间存在的时间检查/时间使用差距**,且没有对并发执行句柄验证的消费者进行充分的同步。 剥离细节看其核心结构: ``` /* 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 */ } } ``` ``` /* 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`。基于被回收的内存做出的分发决策,是区分“不可靠崩溃”和“安全边界违规”的关键。这种区别正是为什么它是具有 EoP 影响的 CWE-362 漏洞,而不是一个稳定性 bug 的全部原因。 ### 为什么 win32k 的竞态在结构上比看起来更难处理 如果你在其他子系统中“跑赢”过 bug,win32k 会让你感到挫败,因为它的架构在三个方面与你作对。 **窗口具有线程亲和性。** 一个窗口属于创建它的线程。该子系统的很大一部分是围绕“拥有线程就是正在触碰对象的线程”这一假设构建的,这意味着简单的“启动两个线程调用同一个 API”的方法通常不会有任何重叠——你不是在竞态,而是在排队。让两条路径在同一个对象上真正发生碰撞,需要理解哪些操作实际上是在调用者的线程上执行的,而哪些被封送到所有者的线程上。 **消息分发对你进行了部分串行化。** Send 和 Post 的行为不同,跨线程与同线程分发的行为再次表现出差异。一些看起来像是并发机会的东西,在它到达你关心的代码之前,就被悄悄转换成了有序操作。如果你不知道你的触发器属于哪个类别,你就会得出真实竞态不可达的结论——这是一个与“这里没有 bug”看起来完全相同的假阴性。 **临界区隐藏在调用者中。** 该子系统的很大一部分在你所关注的函数之上好几层获取的一个粗粒度锁下运行。这是 win32k 审计中最大的时间杀手:一个没有可见同步的函数,却因为是完全安全的,因为每一条进入它的路径都已经被串行化了。**锁覆盖是一个过程间属性。** 你必须向上遍历调用图,而不仅仅是阅读函数本身。 第三点就是为什么“这个函数里没有锁”作为一个信号几乎毫无价值,也是为什么这次搜寻中的大部分工作都花在了可达性上,而不是缺陷本身。 ## 4. 为什么影响评级是现在这样 MSRC 将其评估为 **重要,权限提升,CVSS 8.8,攻击向量本地,需要身份验证。** 两个属性决定了这一点: **低完整性下的可达性。** Win32k 的消息调用面可以从远低于 SYSTEM 的上下文中访问。这就是它为何与沙箱逃逸链息息相关的原因——一个已经在其沙箱内实现代码执行的渲染器进程,仍然可以触达这个表面。一个内核漏洞的严重程度主要取决于*谁能触碰它*,而不是损坏本身有多巧妙。 **影响分发的损坏。** 损坏一个 `switch` 语句依赖的字段,在性质上比损坏一个仅用于日志记录的字段要糟糕得多。前者将内存 bug 转变成了一个控制流问题。 我想在这里明确一点,因为我见过首次披露 CVE 的文章夸大了这一点:**我演示了竞态和 UAF。我并没有交出一个武器化的 SYSTEM 级别 exploit。** 沙箱逃逸的框架描述的是这类 bug 所属的*链类型*以及为什么这个表面是有价值的——这是关于可达性的论证,而不是声称我已经构建了一个。夸大影响是消耗你在厂商那里信誉度的最快方式,而 MSRC 的评估才是重要的数字,而不是我的。 ## 5. 证伪优先——大多数候选者都折戟于此 没人写的那部分:这不是第一个候选者。它是幸存下来的那一个。 我的工作法则是**候选者在被证明有罪之前,都被假定不会产生漏洞。** 每一个充满希望的形态都会得到一个具体的、写下来的“为什么它不可被利用”的理由,在尝试触发它之前,我会先去尝试确立这个理由。在这个之前我被否决的候选者包括: - 看起来未同步,但实际上被上一层获取的锁给串行化的路径, - “被释放”的对象实际上被缓存而不是被释放的路径, - 确实存在竞态,但低权限用户驱动的任何调用者都无法触达的路径。 这里的每一项都是我*没有*发送给 MSRC 的发现。这才是重点。一个研究人员的吞吐量不在于他们生成了多少候选者——而在于他们能多快地杀死错误的候选者,这样他们就不必在凌晨 2 点还在死守着它们。 **杀死了大多数候选者的三个问题:** 1. **我上方有锁吗?** 这是跨过程的,不是局部的。函数中没有锁并不代表什么。 2. **无特权调用者真的能触达两边吗?** 两个需要不同权限级别的路径之间的竞态不是竞态,而是思想实验。 3. **释放的内存在我能影响的窗口内可回收吗?** 如果销毁在实际意义上是以原子方式完成的,那就没有值得报告的 bug。 ## 6. 验证:静态分析给出假设,调试器给出真相 对 `win32kfull.sys` 的静态分析给了我假设。它永远无法把漏洞直接给我。**在反编译器中是看不到竞态条件的**,因为缺陷不存在于指令中——它存在于交错执行中。 ### 实验环境 | 角色 | 设置 | | --- | --- | | 主机 / 调试器 | Windows 11, WinDbg | | 目标机 | Windows Server 2022, Build 20348.2159 | | 分析 | Kali Linux + Windows 11 VM | | 调试传输 | VMware 串口 COM,主机 → 目标机内核调试 | | 静态分析 | Ghidra via GhidraMCP | | 筛选辅助 | 对反编译输出进行 AI 辅助检查 | 三层插桩完成了实际的工作。 ### 特殊池和 Driver Verifier 这是任何内核 UAF 调查中杠杆率最高的单一步骤。默认情况下,释放的池内存几乎会立即被下一个相似大小的分配重用——这意味着释放后使用通常*不会*发生异常。它会读取其他人的有效数据,继续执行,并在几分钟后在某个不相关的地方爆炸。然后你会花三天时间去审计一个无辜的函数。 特殊池改变了这一点。每次分配都会获得自己的页面,相邻有一个保护页,并且释放的页面被标记为不可访问,而不是被回收。结果是,违规的解引用会在**执行它的指令处**发生异常,而不是在下游: ``` !verifier 0x1 win32kfull.sys ; special pool on the target driver !verifier 0x8 win32kfull.sys ; pool tracking ``` 结合池标签过滤,这就是将“负载下的间歇性 bugcheck”转化为可重现的、可归因的错误的原因。 ### 错误上的池取证 一旦发生异常,问题就在于你看到的是**内存损坏**还是**生命周期 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 ``` *(偏移量、地址和重现细节根据协调披露的规范予以保留。)* ### 确认边界声明 影响并不是通过崩溃来证明的。它是通过**谁能够导致崩溃**来证明的。每一次触发的运行都是从目标机器上一个标准的、非管理员的用户帐户执行的,因为只有从已经特权的上下文中才能到达的内核错误是一个稳定性 bug,而不是安全 bug。检查驱动碰撞的进程的完整性级别是一个只需三十秒的步骤,它决定了你拿到的是赏金案例还是一个 Windows Feedback Hub 条目。 关键的纪律:**我不相信单一的崩溃。** 竞态上的单一崩溃是噪音。让它值得报告的原因是在受控时序下的可重复性——能够说出“这两条,这个顺序,这个时间窗口”并得到相同的异常。这就是 MSRC 可以采取行动的报告与他们作为无法重现而关闭的报告之间的区别。 ## 7. 论在内核研究中使用 AI 我使用 AI 辅助筛选以更快地遍历反编译输出,我将坦率地说明它擅长和不擅长什么。 **擅长:覆盖面。** 阅读大量 HLIL 并标记“这些函数在没有可见同步的情况下触及共享对象”属于模式匹配,而大容量的模式匹配正是这些工具所擅长的。它把数周的粗略浏览压缩到了几天内。 **毫无用处:判断。** 它会自信地叙述一个不存在的攻击链,断言它尚未建立的可达性,并为一个不存在的漏洞生成一份结构精美的报告。每一个结论都必须在 WinDbg 中经过手动验证,然后才能将其写入报告中的任何地方。 将虚构的发现发送给 MSRC 会浪费他们工程师的宝贵时间,并消耗你无法迅速重建的声誉。 ## 8. 坦诚的 MSRC 时间线 | 日期 | 事件 | | --- | --- | | 2026 年 5 月 7 日 | 提交 — VULN-186460 | | 2026 年 5 月 7 日 | 案例开启 — MSRC Case 11xxxxx | | 2026 年 6 月 11 日 | 行为被 Microsoft 确认;赏金审核开启 | | 2026 年 6 月 27 日 | 修复计划于 7 月发布;CVE-2026-54107 被分配(预发布) | | 2026 年 6 月 30 日 | 赏金发放 — 8,000 美元,Windows Insider Preview Bounty Program | | 2026 年 7 月 14 日 | 补丁发布;CVE 公布 | 从提交到确认之间有五周的沉默。这就是考验你的部分。你已经写下了关于别人内核的声明,而你还不知道你的推理是否成立,是否是重复报告,甚至不知道它是否能在他们的构建版本上重现。确认邮件发出的那一刻,它不再是你拥有的理论,而是成为了真实存在的漏洞。 有一件让我嘲笑自己的小事:在我当地时间 7 月 14 日,我向案件发送邮件询问为什么 CVE 还没公布。得到的礼貌回复是:*西雅图还是 7 月 13 日。* Microsoft 的发布日历按照太平洋时间运行。现在我知道了。 ## 9. 概览 | 字段 | 细节 | | --- | --- | | **CVE** | CVE-2026-54107 | | **MSRC 案例** | 11xxxxx (VULN-186460) | | **组件** | `win32kfull.sys` — 窗口对象生命周期 | | **类别** | 竞态条件 → UAF | | **CWE** | CWE-362 | | **影响** | 权限提升 | | **严重程度** | 重要 | | **CVSS v3.1** | 8.8 (高) | | **向量** | 本地,需身份验证 | | **项目** | Windows Insider Preview Bounty Program | | **奖金** | 8,000 美元 | | **修复** | 2026 年 7 月安全更新 | ## 10. 我会对刚入门的人说什么 **为了不变量而阅读,而不是为了 bug。** “这段代码在哪里假设了它没有强制执行的东西?”比“哪里有溢出?”能发现更多东西——尤其是在那些容易利用的漏洞类型已经消失的成熟的、经过大量审计的组件中。 **竞态是一个过程间的声明。** 你不能从单个函数中确立或反驳它。如果你的分析停留在函数边界,你将生成永远无法终结的候选者。 **你的调试环境就是你的工作。** 我在不稳定的串行 COM 链接上损失的时间比实际搜寻的时间还要多,而损坏的 pipeline 产生的假阴性看起来和“这里什么都没有”完全一样。我曾经因为一个 COM 端口设置差点放弃这个目标。 **报告原因,而不是崩溃。** MSRC 会收到很多崩溃。推动案例进展的是关于哪个不变量被破坏以及为什么那里没有强制执行的连贯故事。 **确认并不代表结束。** 在确认和发布的补丁之间,还有可重现性跟进、Canary 行为和审查。保持参与。 ## 11. 下一步计划 相同的方法论,不同的接触面—— `tcpip.sys`、`afd.sys`、`clfs.sys`。我宁愿以一系列的工作成果而闻名,也不愿以一次幸运的发现而闻名,而实现这一目标的唯一方法就是以比我生成候选者更快的速度不断杀死它们。 如果你处在一年前的我所在的位置——来自 web 赏金领域,对内核工作感到好奇,不确定自己是否是能做这件事的那种人——你可以通过实践来找到答案。挑选一个驱动程序。附加一个调试器。慢慢读。不断问自己,如果它运行两次会发生什么。 这就是它真正开始的地方。 由 **Pravin Choudhary** ([@pr4v1nx](https://github.com/pr4v1nx)) 撰写 — 独立的进攻性安全研究员。根据协调披露规范向 Microsoft 披露。利用细节、偏移量和重现代码被有意省略。
标签:CVE, Web报告查看器, win32k, Windows内核安全, 数字签名, 漏洞分析, 竞态条件, 路径探测