CVE-2021-24086 的直接崩溃点是空指针写入,但根因分析必须先回答另一个问题:正常受 MTU 约束的扩展头,为什么会在重组路径里接近 64 KiB?历史 Windows 实现接受的嵌套分片路径,使第一轮重组的输出成为第二轮解析的大对象,外层单包限制没有继续约束内层头部区。
Microsoft 在 2021 年 2 月 9 日公告 中将此问题列为拒绝服务漏洞,与同批修复的两项 RCE 区分。以下分析限定于修复前后的历史接收路径;具体 Windows build、tcpip.sys 哈希和完整转储尚未对应,不把函数内偏移当作跨版本地址。
单包长度与重组长度各有边界
IPv6 分片并不是把完整包任意切块。每片携带相应的前置头部和 Fragment Header,后者记录 Next Header、Fragment Offset、M 位与 Identification。重组使用首片的相关头部,按偏移拼接后续数据,并移除对应的 Fragment Header。
| 对象 | 内容与约束 |
|---|---|
| 原始数据报 | IPv6 固定头、扩展头、上层头和载荷 |
| 单个传输分片 | 前置头部、Fragment Header、该片数据;大小受路径 MTU 限制 |
| 重组后的对象 | 恢复原始载荷;长度仍需单独验证 |
RFC 8200 第 4.5 节 要求首片包含直到上层协议头的完整头部链,并规定重组后 Payload Length 超过 65,535 字节时的丢弃行为。它的长度范围不含 40 字节 IPv6 固定头。
因此,单个合法首片的头部区受 MTU 约束,但这是协议接收条件,不是任意实现都天然保证的内存性质。分析嵌套输入时,应检查每次重组之后重新形成的对象,而不是继续套用最外层包长。
两处补丁分别检查什么
tcpip!Ipv6pReassembleDatagram 的版本差异中,新增检查将两项相加:不可分片部分的扩展头长度,以及重组后的可分片部分长度。以下为关键指令的分段摘录,不是完整连续反汇编;栈变量说明属于分析标注。
movzx r9d, word ptr [rdx+88h]
mov edx, [rdx+8Ch]
add edx, r9d
cmp edx, 0FFFFh
jbe short loc_1C019A186新分支在总载荷长度超过 0xffff 时转入提前退出与重组集合清理路径。单独把扩展头或可分片内容与上限比较都不等价;固定 IPv6 头则不属于这次 Payload Length 比较的范围。
另一处差异在 tcpip!Ipv6pReceiveFragment,新增对 Jumbogram 状态的测试:
test byte ptr [rdi+0B1h], 4
jz short loc_1C019A8C7图中分析把该位关联到 Jumbo Payload 选项处理后的状态。设置该位的路径会停止分片处理并进入错误处理。RFC 2675 本身就规定 Jumbo Payload 选项与 Fragment Header 不应共存。
这两处差异提供了排查方向,但并不自动证明它们各自独立阻断同一个输入。分支可达性、状态位来源和重组长度仍应分别跟踪。
第一轮输出成为第二轮输入
这里的“嵌套”描述历史 Windows 的接收行为,不是常规协议构造,也不代表其他操作系统接受相同输入。
为区分层次,示意中外层 Identification 用 0x11111111、内层用 0x22222222。这些标记用于解释对象归属,不是附带真实源地址、偏移和校验和的抓包记录。
flowchart TD A["外层分片集合 / ID 0x11111111"] --> B["外层重组完成"] B --> C["恢复长扩展头链、内层 Fragment Header 与首段数据"]
外层载荷保存的是一段完整字节序列:大量 Routing Header、内层 Fragment Header,以及内层第一段上层数据。经过外层分片传输后,这段字节重新组合为大对象,才进入内层解释。它并不意味着每个外层传输包都重复携带一个完整内层头。
flowchart TD A["外层产物:内层首片"] --> C["内层集合 / ID 0x22222222"] B["单独到达的内层尾片"] --> C C --> D["第二轮重组"]
案例中的尾片单独到达,未像第一部分那样嵌入同一套外层分片。把尾片也按首段方式一起嵌套,并没有得到预期的递归重组路径。这说明层次与到达关系是实验条件,单独复制头部数量并不足以复现。
内层仍有逻辑上的首片,只是它来自外层重组,而不是一个 MTU 大小的传输帧。新的解析对象形成后,长度、头部合法性和存储表示都需要重新验证。
头部长度接近上限的算术
案例使用 0x1ffa 个、每个 8 字节的 Routing Header,累计 0xffd0 字节。加上固定 IPv6 头 0x28 后,NdisGetDataBuffer 的头部读取请求为 0xfff8 字节。
| 量 | 十六进制 | 十进制 | 范围 |
|---|---|---|---|
| Routing Header 数量 | 0x1ffa |
8,186 | 头部个数 |
| 扩展头总长 | 0xffd0 |
65,488 | 不含 IPv6 固定头 |
| 连续头部读取长度 | 0xfff8 |
65,528 | 扩展头加固定头 |
Next Header 链连续指向 Routing Header,末端指向内层 Fragment Header。内层首段上层内容只有 8 字节;外层传输按 0x400 字节载荷分块,内层剩余内容作为单独尾片到达。分块尺寸是该案例的构造参数,不是漏洞的通用常量。
下面的离线模型同时检查头部算术与新增总载荷边界。边界测试的 0x2f、0x30 是特意选择的测试值,并不声称是历史尾片的实际长度。
routing_headers = 0x1ffa
extension_bytes = routing_headers * 8
ipv6_header_bytes = 0x28
requested_bytes = extension_bytes + ipv6_header_bytes
assert routing_headers == 8186
assert extension_bytes == 0xffd0 == 65488
assert requested_bytes == 0xfff8 == 65528
# A model of the observed payload-length check, not a Windows emulator.
def payload_allowed(extension_size, fragmentable_size):
assert extension_size >= 0 and fragmentable_size >= 0
return extension_size + fragmentable_size <= 0xffff
assert payload_allowed(extension_bytes, 0x2f)
assert not payload_allowed(extension_bytes, 0x30)
assert payload_allowed(0, 0xffff)
assert not payload_allowed(0, 0x10000)
print("PASS: 8186 routing headers; extensions=65488; requested=65528")
print("PASS: payload total 0xffff accepted; 0x10000 rejected")$ python fragment_length_model.py
PASS: 8186 routing headers; extensions=65488; requested=65528
PASS: payload total 0xffff accepted; 0x10000 rejected模型没有发送分片,也没有模拟 Windows 重组状态;它只证明这些数值与边界条件的算术关系。
真正漏掉的是哪一个返回值
Microsoft 的 NdisGetDataBuffer 约定 区分逻辑数据量和物理连续性。数据足够且连续时,可直接返回指针;需要拼成连续区域时,调用者应提供足够大的 Storage。非连续数据加上 Storage == NULL 会返回空指针;数据不足或资源问题也可能失败。
第二次进入重组路径时,案例请求 0xfff8 字节连续头部,却没有提供备用存储区。关键调用与保存返回值的关系如下;长栈变量名缩写为 SavedHeaderPtr,该标记是分析别名。
xor r8d, r8d
mov r9d, 1
mov rcx, r14
call cs:__imp_NdisGetDataBuffer
mov qword ptr [rsp+98h+SavedHeaderPtr], rax
call IppCopyPacket
mov rbx, rax
test rax, rax这里省略了参数准备和中间指令,只保留返回值关系。后面的 test rax, rax 检查的是 IppCopyPacket 的返回值,不是已保存在栈槽中的 NDIS 返回值。看到一个空值测试,并不等于前面所有可能失败的调用都已被覆盖。
后续代码又取出保存的头部指针:
mov rax, qword ptr [rsp+98h+SavedHeaderPtr]
movups xmm0, xmmword ptr [rdi+90h]
movups xmmword ptr [rax], xmm0因此要从 NdisGetDataBuffer 的返回寄存器追到保存位置,再追到首次使用,而不是只搜索附近是否出现 test 指令。
崩溃证据支持拒绝服务结论
| 项目 | 值 |
|---|---|
| BugCheck | DRIVER_IRQL_NOT_LESS_OR_EQUAL (0xd1) |
| 被访问地址 | 0x0 |
| IRQL | 2 |
| 操作 | 写入 |
| 故障位置 | tcpip!Ipv6pReassembleDatagram+0x14f |
| 指令 | movups xmmword ptr [rax], xmm0 |
RAX |
0x0 |
调用关系还经过 Ipv6pReceiveFragment、Ipv6pReceiveFragmentList 和接收批处理路径。这与重组函数使用空头部指针写内存的解释一致。BugCheck 0xD1 文档 说明了地址、IRQL 和访问类型参数的含义;+0x14f 仅属于被观察的构建。
它与 RDNSS 八字节错位案例 都出现了 NdisGetDataBuffer,但内存原语不同:RDNSS 案例使用调用者栈存储并发生越界复制,本例则没有提供 Storage,最终使用了失败返回的空指针。API 名相同不是同一漏洞类型的证据。
已记录影响是内核崩溃与拒绝服务,没有建立从空指针写到可控执行的链。可达性也应另行判断:链路本地地址、全局地址、路由与过滤条件决定哪些输入实际到达目标接收路径。
在新对象形成处重新检查
优先依据 Microsoft 更新公告 安装适用安全更新。Microsoft 同时说明,临时阻断 IPv6 分片可能缓解相关暴露,但也会影响依赖 IPv6 的服务,应与长期修复区分。
- 1
静态差异
保存总载荷上限和 Jumbogram 分支的比较,明确字段含义与适用构建。
- 2
重组结构
分别记录内外层 Identification、Fragment Offset、M 位及 Next Header 关系,确认每一轮实际形成的解析对象。
- 3
运行数据流
记录请求长度、数据连续性、Storage、函数返回值及首次非法访问;检查的是正确返回值,而不是附近另一次调用的结果。
由根因推导出的工程要求是:每次重组产生新对象时恢复长度不变量,每次申请连续视图时处理失败。外层输入“小”,不保证解码、解压或重组后的对象也“小”;先前通过的检查也不会自动覆盖新的对象。