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