~/posts/binary/cve-2021-24086-nested-ipv6-reassembly.md

CVE-2021-24086:嵌套分片与空指针写入

从两处补丁追到嵌套 IPv6 重组对象,核对 65,528 字节头部请求如何遇到非连续存储。区分 NDIS 与后续调用的返回值,解释内核崩溃证据、协议约束和拒绝服务边界。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
二进制
目录
  1. 0x00单包长度与重组长度各有边界
  2. 0x01两处补丁分别检查什么
  3. 0x02第一轮输出成为第二轮输入
  4. 0x03头部长度接近上限的算术
  5. 0x04真正漏掉的是哪一个返回值
  6. 0x05崩溃证据支持拒绝服务结论
  7. 0x06在新对象形成处重新检查
  8. 0x07参考资料

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。

普通分片中的对象3 行
对象 内容与约束
原始数据报 IPv6 固定头、扩展头、上层头和载荷
单个传输分片 前置头部、Fragment Header、该片数据;大小受路径 MTU 限制
重组后的对象 恢复原始载荷;长度仍需单独验证

RFC 8200 第 4.5 节 要求首片包含直到上层协议头的完整头部链,并规定重组后 Payload Length 超过 65,535 字节时的丢弃行为。它的长度范围不含 40 字节 IPv6 固定头。

因此,单个合法首片的头部区受 MTU 约束,但这是协议接收条件,不是任意实现都天然保证的内存性质。分析嵌套输入时,应检查每次重组之后重新形成的对象,而不是继续套用最外层包长。

两处补丁分别检查什么

tcpip!Ipv6pReassembleDatagram 的版本差异中,新增检查将两项相加:不可分片部分的扩展头长度,以及重组后的可分片部分长度。以下为关键指令的分段摘录,不是完整连续反汇编;栈变量说明属于分析标注。

Reassembly length-check excerptsasm
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 状态的测试:

Fragment receive check excerptasm
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 字节。

三个长度不要混用3 行
量 十六进制 十进制 范围
Routing Header 数量 0x1ffa 8,186 头部个数
扩展头总长 0xffd0 65,488 不含 IPv6 固定头
连续头部读取长度 0xfff8 65,528 扩展头加固定头

Next Header 链连续指向 Routing Header,末端指向内层 Fragment Header。内层首段上层内容只有 8 字节;外层传输按 0x400 字节载荷分块,内层剩余内容作为单独尾片到达。分块尺寸是该案例的构造参数,不是漏洞的通用常量。

下面的离线模型同时检查头部算术与新增总载荷边界。边界测试的 0x2f、0x30 是特意选择的测试值,并不声称是历史尾片的实际长度。

fragment_length_model.pypython
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,该标记是分析别名。

Saved header pointer and a later independent checkasm
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 返回值。看到一个空值测试,并不等于前面所有可能失败的调用都已被覆盖。

后续代码又取出保存的头部指针:

Dereference of the saved pointerasm
mov   rax, qword ptr [rsp+98h+SavedHeaderPtr]
movups xmm0, xmmword ptr [rdi+90h]
movups xmmword ptr [rax], xmm0

因此要从 NdisGetDataBuffer 的返回寄存器追到保存位置,再追到首次使用,而不是只搜索附近是否出现 test 指令。

崩溃证据支持拒绝服务结论

案例中的关键崩溃值7 行
项目 值
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 的服务,应与长期修复区分。

验证链应保留的三类证据3 步
  1. 1

    静态差异

    保存总载荷上限和 Jumbogram 分支的比较,明确字段含义与适用构建。

  2. 2

    重组结构

    分别记录内外层 Identification、Fragment Offset、M 位及 Next Header 关系,确认每一轮实际形成的解析对象。

  3. 3

    运行数据流

    记录请求长度、数据连续性、Storage、函数返回值及首次非法访问;检查的是正确返回值,而不是附近另一次调用的结果。

由根因推导出的工程要求是:每次重组产生新对象时恢复长度不变量,每次申请连续视图时处理失败。外层输入“小”,不保证解码、解压或重组后的对象也“小”;先前通过的检查也不会自动覆盖新的对象。

参考资料

NORMAL~/posts/binary/cve-2021-24086-nested-ipv6-reassembly.md§--
0%zh-CN