在之前的文章里,我们聊过 CVE-2026-40369 的宏观影响——一个从浏览器低权限沙箱一路通往 SYSTEM 权限的“满分跳板”。但对技术人来说,光知道“能提权”远远不够。这 12 字节的内核写原语在汇编层到底长什么样?为什么 ProbeForWrite 偏偏在这个函数里形同虚设?
今天直接拿 Hex-Rays 反编译出来的内核源码,从代码审计的视角,把触发点和调用链彻底盘一遍。
用户态发起系统调用后,执行流会从 nt!NtQuerySystemInformation 一路下沉。对 Windows 11(build 26100.8246)内核符号 的逆向分析显示,核心调用路径非常清晰[1]:
NtQuerySystemInformation(SystemInformationClass, SystemInformation, Length, ReturnLength)
-> nt!NtQuerySystemInformation
-> nt!ExpQuerySystemInformation (负责参数分发与头部的基本 Probe)
-> nt!ExpGetProcessInformation (核心进程遍历工作线程,包含未校验写)
漏洞的核心引爆点,就藏在 nt!ExpGetProcessInformation 这个负责收集进程信息的内部函数中。当传入的信息类(Information Class)为 253 时,函数会进入一段特殊处理逻辑。
二、核心源码审计:那个让校验失效的“零长度”陷阱
直接看 nt!ExpGetProcessInformation 在处理类 253 时的关键反编译代码片段[1]:
NTSTATUS __fastcall ExpGetProcessInformation(
__int64 a1, // 用户态传入的 SystemInformation 指针(完全由调用者控制)
unsignedint a2, // 传入的 Length 缓冲区长度
_DWORD *a3, // 返回长度指针
_DWORD *a4, // 会话过滤参数
int a5) // 信息类 (Class, 此处为 253)
{
unsignedint *v85, *v95, *v99;
...
v95 = (unsignedint *)a1;
...
if ( a5 == 253 ) {
v77 = 0;
v86 = 12; // 固定的目标写入长度:12 字节
v71 = 12;
v87 = 0;
v99 = v95; // <-- 关键点:v99 直接指向了用户态传入的指针 a1!
v85 = NULL;
goto LABEL_11;
}
...
LABEL_11:
...
/* 长度校验段:仅仅设置状态,但不进行提前返回! */
v97 = v86;
v11 = a2 < v86;
if ( a2 < v86 ) {
if ( !a3 )
return STATUS_INFO_LENGTH_MISMATCH;
v11 = a2 < v86;
}
v13 = v11 ? STATUS_INFO_LENGTH_MISMATCH : 0; // 状态被置为 MISMATCH,但函数继续硬着头皮往下走!
从这段代码里,能抓出两个致命缺陷:
- 指针无条件混同:当
a5 == 253 时,全局指针变量 v99 被直接赋值为 v95,也就是用户态传入的 a1。之后没有任何中间层对 v99 指向的地址合法性做二次核对。
- 校验逻辑“形同虚设”:代码检查了
a2 < v86,但没有立即 return,而是把错误状态暂存到 v13,接着继续执行主循环。也就是说,Length 传 0 也不会在这里被拦住。
三、触发点引爆:硬编码的 12 字节任意内核写
当执行流进入主循环、遍历完系统进程链表后,真正触发崩溃和任意写的汇编现场出现了:
if ( a5 == 253 ) {
v25 = v99;
++*v99; // 写入 #1: [target + 0] 自增 1
v25[1] += PsGetProcessActiveThreadCount((__int64)NextProcess); // 写入 #2: [target + 4] 累加线程数
v25[2] += ObGetProcessHandleCount((struct _EX_RUNDOWN_REF *)NextProcess, 0LL);
// 写入 #3: [target + 8] 累加句柄数
}
这就是整个漏洞的绝对触发点:
- 第一次写入:对目标地址执行
++*v99,4 字节递增。
- 第二次写入:在目标地址偏移
+4 处,加上当前遍历进程的活跃线程数,4 字节累加。
- 第三次写入:在目标地址偏移
+8 处,加上当前进程的句柄数,4 字节累加。
三次写入加起来,刚好是 12 字节的内核写入原语。
为什么顶层 ExpQuerySystemInformation 没有拦住?因为上层分发时,通用的 ProbeForWrite 检查使用用户传入的 Length 去跑,攻击者可以设为 0。当 Length == 0 时,ProbeForWrite 内部的页触摸和范围检查会直接变成空操作:
void __stdcall ProbeForWrite(volatilevoid *Address, SIZE_T Length, ULONG Alignment)
{
if ( Length ) { // <-- 只要 Length 为 0,大门直接敞开,根本不进循环检查!
...
}
}
这就形成了一个完整的逻辑闭环:沙箱内攻击者调用 NtQuerySystemInformation,伪造类 253 且 Length=0,顶层检查放行,底层却误以为存在合法的 12 字节空间,进而向任意可写内核地址疯狂叠加计数器。
四、从 12 字节到 SYSTEM:攻击者的提权闭环
拿到 12 字节写原语后,利用链没有直接走传统内核劫持,而是玩了一套更现代的“组合拳”:
- 破除 KASLR:利用累加写入翻转内核中的某些状态标志位,比如
Feature_RestrictKernelAddressLeaks,把原本隐藏的内核地址指针“挤”出来。
- 绕过经典窃取:不修改常规进程的 Token 指针,而是直接调用
NtCreateToken 系统调用。
- 凭空造令牌:借助系统原生 API 的合法通道,构造一个带
SeTcbPrivilege 特权的 SYSTEM 主令牌,扣到自己头上。
至此,从浏览器沙箱出发,仅靠内核记账逻辑在“加减法”上的疏忽,最终演变成对整台 Windows 主机的完全接管。
参考:
[1] https://voidsec.com/cve-2026-40369-browser-sandbox-escape/