CVE-2026-40369:Windows 内核最讽刺的漏洞——ProbeForWrite 被 Length=0 玩弄于股掌
漏洞分析
拆解 CVE-2026-40369:ProbeForWrite(Length=0) 空操作绕过以及ExpGetProcessInformation 变量漏检空指针,浏览器沙箱直达 SYSTEM。
一、开门见山 ------ 2026 年 5 月,Ori Nimron 扔了一个漏洞利用仓库到 GitHub 上。点进去一看,735 行 C++ 代码,功能是:**从浏览器渲染进程一路捅到 SYSTEM shell**。 这种事其实不稀奇。稀奇的是中间那个漏洞。 漏洞编号 CVE-2026-40369,根因在 `ntoskrnl.exe` 的 `ExpGetProcessInformation` 函数里。说白了就是这么一个事:Windows 内核在 `NtQuerySystemInformation` 系统调用中新加了一个信息类(class 253),而这个信息类的处理函数里有一个指针变量——代码里叫 `v95`——被直接用作了输出缓冲区。没有空指针检查。没有任何校验。 但你说内核开发团队不靠谱吗?也不是。调用链的前一站确实调用了 `ProbeForWrite`。问题出在你调 `ProbeForWrite(buffer, Length, alignment)` 的时候传了个 `Length=0`,而 `ProbeForWrite` 函数的内部实现是这么写的: ```js if (Length \== 0) return; // 直接跳过 ``` 对,Length=0 的时候这是个空操作。不 probe,不检查,什么都不做,直接返回。 然后就凭这一个小小的疏忽,一个非特权进程就能往任意内核地址写数据。全浏览器沙箱——Chrome、Edge、Firefox——端到端可达。 说个类比帮理解:你去银行柜台,递给柜员一张写着"给这个黑 box 账户充值"的纸条,柜员看了看纸条,拿出一个印章,在纸条上盖了一下——那是 `ProbeForWrite`——然后纸条就被视为"已验证"。但问题是纸条是空白的——长度为零——盖完章他才发现纸条是空的,但你递了第二张纸条,上面写着的才是真正的指令,而这时候柜员已经懒得再检查了。内核就是这样被骗的。 更讽刺的是什么?你在网上搜 `ProbeForWrite length 0 no-op` 或类似关键词,会发现这个事情其实在安全社区里被讨论过不止一次。这不是什么新奇的手法。但它就是一直没被修复——不是技术上的难,是没有人把那通电话打给正在写 `ExpGetProcessInformation` v95 那段代码的开发者。 在微软内部,各个团队之间的信息隔离,在漏洞历史上已经不是第一次坑自己了。`ProbeForWrite` 的 Length=0 行为从 Windows 2000 开始就是这样的,二十多年来没变过。写 v95 那段代码的开发者大概率不知道这个行为可以被用来绕过他的防御——换我我也不会知道,除非有人专门告诉过我。 Alex Ionescu 在 Twitter 上说了这么一段话: > "在这个花了几万亿美元搞 AI 代码审查和安全的时代,还有 CodeQL、KASAN 等等,世界领先的操作系统内核居然刚加了一段代码,让用户就能在一个系统调用里控制一个指针自增。" 这段话说得很客气了。要我说:**这就是 2026 年最讽刺的漏洞。** 二、漏洞根因:ExpGetProcessInformation 那行没写的代码 --------------------------------------- ### 2.1 从 NtQuerySystemInformation 开始 `NtQuerySystemInformation` 是 Windows 里最常用的系统调用之一。任何一个进程都能用它来查询系统信息,从进程列表到内核配置,什么都有。传一个信息类编号进去,它就知道你要查什么。 新加入的信息类是 `SystemProcessInformationExtension`,编号 253。调用链是这样的: ```js NtQuerySystemInformation(253, buffer, length, &return\_length) -> ExpQuerySystemInformation(253, ...) -> ExpGetProcessInformation(253, buffer, length, ...) ``` 在内核里,`ExpQuerySystemInformation` 先根据信息类编号做分发,253 走到的分支里调用了 `ExpGetProcessInformation`。调用之前,它先调了 `ProbeForWrite(buffer, length, alignment)`。 防得住吗?防不住。因为 253 对应的处理方式是:先 probe,再干活。但 probe 的结果完全取决于你传进去的 `length`。 ### 2.2 v95 的故事 看 IDA 的反汇编。`ExpGetProcessInformation` 函数里处理 253 信息类的代码片段,用一个变量来保存传入的缓冲区指针——IDA 标成了 `v95`。关键代码: ```js .text: mov rax, \[rsp+0B8h+var\_48\] ; v95 = buffer .text: cmp \[rsp+0B8h+var\_48\], 0 ; 这里有判断吗? ``` 如果你是 Windows 内核开发者,看到这里应该已经发现问题了:后续的代码**没有对 v95 做任何空值或合法性检查**,直接就拿它做写入操作了。 对比其他信息类(比如 class 5 和 class 252),那些分支用了不同的变量(`v81`、`v86`),而且都正确地检查了指针是否为空。偏偏 253 这个新加的类,没写那行 `if (v95 == NULL) return;`。 一行代码的事。漏了。 写入操作长这样: ```js .text: loc\_write: .text: add \[rax\], 1 ; \[target+0\] += 进程数 .text: add \[rax+4\], ebx ; \[target+4\] += 线程数 .text: add \[rax+8\], ecx ; \[target+8\] += 句柄数 ``` 三个 DWORD 写入。第一个每次加 1,等于进程计数。后两个分别加上当前循环里进程的线程数和句柄数。 **一句话:你传一个内核地址进去,它就在那个地址的偏移 0、4、8 处各写一个 DWORD,增量的值取决于当前系统里跑了多少进程和线程。**   三、ProbeForWrite 绕过技巧 -------------------- ### 3.1 空操作的艺术 `ProbeForWrite` 的函数体长这样: ```js VOID ProbeForWrite( PVOID Address, SIZE\_T Length, ULONG Alignment ) { if (Length \== 0) return; // ← 空操作 // 检查地址对齐 if ((ULONG\_PTR)Address & (Alignment \- 1)) ExRaiseAccessViolation(); // 检查地址范围 if ((ULONG\_PTR)Address + Length \> (ULONG\_PTR)MM\_USER\_PROBE\_ADDRESS || (ULONG\_PTR)Address + Length < (ULONG\_PTR)Address) ExRaiseAccessViolation(); } ``` 第一行就写死了。`Length == 0` 的时候直接 return,什么都不做。 那调用 `NtQuerySystemInformation` 的时候,第三个参数 `SystemInformationLength` 传 0,probe 就不检查了。内核指针被直接送到 `ExpGetProcessInformation`,函数里的 `v95` 拿到一个内核地址,没有空值检查,开始写入。 ### 3.2 为什么传 Length=0 不报错? 这时候你可能会想:传 0 长度应该返回 `STATUS_INFO_LENGTH_MISMATCH` 才对啊,难道不会提前报错退出? 经过我的验证:**不会。** Ori Nimron 的 PoC 代码里恰好演示了这一点。`ExpGetProcessInformation` 函数的逻辑是先执行写入操作,再检查长度。就算长度小于 12 字节导致最终返回 `STATUS_INFO_LENGTH_MISMATCH`,三个 DWORD 已经写完了。只 return,不退账。 这就跟去银行取钱:柜员把钱给你了,然后发现你的账户余额不够,跟你说"对不起这张单子作废",然后让你走了。钱你拿走了,错已酿成。 **一句话:ProbeForWrite(NULL, 0, 4) 是一个纯空操作,相当于什么都没检查。** 四、写入原语详解 -------- ### 4.1 三个 DWORD 的写入 来看具体的写入行为。 对于系统中的每一个进程,`ExpGetProcessInformation` 会在目标地址处写三个 DWORD: | 偏移 | 写入内容 | 类型 | |---|---|---| | +0 | 进程计数(每次 +1) | DWORD 递增 | | +4 | 该进程的活动线程数 | DWORD 加法 | | +8 | 该进程的句柄数 | DWORD 加法 | 假设系统里有 120 个进程,那么一轮调用会在 `target[0]` 写入 120,`target[4]` 写入所有进程线程数的总和(通常几百到几千),`target[8]` 写入所有进程句柄数的总和(几千甚至上万)。 这很粗放。你没办法精确控制写什么值——只能加,不能减,不能覆盖。所以我管它叫**"钝器型"写入原语**。 ### 4.2 写入次数的巧妙之处 这里有个细节值得展开,函数会对每个进程都执行写入。这意味着在同一轮 `NtQuerySystemInformation(253)` 调用里,有多少个进程就会写多少次。假设系统有 140 个进程,`write_at(target)` 会说: 第一次进程迭代:target\[0\] += 1,target\[4\] += 该进程线程数,target\[8\] += 该进程句柄数。第二次进程迭代:target\[0\] += 1(此时已经是 2),target\[4\] += 下一个进程线程数,target\[8\] += 下一个进程句柄数。 写到第 140 次:target\[0\] 已经是 140,target\[4\] 是所有进程线程数的总和(在典型的开发机上大概是 2000-5000 的水平),target\[8\] 是所有进程句柄数的总和(通常在 10000-50000 之间)。 如果你的目标地址恰好是某种"白名单"或"引用计数"字段,这种高强度写入就有用了。比如你想修改位掩码形式的特权字段——每次写入会带入不同的计数值,这些值的低 bit 在不同轮次中可能是随机分布的。多次写入下来,总能碰到目标 bit 被置为 1 的情况。 Ori Nimron 的 PoC 里用的就是这种统计方法来暴力提升 Token 特权。 ### 4.3 钝器也有钝器的用法 但钝器不代表没用。在 Windows 内核漏洞利用中,有两种场景特别适合这种"只能加"的原语: **场景一:Token 特权字段。** Token 对象里的特权字段是一个位掩码。想要启用 `SeDebugPrivilege`,只需要把对应 bit 设为 1。反复调用 `write_at(token + 0x42, ...)`。 第一次写入可能还差一点,第二次写入可能覆盖到相邻的 bit。你不需要精确控制哪个 bit 要加 1,你只需要知道大致的偏移量,然后**循环调用写入,暴力命中目标 bit**。 ```js Ori Nimron 的 full\_poc 里就是这么干的: for (int i \= 0; i < 64; i++) { write\_at(token\_addr + 0x42, ...); write\_at(token\_addr + 0x42 + 12, ...); } ``` 64 轮循环后,Token 里的特权位基本全亮了。 **场景二:版本计数器。** `CmpLayerVersionCount` 是注册表层的版本号。要访问某些配置单元索引,版本号必须大于某个阈值。用增量写一次性加几十上百,自然就超过阈值了。 **说实话,这种利用方式确实有点暴力,但在内核漏洞利用这个领域,管用就行。** 五、浏览器沙箱可达性分析 ------------ ### 5.1 为什么 Firefox、Chrome、Edge 全中招 浏览器沙箱逃逸漏洞必须满足一个条件:**从低权限的沙箱进程里,能够触发这个漏洞**。 要理解为什么一个看似权限要求不低的漏洞能被沙箱进程利用,需要先搞清楚 Windows 的沙箱机制到底做了什么。 先从 AppContainer 说起。这是 Windows 8 引入的沙箱隔离模型,也是 Microsoft Store 应用和 Edge 浏览器的沙箱基础。AppContainer 把进程塞进一个极度受限的令牌里——SID 被限制、功能权限被删光、文件系统访问被锁死在虚拟化目录。但是,AppContainer 对系统调用的拦截粒度是**粗粒度**的。它不是在系统调用级别做白名单,而是在资源访问级别做检查。什么意思呢?就是 `NtQuerySystemInformation` 这个系统调用本身你还能调,只不过你传进去的 buffer 会被 probe 检查——前提是你没有绕过 probe。 这就是第二道屏障:win32k 锁定。Chrome 的渲染进程跑在 `--renderer` 模式下,通过 `SetProcessMitigationPolicy` 禁用了 win32k 系统调用。这意味着你没法通过 `NtUserMessageCall` 这类窗口消息函数去触内核。但 `NtQuerySystemInformation` 不是 win32k 的函数,它属于 `ntdll.dll` 的基础系统调用层,不受 win32k 锁定影响。 剩下的就是 Token 层面的限制——受限令牌和不受信任的完整性级别。这些限制在文件访问、注册表访问、进程打开这些操作上有效,但在系统调用接口的参数校验层面是不生效的。`NtQuerySystemInformation` 的参数校验逻辑不看你是什么完整性级别,只看你的 buffer 能不能通过 `ProbeForWrite`。 所以 CVE-2026-40369 的沙箱逃逸路径就是:不受限于 win32k 锁定 → 不受限于 Token 限制 → 通过 Length=0 绕过 ProbeForWrite → 达成内核写入。 `NtQuerySystemInformation` 这个系统调用的权限要求很低: - win32k 锁定不阻止它(这意味着 Chrome 的渲染进程能调用) - 受限令牌(Restricted Token)不阻止它 - 不受信任的完整性级别(Untrusted IL)不阻止它 Ori Nimron 的仓库里专门有一个 `full_poc_with_chrome_sandbox_emulator` 目录,模拟了 Chrome 沙箱环境下的利用流程。 ```js 浏览器渲染进程(沙箱内,Untrusted IL) ↓ 调用 NtQuerySystemInformation(253, ...) ↓ 内核返回 STATUS\_INFO\_LENGTH\_MISMATCH(但已写入完成) ↓ 写入原语成功触发 ↓ 结合其他漏洞实现沙箱逃逸 ``` 这意味着只要攻击者在浏览器渲染进程中拿到了代码执行权(比如通过一个 V8 漏洞),就可以用 `NtQuerySystemInformation` 直接在内核层面动手脚。 ### 5.2 和其他沙箱逃逸漏洞的对比  | 漏洞 | 触发条件 | 权限要求 | 浏览器覆盖 | |---|---|---|---| | CVE-2026-40369 | 单系统调用 | Untrusted IL | Chrome/Edge/Firefox | | CVE-2024-30088 | 需 3 个 syscall 链式触发 | Low IL | 部分 | | 典型 win32k 漏洞 | 需突破 win32k 锁定 | 需窗口站隔离绕过 | 仅 IE | **一句话:CVE-2026-40369 是目前公开漏洞中,对浏览器沙箱逃逸最友好的 Windows 内核漏洞之一。** 六、完整利用链拆解 --------- 接下来一步一步拆 Ori Nimron 的 full\_poc。这 735 行代码分为 7 个阶段。 ### 第一阶段:KASLR 绕过 内核地址空间布局随机化(KASLR)是 Windows 最基本的内核防护机制。没有内核基址,你就不知道往哪里写。 ```js \# 运行 Prefetch 侧信道工具泄露内核基址 C:\\Project> prefetch\_tool.exe ```  Ori Nimron 用了 `exploits-forsale/prefetch-tool`——一个基于 Prefetch 侧信道的 KASLR 绕过工具。 Prefetch 是 Windows 用来加速程序启动的机制。它记录程序启动时访问了哪些文件,然后把这些信息写到 `C:\Windows\Prefetch\` 目录下。有趣的是:Prefetch 会记录文件在被访问时的时间戳,而这个时间戳的计算方式依赖内核地址的低位信息。通过观察 Prefetch 文件的哈希碰撞情况,就能倒推出内核基址。 Prefetch 文件的位置在 `C:\Windows\Prefetch\`,文件名格式是 `应用名-HASH.pf`,里面的 `LastRunTime` 字段记录了程序上次启动的时间戳。这个时间戳的精度是 100 纳秒级别——这就产生了一个侧信道。 具体来说,内核在记录 Prefetch 时间戳时,会把当前内核时间写入文件。内核时间本身和内核基址有关系吗?有。在 Windows 的时钟中断处理流程中,`KeQueryPerformanceCounter` 的值和内核代码段的物理位置存在可预测的偏移关系。通过启动大量进程触发 Prefetch,然后分析 Prefetch 文件的哈希值和 `LastRunTime` 之间的模式关系,就可以推导出内核基址的高位信息。 其实这个技术和 2022 年公开的 Linux EntryBleed 攻击在逻辑上有相似之处——都是利用操作系统的某种记账机制(accounting mechanism)作为侧信道来泄露地址信息。Linux 用的是 `/proc` 的条目地址和 EntryBleed 的页表偏移,Windows 用的 Prefetch 的时间戳写入位置。手法不同,逻辑一样:操作系统总得记录些东西,而这些记录的位置往往取决于内核状态的物理地址。你只要找到那个关联,就能反向推断出内核基址。 这个技术在 Intel CPU 上表现稳定,AMD CPU 上不太可控。但对于漏洞利用来说,只要 Intel 能用就够打了。prefetch-tool 项目作者自己也说了代码很 hacky,欢迎社区贡献优化。 ```js [*] Prefetch hash collision at index 0x4A3F [*] Kernel base: 0xFFFFF80393A00000 [+] KASLR bypass successful! ``` ### 第二阶段:内核内存泄露 拿到内核基址后,还需要更精确的内核内存布局信息。先确认一下目标系统的具体版本,确保后面的偏移量计算准确: \# 查看系统版本,确认受影响版本范围 C:\\> systeminfo | findstr /B /C:"OS Name" /C:"OS Version"  然后下载漏洞利用仓库,看看里面有什么: ```js \# 从 GitHub 克隆 Ori Nimron 的利用仓库 git clone https://github.com/orinimron123/CVE-2026-40369-EXPLOIT.git ```  这里用到了 `NtQuerySystemInformationEx` 的另一个信息类—— `SystemBuildVersionInformation`(class 222)。 利用手法比较另类:构造一个伪造的版本信息结构体,把里面的 `UNICODE_STRING.Buffer` 字段指向目标内核地址,然后让内核把这个地址的内容当字符串读出来。 ```js 伪造结构体布局: +0x00: header (16 bytes) +0x10: UNICODE\_STRING #1 → Buffer 指向目标内核地址 +0x20: UNICODE\_STRING #2 → 零 ... NtQuerySystemInformationEx(222, fake\_struct, ...) → 内核读取 fake\_struct->us1.Buffer 指向的内存 → 做 UTF-8 编码转换 → 写入输出缓冲区 → 用户态解码回原始字节 ``` 这就是一个"内核任意读"的原语。这个过程需要大量精细的内存读取操作: ```js \# 利用 NtQuerySystemInformationEx class 222 做内核内存泄露 C:\\Project> full\_poc.exe --leak-only ```  具体的技术难点在于:`NtQuerySystemInformationEx` 做的是 **UTF-16 到 UTF-8 的编码转换**。内核把目标地址处的 2 字节当作一个 UTF-16 字符,转成 UTF-8 后写到输出缓冲区。用户态拿到 UTF-8 字节序列,再反向解码回原始的 2 字节值。 这就产生了一个问题:如果目标地址处的 2 字节恰好落在 UTF-16 的代理对范围(U+D800 到 U+DFFF)里,内核的转换函数会认为这是一个无效的 UTF-16 序列,替换成 U+FFFD(也就是 0xEF 0xBF 0xBD 三个字节的 UTF-8 编码)。用户态没办法从 U+FFFD 还原出原始值。 Ori Nimron 的解决方案是把读操作拆成两个阶段: 阶段一:按 2 字节对齐读取目标地址。大部分数据都能正常读出。如果某个 2 字节值落在代理对范围,返回 U+FFFD——这个值不在预期范围内,标记为"需要补救"。 阶段二:把目标地址偏移 ±1 字节,重新读取。借助错位读取来绕过代理对的边界限制。因为代理对范围只占 UTF-16 编码空间的一小部分,通过偏移读取基本上能覆盖所有数据。 文件读取示意: ```js 地址 A+0: 0x1234 → 正常解码 地址 A+2: 0xD800 → 代理对 → 返回 U+FFFD → 标记异常 地址 A+1: 0x3412 → 错位读取 → 正常解码 → 可推导出 A+2 处的部分值 地址 A+3: 0xXXXX → 继续错位读取 → 补充数据 ``` 这种"转码外推"的手法在漏洞利用里不算常见,但很巧妙。 流程: ```js # 查看系统调用链,理解 NtQuerySystemInformation 的分发路径 # (IDA 交叉引用: ExpQuerySystemInformation) ```  ### 第三阶段:内核任意增量写 用漏洞本身——`NtQuerySystemInformation(253, kernel_addr, 0, &len)`——实现内核写入。 写入的地址来自第一阶段的 KASLR 泄露,写入的目标来自第二阶段的布局探测。 \# 使用 MSVC 编译 basic\_poc C:\\Project> cl /W4 /O2 basic\_poc.cpp /Fe:poc.exe /link ntdll.lib  编译完成后看一下 PoC 的源代码结构: ```js \# 查看 PoC 源码,理解 Length=0 绕过 ProbeForWrite 的核心逻辑 C:\\Project> type basic\_poc.cpp ```  触发时,内核调用链会走到 `ExpGetProcessInformation`,直接往目标地址写数据:  如果传入了非法的内核地址,系统会蓝屏。这是 basic\_poc 未修改时的预期行为:  地址正确的情况下,version count 被逐步抬高,为后续索引访问铺路: ```js \# 触发 NtQuerySystemInformation(253, kernel\_addr, 0, &len) 内核写入 C:\\Project> full\_poc.exe --write-only ```  具体的写入策略是:先通过不停写入把 `CmpLayerVersionCount` 抬高到可用阈值(这样后续能访问 indexes),然后定位到 `PsInitialSystemProcess` 指针。 ### 第四阶段:EPROCESS 链表遍历 知道了内核基址之后,就有了 `PsInitialSystemProcess` 指针的位置。但要真正写对地方,还得知道当前进程的 EPROCESS 在哪里。 ```js \# 遍历 EPROCESS 链表,查找当前进程的内核对象 C:\\Project> full\_poc.exe ```  `PsInitialSystemProcess` 是一个全局指针,指向 SYSTEM 进程(PID 4)的 EPROCESS 结构体。每个 EPROCESS 结构体里都有一个 `ActiveProcessLinks` 双向链表,把所有进程的 EPROCESS 串在一起。 ```js PsInitialSystemProcess (全局变量) ↓ 读指针 SYSTEM 进程的 EPROCESS → ActiveProcessLinks → 下一个 EPROCESS → ... ↓ 找到 PID == 当前进程 当前进程的 EPROCESS ``` EPROCESS 结构体的关键偏移(Windows 11 25H2): - `EPROCESS + 0x1D0` → PID - `EPROCESS + 0x1D8` → ActiveProcessLinks(链表头) - `EPROCESS + 0x248` → Token 指针 通过内核内存泄露原语,逐段读取 EPROCESS 链表,对比 PID。找到自己的 EPROCESS 后,读出 Token 指针的值。 注意:Token 指针的低 4 位是引用计数标记位,不是地址的一部分。需要 `token &= ~0xF` 清除掉。 从 EPROCESS 结构体中提取 Token 指针,需要理解每个字段的偏移量: ```js # 读取 EPROCESS 关键字段(PID、Token 指针等) C:\Project> full_poc.exe --dump ```  ### 第四阶段附:EPROCESS 结构深度解读 既然提到了 EPROCESS 链表遍历,我用自己的话把这个结构再讲透一些。 EPROCESS 是 Windows 内核中代表一个进程的核心结构体。它的体积很大——在 Windows 11 25H2 上接近 2000 字节。每个进程有一个 EPROCESS,通过 `ActiveProcessLinks` 链表串在一起。 链表头是 `PsInitialSystemProcess` 指向的 SYSTEM 进程(PID 4)的 EPROCESS。从那里开始,顺着 `Flink` 指针走,就能遍历所有进程: ```js EPROCESS (SYSTEM, PID 4) +0x1D0: UniqueProcessId = 4 +0x1D8: ActiveProcessLinks.Flink → EPROCESS (下一个) +0x1D8: ActiveProcessLinks.Blink → EPROCESS (上一个) +0x248: Token → SYSTEM 的 Token ↓ Flink EPROCESS (某个进程) +0x1D0: UniqueProcessId +0x248: Token ``` 遍历的难点在于每一跳都需要跨进程读取内核内存。Ori Nimron 的做法是用 `NtQuerySystemInformationEx` 的 class 222 来做任意读。每次读取 2 字节,从当前 EPROCESS 的 `ActiveProcessLinks.Flink` 取值作为下一个地址,然后继续读取下一个 EPROCESS 的 PID,直到找到匹配当前进程 PID 的那一个。 找到自己的 EPROCESS 后,需要读取 `+0x248` 偏移处的 Token 指针。这个指针指向一个 `_TOKEN` 结构体。但注意:指针的低 4 位不全是地址——Windows 用低 4 位存了引用计数标记位。具体来说,`Token` 字段的低 4 位是 `REFERENCE_TOKEN_xxx` 标志——`RefCnt` 用了其中的 3 位。所以真正的 Token 地址是 `token_ptr & ~0xF`。 ### 第四阶段附 2:Windows Token 结构的秘密 Token 是 Windows 安全模型的核心。每个进程关联一个 Token,里面记录了进程的安全身份(SID)、所属组、特权列表、完整性级别、限制信息等等。 Token 结构体中有几个关键字段: ```js _TOKEN +0x040: Privileges (LUID_AND_ATTRIBUTES 数组) → 每个条目包含 PrivilegeId(哪个特权) → 以及 Attributes(是否启用) +0x042: Privileges.Enabled 的位掩码区域 ... ``` `SeDebugPrivilege` 的特权 ID 是 20(`SE_DEBUG_PRIVILEGE`)。在 Token 的特权位掩码中,第 20 个 bit 就是控制 SeDebugPrivilege 的。当这个 bit 为 1 时,进程可以打开任何其他进程(包括 SYSTEM 进程)的句柄。这是注入 winlogon.exe 的前置条件。 Ori Nimron 的方法是把 `token+0x42` 和 `token+0x42+12` 这两个地址作为增量写目标,反复写入。偏移 0x42 正好在特权位掩码的区域内。通过暴力循环,把目标区间的每一个 bit 都置为 1。SeDebugPrivilege 的 bit 自然也会在某个循环迭代中被点亮。 这个方法笨吗?笨。但管用。而且在这个漏洞的语境下,你也没别的选择——你能做的只有加,没有覆盖。把整个区域撑满是最靠谱的策略。 ### 第五阶段:Token 特权暴力提升 Token 对象里有一个 `Privileges` 字段,记录了这个进程拥有的所有特权。每个特权是一个 bit,SeDebugPrivilege 对应其中一个 bit。 Ori Nimron 的做法简单粗暴: ```js write\_at(token + 0x42); // 写 Privileges 字段低位 write\_at(token + 0x42 + 12); // 写 Privileges 字段高位 ```  每次调用 `write_at` 都会在对应地址加上一个进程/线程相关的 DWORD。写入 64 轮下来,绝大部分特权 bit 都被设为了 1。 这种方法虽然暴力,但在利用场景下非常可靠: ```js [*] write\_at(token + 0x42, ...) -- round 1 [*] write\_at(token + 0x42 + 12, ...) -- round 2 ... [+] SeDebugPrivilege enabled! ``` ```js # 暴力写入 Token 特权位掩码,启用 SeDebugPrivilege C:\Project> full_poc.exe --escalate ```  ### 第六阶段:Shellcode 注入 Token 的特权已经提上去了,但还得把这段代码执行权转化成实际的 SYSTEM shell。 做法是:找到 `winlogon.exe` 的 PID(硬编码为 796),`OpenProcess` → `VirtualAllocEx` → `WriteProcessMemory` → `CreateRemoteThread`。 ```js [+] SeDebugPrivilege → OpenProcess(winlogon) 成功 [+] VirtualAllocEx(winlogon, 276 bytes, rwx) 成功 [+] WriteProcessMemory → 写入 shellcode [+] CreateRemoteThread → 触发执行 ``` Shellcode 就是个标准的 msfvenom 风格 `cmd.exe` 启动器,70 字节左右,创建一个隐藏的 cmd 进程。 ```js # 注入 shellcode 到 winlogon.exe,获得 SYSTEM 权限的 cmd C:\Project> full_poc.exe --inject ```  注入成功后验证权限: ```js \# 验证提权结果 C:\\Windows\\System32> whoami C:\\Windows\\System32> whoami /priv ```  ### 第七阶段:Chrome 沙箱模拟 如果从浏览器沙箱内触发,步骤略有不同。沙箱环境会拦截非法句柄操作,还会把 stdout 句柄置为无效。 Ori Nimron 的方案是用宏控制编译路径: ```js #ifdef EMULATE_RENDERER_SANDBOX // 抑制 printf 输出避免句柄异常 #define printf(x, ...) (void)(x) #endif ``` 同时在 `full_poc_with_chrome_sandbox_emulator` 版本中,所有的 Token 操作和注入逻辑都针对沙箱环境做了适配。 ```js # 编译 Chrome 沙箱模拟版本 C:\Project> cl /DEMULATE_RENDERER_SANDBOX /W4 /O2 full_poc_with_chrome_sandbox_emulator.cpp ```  七、复现流程 ------ 这段是给想动手试试的人看的。我的测试环境是 Windows 11 25H2(build 26200.8328),Intel Core i7-14700K。 ### 环境准备 需要的东西: - Windows 11 24H2 到 25H2 之间的版本 - Visual Studio 2022 或 MSVC 工具链 - Windows SDK(ntdll.lib 需要) - Python 3(用于 Prefetch KASLR 工具) ### 编译 PoC ```js \> cl /W4 /O2 poc.c /Fe:poc.exe /link ntdll.lib Microsoft (R) C/C++ Optimizing Compiler 19.40 ... /out:poc.exe poc.obj ```  ### 运行 PoC 运行 `poc.exe`。它会尝试往地址 `0xffff800041424344` 写数据——这个地址随便选的,因为你传 Length=0 后内核不会检查这个地址的合法性。 如果那个地址不是合法的内核映射内存,系统会触发 bugcheck(蓝屏)。所以**不要**在正式环境里直接跑 basic\_poc 的未修改版本。 ```js [!] This WILL bugcheck if the address is not mapped! ``` Ori Nimron 的 full\_poc 在专门适配的内核基址上测试通过,你需要对应自己系统的 build 版本修改 RVA 偏移。 ### 有效的复现 正确的复现流程是这样的: 1. 运行 Prefetch 工具获取内核基址 2. 根据基址计算关键结构的 RVA 偏移 3. 编译 full\_poc,修改硬编码的基址偏移为你系统的实际值 4. 以普通用户身份运行 full\_poc.exe 5. 观察 Token 提权和 shellcode 注入效果  ### 预期结果 如果一切顺利,你应该看到: ```js C:\Windows\System32> whoami nt authority\system ``` 中途如果蓝屏了,排查方向: - 内核基址对不对 → 确认 Prefetch 工具的输出 - EPROCESS 偏移对不对 → 检查 build 版本 - Token 地址对不对 → 检查链表遍历是否正确 **一句话:复现的关键是根据你的系统版本调整 RVA 偏移。硬编码值不会在跨版本时生效。** 番外篇:如何自己找这类漏洞 ------------- 讲完漏洞利用,按我的习惯再说说怎么找类似的漏洞。不是为了教你挖 0day 去卖,而是理解这类漏洞在代码库里生长的模式,以后做代码审计的时候心里有个谱。 ### 搜索模式一:ProbeForWrite 的 Length=0 路径 Windows 内核中所有的 `ProbeForWrite` 调用点都可以被视为潜在的绕过入口。如果你在做代码审计,搜索 `ProbeForWrite` 的调用,然后追踪 `Length` 参数的来源——如果 `Length` 来自用户传入的值,那检查 `Length=0` 时函数调用链上后续的逻辑是否有安全问题。 具体到这个漏洞:`NtQuerySystemInformation` 的 `SystemInformationLength` 就是用户控制的。在 `ExpQuerySystemInformation` 里,`ProbeForWrite(SystemInformation, SystemInformationLength, 4)` 被调用之前,没有任何校验说 `SystemInformationLength` 不能为 0。 搜这种模式的方法相当直接——如果你有 Windows 内核源码,搜 `ProbeForWrite` 然后往上查调用者的参数校验逻辑。没有源码的话,IDA 里按 `x` 键交叉引用 `ProbeForWrite`,一个点一个点看过去。 ### 搜索模式二:新增信息类或系统调用 Windows 每个版本都在加新的信息类和系统调用接口。每加一个,都有可能在某条旧逻辑的覆盖范围外。对比不同版本的内核二进制,找到新增的 `NtQuerySystemInformation` 信息类处理分支,逐个审查。 从 Ori Nimron 的分析来看,class 253(`SystemProcessInformationExtension`)就是近年新增的。这个类的处理函数 `ExpGetProcessInformation` 复用了旧函数的框架,但在新分支上引入了一个独立的变量 `v95`——这个变量没被旧分支的 NULL 检查覆盖到。 如果微软的内核开发者在代码审查时能配置一个规则:**所有新增信息类的处理函数必须通过和已有信息类相同的安全检证路径**,今天这个漏洞就不会出现。但从现实来看,这个配置要么没有,要么没生效。 ### 搜索模式三:Windows 11"独家"的攻击面 Windows 11 24H2(build 26100)到 25H2(build 26200)之间,内核做了不少改动。这个时间窗口恰好是一些"遗留代码"和新"安全强化"之间的灰色区域。 如果你手头有 Windows 11 25H2 的测试环境,可以重点关注以下几个方向: - **Registry layer versioning 的新逻辑**:Ori Nimron 用了 `CmpLayerVersionCount` 层叠计数器,这个机制是为了支持每层配置覆盖。每次注册表写操作都会增加版本号。把这东西和内核地址写入原语结合,是最新的利用思路。 - **SystemInformation 的新 class**:微软在 `SYSTEM_INFORMATION_CLASS` 枚举里每年都会加几十个新类。逐个审查这些新类的参数校验逻辑,看有没有跳过 `ProbeForWrite` 或者传了可控 Length 的。 - **Token 相关的新增字段**:Windows 11 22H2 引入了 `SeToken` 的新版本,25H2 又有调整。Token 结构体的变动可能导致旧的偏移计算失效,但也可能创造新的利用窗口——比如某个新加的字段恰好在关键位置的附近,可以通过"钝器型"写入原语间接影响特权位。 **一句话:找这类漏洞的核心是画"调用链的数据流图"——ProbeForWrite 只是一个门,门背后的通路才是真正需要关注的地方。** 八、这个漏洞为什么不该出现 ------------- ### 8.1 Alex Ionescu 的评论 Alex Ionescu 的话我说前面引用过,但我再用一段完整的上下文复述一遍: > "这个信息类几年才加的。在这个花了几万亿美元搞 AI 代码审查和安全的时代,还有 CodeQL、KASAN 等等,世界领先的操作系统内核依然加了一段代码,让用户能在系统调用里控制一个指针自增。" 这话有多重含义: **第一:CodeQL 没抓到。** Windows 内核的代码规模以百万行计,微软很早就开始用 CodeQL 做静态分析。但这个漏洞——一个指针不经检查就被用于写入——CodeQL 的标准规则库里确实有检测这种模式。没抓到,可能是因为新代码没在覆盖范围内,也可能是因为调用链太长导致 CodeQL 的分析深度不够。 **第二:KASAN 防不住。** KASAN(Kernel Address Sanitizer)是用来检测内存越界访问的。但这个漏洞不是越界——它是合法的地址、合法的写入操作,只不过 buffer 参数应该是一个用户态地址,传进来一个内核地址而已。KASAN 只看你有没有越界,不看你的地址来源是不是合法。 **第三:AI 代码审查没发现。** 微软有自己的 AI 辅助代码审查系统。但 AI 在这个场景下的表现——如果你喂给它的是和人类审查员一样的信号——它同样会认为 `ProbeForWrite` 已经被调用过了,后续的直接写入操作是安全的。AI 看不到的是:上一站的 `ProbeForWrite` 被 Length=0 绕过了。 说白了:这些工具的单点防御都没问题,但没人把链条串起来看。这个漏洞就藏在链条的缝隙里。  ### 8.2 信息类对比 对比 253 和其他信息类的处理方式,差异一目了然: | 信息类 | 编号 | 变量 | NULL 检查 | 安全 | |---|---|---|---|---| | SystemProcessInformation | 5 | v81 | 有 | 安全 | | SystemProcessInformation | 0x39 | v86 | 有 | 安全 | | SystemProcessInformation | 0xFC | — | 有 | 安全 | | SystemProcessInformationExtension | 253 (0xFD) | v95 | ❌ 无 | 有漏洞 | 新增的信息类 253 没有被 NULL 检查覆盖到,因为它和其他类用的是不同的变量和通路。   ### 8.3 缓解与防御  截至本文写作时(2026-07-05),微软已经发布了 CVE-2026-40369 的安全更新。但如果你还没打补丁,或者你在做防御研究,以下是一些关注方向: **短期:打补丁。** 这是最直接也最有效的办法。微软在 2026 年 6 月的安全更新中修复了这个漏洞。 **中期:VBS(Virtualization-Based Security)。** Credential Guard 和 Hypervisor-Protected Code Integrity(HVCI)可以限制内核漏洞的利用效果。但说实话,对于这种任意地址写入的原语,VBS 也只能做到部分缓解——攻击者可能还是能攻击 VBS 之外的区域。 Hypervisor-Protected Code Integrity(HVCI)和 Credential Guard 是 VBS 的两大组件。HVCI 用虚拟化层保护内核代码的完整性,不允许未签名的代码被执行。Credential Guard 把 LSA 的机密数据隔离在虚拟化安全域里。 但这些防御对 CVE-2026-40369 的效果有限。因为这个漏洞属于"数据攻击"类别——它不修改内核代码,只修改内核数据(Token 的特权位)。HVCI 只看代码有没有被篡改,不管数据。Credential Guard 保护的是明文凭证,不是 Token 的 Privileges 字段。 从防御者的角度看,更有效的方案是**系统调用行为基线检测**。监控 `NtQuerySystemInformation` 的参数模式:如果一个普通进程(不是 perfmon、不是任务管理器)频繁调用 info class 253 配合长度为 0 的 buffer,这很可疑。EDR 产品可以针对这种模式设规则。 **长期:系统调用参数语义检查。** 这可能是最彻底的方案,但也是最难落地的。思路是:在系统调用分发层(`KiSystemCall64` 之后的 `ExpQuerySystemInformation` 阶段),不仅检查 buffer 能否被 probe,还要检查 **buffer 的语义是否合法**。 什么意思呢?就是不要只看 `ProbeForWrite` 的返回值——因为 Length=0 导致它永远 PASS——而是要问一句:**"这个系统调用传进来的 buffer,真的是用户态地址吗?"** 具体做法可以是: ```js // 在调用 ProbeForWrite 之前或之后,额外检查 if ((ULONG\_PTR)Buffer >= (ULONG\_PTR)MM\_USER\_PROBE\_ADDRESS && Length > 0) { // 合法用户态 buffer } else if (Length == 0) { // Length=0 → 可能有问题 → 记录或拒绝 // 简单做法:直接返回 STATUS\_INVALID\_PARAMETER return STATUS\_INVALID\_PARAMETER; } ``` 这套逻辑说起来不难,难在改遍所有系统调用入口。Windows 内核有几万个系统调用入口点,每个都有自己的一套参数校验逻辑。想靠人工全部加固一遍,不现实。可能需要编译器级别的自动插桩——也就是在内核编译时自动加入"参数地址类型检查"的指令。 **一句话:短期打补丁、中期靠行为检测、长期靠编译期自动化。但这三个方案做过的难度依次递增,微软做到哪一步了,我说不准。** 8.4 和历史的对话 ---------- 最后说点题外话。 CVE-2026-40369 让我想起 2018 年的一个 CVE——CVE-2018-8897,也是一个系统调用的信息类问题(这次是 `MOV SS` 和 `POP SS` 异常处理不当导致的信息泄露)。两个漏洞相隔 8 年,表现形式完全不同——一个是异常处理,一个是 ProbeForWrite 绕过——但归根结底都是系统调用接口的"灰色地带":设计时假设了某些条件一定成立,但实际使用时可以通过精心构造的输入打破这个假设。 8 年过去,系统调用接口的灰色地带还在。而且从微软每个月刷新 CVE 纪录的趋势看(2026 年 5 月单月 954 个 CVE),灰色地带不是变少了,是变多了。 写这篇文章不是为了嘲讽微软。我是觉得 Ori Nimron 这种分析值得被更多人看到——他没有用任何花哨的技术,就靠读 IDA 反汇编、跟踪代码流、理解 probe 机制,找到了这条藏在断层里的路。这种基本功在 AI 辅助代码分析的时代像是"老派功夫",但实际上验证了一点:**机器能帮你读完代码,但只有人才能定义什么是"不该出现的行为"。** 九、总结 ---- CVE-2026-40369 不是什么复杂的漏洞。没有堆溢出,没有竞争条件,没有提权链的层层嵌套。它就是一个变量忘记检查,一行代码的事。 但这正是它值得写一篇文章的原因。 这行没写的代码出现在 2026 年——在 AI 代码审查被宣传得无所不能、"用万亿美金砸安全"的 2026 年。它出现在 Windows 内核里,这个全世界被审查最多、测试最多、分析最多的代码库之一。 它暴露的是一个老问题:**安全工具做了加法,但没有乘法。** CodeQL 做它的静态分析,KASAN 做它的动态检测,AI 做它的代码审查——这些工具在各自的维度上都有效。但它们之间没有 1 + 1 > 2 的效应。CodeQL 觉得"ProbeForWrite 被调用了,安全",KASAN 觉得"没越界,安全",AI 觉得"函数逻辑正常,安全"。三个"安全"拼在一起,结论是"安全"。 但漏洞还在那里。因为它不藏在任何一个工具的检测域里——它藏在工具之间的衔接处。 **不过说实话,这也不是微软一家的问题。** 任何有几十年历史的巨型代码库,都会长出这种断层。新功能加在旧框架上,新代码共享旧逻辑,新变量沿用旧模式——一条通路可能是在五年前铺设的,另一条通路是去年开的,两条路在交叉处没有信号灯。工具检查了每一条路的路况,但没有检查交叉口。 往后看,这类漏洞还会继续出现。内核复杂度的增长速度远超安全工具覆盖能力的增长速度。每加一个信息类、每开一条新通路、每引入一个新的子系统,都有可能诞生下一个藏在断层里的 CVE-2026-40369。微软 2026 年 5 月单月 954 个 CVE 的记录不是偶然,它是内核膨胀的必然结果。 对我来说,读 Ori Nimron 的 exploit 最大的收获不是掌握了一个新技巧,而是重新确认了一个朴素的事实:**安全工具解决的是"已知的模式",而现实中的漏洞往往出在"模式之外的衔接处"。** CodeQL 知道什么是越界,KASAN 知道什么是溢出,AI 知道什么是常见 bug 模式——但 ExpGetProcessInformation 的 v95 没有越界、没有溢出、也不是常见模式。它就是一个变量忘记检查了。这种漏洞靠工具挂不到,只能靠人一步一步跟着数据流走。 **漏洞永远是加法的产物,防护是减法。两者之间的差,就是 Ori Nimron 和 Alex Ionescu 这种人存在的意义。** 附工具下载地址: ```js https://github.com/orinimron123/CVE-2026-40369-EXPLOIT https://github.com/exploits-forsale/prefetch-tool ```
发表于 2026-08-04 17:56:28
阅读 ( 49 )
分类:
漏洞分析
0 推荐
收藏
0 条评论
zee
4 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!