CVE-2021-24086 :TCPIP 中的空指针解引用漏洞
信息收集
patch 位于 tcpip.sys 中的 Ipv6pReassembleDatagram 函数,对某一个变量的大小进行了约束,使其不能超过 0xffff

并且下方的 NdisGetDataBuffer 传入的第二个参数 BytesNeeded 不再截断到两个字节

从直觉上来看这应该是个对 length 的检查,不过这个 length 是怎么来的还要进一步分析
使用公开的 poc ,更改 iface
1 | parser.add_argument('--iface', default = "VMware Network Adapter VMnet8") |
然后执行,发现崩溃,崩溃信息如下
1 | KDTARGET: Refreshing KD connection |
在 Ipv6pReassembleDatagram 中有个空指针解引用,如下所示

BytesNeededa 是由 NdisGetDataBuffer 所得到的,关于 NdisGetDataBuffer 的信息如下
1 | NDIS_EXPORTED_ROUTINE PVOID NdisGetDataBuffer( |
其返回值说明如下

返回空的情况有如下几种:
- 当
NetBuffer中的NET_BUFFER_DATA的成员Datalength小于BytesNeeded的时候,便会返回空 - 当在
Netbuffer中的请求数据不是连续且Storage为空时,返回空 - 当资源不足以将
data buffer进行映射的时候,也会返回空
那么我们通过调试来检查是哪个情况
在 NetioAllocateAndReferenceNetBufferAndNetBufferList 中会初始化 netbuffer ,然后在 NetioRetreatNetBuffer 中,会对 Datalength 进行更新,其会加上 BytesNeeded

但是需要注意的是这里加上的是截断后的 BytesNeeded

调试发现,发送最后一个包的时候,Datalength 为 0 ,传入的截断后的 BytesNeeded 为 0x10

但是实际的 BytesNeeded 为 0x10010

因为 BytesNeeded 大于 Datalength ,所以 NdisGetDataBuffer 会返回空,进而导致空指针解引用
接下来分析为什么 BytesNeeded 会那么大,往上找可以找到是 Ipv6pReceiveFragment 函数构造出来的参数,该函数对 ipv6 的分片包进行解析处理的。当最后一个包到达的时候,会调用 Ipv6pReassembleDatagram 函数进行处理,这个行为也可以通过调试直接观察出来
抓包进行调试,Ipv6pReceiveFragment 一开始的 NdisGetDataBuffer 会读出 ipv6 fragment header 并进行解析

根据这个可以恢复其结构
1 | 00000000 Ipv6FragmentHeader struc ; (sizeof=0x8, mappedto_712) |
针对这里的 v17 ,在动态调试之后可以发现是如下字段


也恢复一下数据结构
1 | 00000000 ipv6_header_t struc ; (sizeof=0x28, mappedto_713) |
之后就可以大致地去逆一下 Ipv6pReceiveFragment 函数了
Ipv6pReceiveFragment 函数流程分析
Ipv6pReceiveFragment 函数,顾名思义就是对 Ipv6 分片包进行处理的结构,Ipv6 的分片包行为的总结图如下:

其分为 unfragmentable 和 fragment 的部分,unfragmentable 部分主要由 ipv6 报头和 ipv6 fragment 报头组成,而 fragment 部分主要由上层报头和有效负荷组成(例如 ICMPv6 的报头加上有效负荷)
然后我们来看 Ipv6pReceiveFragment 函数。首先从 netbuffer 中读入 8 个字节的 ipv6 fragment header 并进行解析,同时进行参数合法性的判断
1 | ipv6FragmentHeader = (Ipv6FragmentHeader *)NdisGetDataBuffer(netbuffer, 8u, 0i64, 1u, 0);// 从netbuffer中读取8字节到ipv6FragmentHeader |
然后进行 ipv6 分片的查询
1 | ipv6Header = *(ipv6_header_t **)(a1 + 0x110); |
Ipv6pFragmentLookup 大致如下,通过对传入的分片进行哈希匹配来寻找需要使用的 Reassembly
1 | reassembly_t *__fastcall Ipv6pFragmentLookup(_LIST_ENTRY *unknown, int identification, ipv6_header_t *ipv6Header, KIRQL *NewIrql) |
然后对获得的 Reassembly 进行判断。
1 | if(Reassembly){ |
如果没有 Reassembly 的话,当分片头中 offset、reservedbit 和 morefrgment 都为 0 的时候,该包是第一个分片,且没有下一个分片的时候,这个时候会处理一下直接返回。否则调用 IppCreateInReassemblySet 来创建一个新的 Reassembly 并继续。
然后对包的参数 offset 进行检查,防止溢出。同时也检查了分片是否合法
1 | if ( netbuffer->DataLength + offset > 0xFFFF )// 长度检查,netbuffer的参数异常 |
接下来也是一些长度的检查
1 | if(netbuffer->DataLength){ |
其中 Reassembly->accumLength在 Reassembly 初始化的时候该值为 -1 。这里主要是判断当分片头的 offset 、reservedbit 和 morefragment 有不为 0 的时候(也就是还在分片流程中的时候),如果 DataLength 不是 8 比特对齐或 Reassembly 在不是初始情况下DataLength 和 offset 的和比 accumLength 大,则直接错误。
另外如果 netbuffer 中的 DataLength 为 0 ,但是分片包头中的标识位显示还在分片流程中的时候,则也会直接错误。
然后是对 Reassembly 的 accumLength 进行更新
1 | accumLength= HIDWORD(Reassembly->accumLength); |
当 accumLength 为 -1 的时候,会直接赋予其 offset + dataLength 的值
随后调用 IppReassemblyFindLocation 寻找上一个分片的 mdl
1 | lastmdl = IppReassemblyFindLocation( |
同时进行当前分片的 mdl 的申请
1 | mdlSize = (MmSizeOfMdl((PVOID)0xFFF, netbuffer->DataLength) + 7) & 0xFFFFFFFFFFFFFFF8ui64; |
经过一系列的长度检查,最后申请构造出 mdls
1 | if(lastmdl == &Reassembly->first_mdl_data){ |
这里会重新计算并更新 weakLength ,同时将构造新的 pool 并更新,最后再更新 Reassembly 中的大小
最后再判断 sumLength 和 accumLength 是否相同,相同的话就进行重组, 否则就检查 Reassembly 的 Quota
1 | if ( (unsigned __int16)Reassembly->sumLength == Reassembly->accumLength ) |
漏洞分析
经过 Ipv6pReceiveFragment 函数的分析,我们可以发现能修改到 weakLength 并导致后面的溢出的地方只有这里
1 | if(lastmdl == &Reassembly->first_mdl_data){ |
那么就证明最后一次更新的时候,这里的 *(_WORD *)(a1 + 0x30) 是小于 0x30 的(这里的 0x30 是 ipv6 的头 0x28 加上 ipv6 fragment 的头 0x8)
同时在查看 poc 的时候我们可以发现,其最后发送的包所使用的 identification 和之前的包的 identification 相同
1 | ... |
经过前面的流程我们可以知道,Ipv6pReceiveFragment 中有使用 identification 来寻找匹配的 Reassembly ,第二次发送的包可能使用了第一次包的 Reassembly 的累计长度,并因此产生了了溢出。
那么我们首先断在 Ipv6pReceiveFragment 的修改 size 的地方,然后跟到 Ipv6pReassembleDatagram ,发现有一个 CopyPacket

下内存断点到 rax + 0x30 处,跟进一下


发现这里的 size 处加了一个 0x710 ,刚好就是 Ipv6 fragments 的长度

不难分析得出 tcpip 对每个 Destination Options 都进行了对应的处理,并进行了合并。最后全部的 size 加起来为 hex(1808*36+424) = 0xffe8 ,也就是最终的 weakLength 。
然后第二个 Ipv6 的分片包使用了之前的 identification ,复用了前一个请求的上下文,导致使用的 weakLength 为之前统计的长度。而 Ipv6pReceiveFragment 函数只是检查了 netbuffer 的 Datalength 和 ipv6FragmentHeader 的 offset ,并未对该值进行检查,最终 weakLength 的值导致了整数溢出,而 NetioRetreatNetBuffer 只使用了两字节的 BytesNeeded ,导致 NdisGetDataBuffer 长度不匹配,返回空指针。最后在没有进行空指针判断的情况下对空指针进行操作,造成拒绝服务攻击。
至于为什么使用的是 destination options headers ,这是因为需要绕过 MTU 的限制。在平常的情况下,我们只能在不可分片的拓展报头中放入最多不到 1500 字节的数据,这样统计出的长度也不会超过 1500 字节。而 Windows 中支持 nested IPv6 fragments ,也就是说当我们的分片部分包含了另一个分片报头信息的时候,如下所示:

我们有 Outer 和 Inner 两类分片报头,当最终分片重组完成,会形成如下的结构:

其会再重组一遍,变成这样:

这样我们就能绕过 MTU 的限制,所以我们可以构造如下报文:

这里不仅仅是 Routing headers ,像我们上面提到的 destination options headers 也可以,所有可行的报头如下:

使用这些报头,我们就可以触发该漏洞。
总结与思考
CVE-2021-24086 是一个空指针解引用漏洞,攻击者可以构造 nested IPv6 fragments ,通过复用分片报文中的 identification 来引用上一个 ipv6 分片报文的上下文内容,从而触发整数溢出,而传入 NdisGetDataBuffer 的长度参数又被截断到了两字节,导致 NdisGetDataBuffer 中的参数不匹配而返回空。而代码没有对该返回的指针做检查就直接使用,进而导致拒绝服务攻击。
在来谈谈 patch ,分别 patch 了一个对长度的检查并对 NdisGetDataBuffer 的传入参数进行了不截断的操作,从原理上防止了漏洞的触发。但是还没有对 NdisGetDataBuffer 的返回值进行检查,虽然从目前来看是没啥问题的,但是不知道这会不会埋下伏笔。
第一次针对 tcpip 进行比较深入的逆向,但是还是有些参数和结构体不是很明白,看别人的 分析文章 会发现基本上都逆出来了,只能说膜拜。希望今后也能够不断进步!
EXP
公开的 EXP ,由于写得很好就直接贴上来了:
1 | # Axel '0vercl0k' Souchet - April 7 2021 |
Reference
Analysis of a Windows IPv6 Fragmentation Vulnerability: CVE-2021-24086 (quarkslab.com)
[原创]CVE-2021-24086 Windows TCP/IP拒绝服务漏洞分析-二进制漏洞-看雪论坛-安全社区|安全招聘|bbs.pediy.com
