CVE-2020-16898 涉及 2020 年 10 月安全更新前的 Windows IPv6 路由器通告(Router Advertisement,RA)处理。一个畸形 RDNSS 选项让两轮遍历相差 8 字节:验证阶段视作数据的尾部,在使用阶段变成了新的选项头,最终影响内核栈上的复制长度。
关键不只是 Length 字段的奇偶性,还包括 NET_BUFFER 是否连续,以及函数返回前的栈 cookie 检查。以下地址和崩溃值限定于该历史案例;具体 Windows build 与 tcpip.sys 哈希尚未对应,结论停留在解析错位和内存破坏,不延伸为跨版本的完整代码执行链。
RDNSS 的长度必须闭合
RDNSS 类型为 0x19。按照 RFC 8106 第 5.1 与 5.3.1 节,它由 8 字节固定头部和至少一个 16 字节 IPv6 地址组成。Length 以 8 字节为单位,合法值至少为 3,并满足 (Length - 1) % 2 == 0。
| 偏移 | 名称 | 类型 | 大小 | 值 |
|---|---|---|---|---|
| 0x00 | Type | uint8 | 1 | 0x19 |
| 0x01 | Length | uint8 | 1 | 3 |
| 0x02 | Reserved | uint16 | 2 | |
| 0x04 | Lifetime | uint32 | 4 | |
| 0x08 | Address | IPv6 | 0x10 |
包含 N 个地址时,字节长度为 8 + 16*N,Length 为 1 + 2*N。偶数 Length 不只是“多了些填充”:RDNSS 结构本身没有供这一残余半地址使用的尾部字段。
设 Length 为 4。外层按 4*8 认定跨度为 32 字节,内层若用整数除法得到 (4-1)//2 == 1 个地址,就只消费 8+16 == 24 字节。
| Length | 声明跨度 | 按完整地址消费 | 差值 |
|---|---|---|---|
| 3 | 24 | 24 | 0 |
| 4 | 32 | 24 | 8 |
| 5 | 40 | 40 | 0 |
| 6 | 48 | 40 | 8 |
两次遍历赋予尾部不同身份
入口 tcpip!Ipv6pHandleRouterAdvertisement 与后续 Ipv6pUpdateRDNSS 的配合,是定位边界分歧的重点。第一轮按声明长度检查选项;第二轮按类型消费内容。若两者的游标推进规则不同,“已验证的字节范围”并不保证“最终解释出的每个对象都已验证”。
案例中的两次读取起点如下。这里展示的是地址关系,不是可移植的函数偏移。
| 观察项 | 值 | 含义 |
|---|---|---|
| 第一次选项起点 | ffff920766042650 |
RDNSS 起点 |
| 后续选项起点 | ffff920766042668 |
实际前进 0x18 |
| 声明跨度 | 0x20 |
与实际增量相差 8 字节 |
| 尾部标记 | XXXXYYYY |
首两个 0x58 分别被读取为 Type 和 Length |
把偶数长度 RDNSS 的最后 8 字节作为新头部读取后,第二轮看到的选项序列已不是第一轮验证过的序列。严格地说,不是某个超长选项成功通过了自己的校验,而是对应的类型检查从未作用于这段字节。
Route Information 承接未经同类检查的长度
Route Information 类型为 0x18。RFC 4191 第 2.3 节 规定其 Length 为 1、2 或 3,取值还受 Prefix Length 约束,因此协议表示的最大长度是 24 字节。
直接放入一个超长 Route Information,早期校验会发现长度异常。让其头部落在 RDNSS 的残余 8 字节内,则形成另一条路径:第一轮仍把它看作 RDNSS 内容,第二轮才把它识别为 Route Information,并把其中的 Length 交给后续内存操作。
审查这类多阶段解析时,可以同时记录三个量:
- 验证读取的起点。
- 使用读取的起点。
- 两个阶段各自计算跨度的公式。
只证明缓冲区“整体足够长”并不充分;还要证明被使用的类型和边界正是此前检查的那一个。
NdisGetDataBuffer 的复制分支改变结果
PVOID NdisGetDataBuffer(
PNET_BUFFER NetBuffer,
ULONG BytesNeeded,
PVOID Storage,
ULONG AlignMultiple,
ULONG AlignOffset
);Microsoft 的接口文档 要求调用者提供的 Storage 至少容纳 BytesNeeded 字节。函数在数据连续、满足访问条件时可以返回原有数据指针;数据不连续且提供了 Storage 时,会使用该区域组织所需数据。数据总量不足、映射资源不足等条件也会导致空指针返回。
本案例的调用点把固定大小栈区域用作 Storage,却根据重新解释出的 Length 计算 BytesNeeded。Length << 3 表示乘以 8;Length 为 0x22 时,请求长度为 0x110,即 272 字节,远大于正常 Route Information 的 24 字节上限。
因此需要同时证明两件事:输入长度到达了复制参数;本次调用实际进入了使用备用存储区的分支。只观察到 Length 偏大,还不足以解释栈覆盖。
分片、重组与协议接收条件
案例使用分片输入影响重组后的数据布局,从而触发非连续数据的复制路径。这里真正起作用的是 NET_BUFFER 和内存描述符链(MDL)的实际组织方式;网络上出现 Fragment Header,不自动等于目标位置一定跨越 MDL 边界。驱动、收包及重组实现都会影响这一结果。
RA 还具有链路范围及 Hop Limit 等接收条件。这个案例不支持“任意互联网位置都能直接触达目标解析器”的结论。
另一个独立约束是实际数据量:即便请求长度越过了栈区域容量,网络缓冲区也要有足够的后续字节,才可能进入相应复制。只有夸大的 Length 而没有足够数据,可能先走失败返回。复现时应检查接收与重组后的状态,而不是把某个分片尺寸当作跨环境常量。
用算术模型隔离边界错误
下面的模型只验证字节消费关系,不生成网络流量,也不模拟 Windows 内核。它穷举 8 位 Length 中从 3 到 255 的 253 个值,核对奇数的差值为 0、偶数的差值为 8,并检查案例指针增量与复制长度。
def rdnss_offsets(length_units):
if not 3 <= length_units <= 255:
raise ValueError("length outside the modeled range")
declared = length_units * 8
walked = 8 + ((length_units - 1) // 2) * 16
return declared, walked
for length in range(3, 256):
declared, walked = rdnss_offsets(length)
assert declared - walked == (8 if length % 2 == 0 else 0)
assert rdnss_offsets(4) == (32, 24)
assert 0xffff920766042668 - 0xffff920766042650 == 0x18
assert 0x22 << 3 == 0x110 == 272
print("PASS: 253 lengths; even delta=8; odd delta=0; copy length=272")$ python rdnss_boundaries.py
PASS: 253 lengths; even delta=8; odd delta=0; copy length=272案例还记录到返回地址区域出现 0x4242424242424242,随后触发 BugCheck 0x139,参数 1 为 2,并进入 _report_gsfailure。Microsoft 对该参数的定义 对应栈 cookie 检测到的栈缓冲区越界。
| 层次 | 本案例的证据边界 |
|---|---|
| RDNSS 游标错位 | 指针增量与尾部字段解释相符;算术模型另行通过 |
| 未经同类检查的选项长度 | 两次遍历对尾部的对象身份不同 |
| 栈内存破坏 | 复制路径、填充值及安全检查失败相互支持 |
| 稳定代码执行 | 未完成这一证明;覆盖控制数据不等同于成功使用它 |
栈保护检测到破坏,并不否定之前发生了越界;反过来,发生越界也不证明已经越过栈保护。把这两种判断分开,才能准确表达结果。
修复与回归应恢复同一组不变量
系统维护应以 Microsoft 的 CVE-2020-16898 更新公告 选择对应版本的安全更新。对解析器本身,以下是从根因推导出的审查与回归条件,不是对某个未核对补丁的逐行描述。
- 1
先验证结构合法性
RDNSS 长度至少为 3,且
(Length - 1) % 2 == 0;合法地址数应与声明跨度精确匹配。 - 2
固定每个选项的解释边界
校验与消费复用同一套边界信息,类型解析器的消费量与外层声明跨度保持一致。
- 3
在复制点约束备用存储
比较
BytesNeeded与真实Storage容量,而不是仅信任前序协议遍历。 - 4
覆盖两种内存表示
对连续与非连续缓冲区分别测试,同时覆盖数据不足、畸形长度、协议层提前丢弃等结果。
这条链的核心是三层约束发生分离:协议长度、解析游标和底层存储。审查时把三者重新对齐,才有机会在栈损坏之前定位真正失守的边界。