自8月13日最新的Windows补丁发布以来,我一直深入研究tcpip.sys(负责处理TCP/IP数据包的内核驱动程序)。在Windows内核中最容易接触到的部分存在一个CVSS评分为9.8的漏洞,这让我无法忽视。我之前从未真正研究过IPv6(或负责解析它的驱动程序),所以我知道尝试逆向工程这个漏洞将极具挑战性,但也是一次很好的学习体验。
大多数情况下,tcpip.sys几乎没有文档可参考。我找到了一些关于旧漏洞的利用报告:这里,这里,和这里,但其他信息非常有限。当我用英文在Google搜索时,最顶端的结果是用中文写的,我立刻意识到我已经完全超出了自己的能力范围,这注定是一次艰难的体验,但学习是必须的。尽管Google翻译的表现不算太好,但这篇文章提供了有关IPv6分段工作原理的非常详细的见解,并为我提供了一个良好的开端。
稍后,在Google搜索一些函数名时,我遇到了另一篇关于同一2021年漏洞的分析,由Axel Souchet(又名0vercl0k)撰写,该文章更深入地探讨了tcpip.sys的内部机制,并为我提供了足够的信息来定义几个未公开的结构。
有史以来最简单的补丁分析
通常,即使只是逆向工程补丁以找出哪个代码更改对应漏洞也可能需要数天甚至数周,但在这种情况下却是瞬间的。事实上,这次太简单了,以至于社交媒体上有很多人告诉我我错了,漏洞在别的地方。我是否真的听了他们的话,然后浪费了一整天的时间去逆向错误的驱动程序?我们可能永远不会知道。
整个驱动程序文件只做了一处更改,结果证明,这确实是漏洞所在。
tcpip.sys在安装补丁前后的bindiff概览。
整个驱动程序中只有一个函数被修改了。通常,我可能需要花一整天的时间来查看20多个不同的函数更改,只为找出应该查看的那个,但这次不是。
补丁前的Ipv6pProcessOptions()
。
补丁后的Ipv6pProcessOptions()
。
不仅仅是一个函数被修改了,而是只有一行代码。
那个名字超长的Feature_2660322619__private_IsEnabledDeviceUsage_3()
函数是微软有时添加的,用来启用部分补丁回滚。该调用检查全局标志或注册表设置的存在,如果设置了,则该函数将返回false,导致执行原始代码而不是补丁后的版本。
微软这样做的原因是,有时安全补丁会无意中破坏某些功能,因此此设置使管理员可以仅取消一个漏洞的补丁,而无需卸载整个每月补丁合集,从而大大削弱系统安全性。
考虑到这一点,很明显,这个补丁所做的只是将IppSendErrorList()
的调用替换为IppSendError()
,这暗示问题与某种列表有关。史上最简单的补丁对比(或者我以为是这样)。
漏洞是可选的,利用是必须的
逆向工程补丁以找到更改的代码只是挑战的一半(或者在这种情况下不到0.1%)。剩下的过程包括逆向工程足够多的代码库以理解发生了什么,找出修补了什么样的漏洞,如何制作请求以达到目标代码,以及什么状态会导致可利用的情况。
第一部分相当容易。更改在Ipv6pProcessOptions()
中,这告诉我们它是IPv6并涉及处理选项。因此,快速查阅RFC告诉我们IPv6选项究竟是什么以及我们可以在哪里找到它。
Wikipedia上的目的地选项头布局。
好的,很酷。我们要找的似乎是目的地选项头,它直接位于主IPv6头之后。让我们使用Python库‘scapy’来制作一个测试的IPv6数据包。
注意:为了减轻使用伪造IP地址进行的DDoS攻击,Windows限制了构建原始IP数据包的能力。出于这个原因,我选择在Linux上开发我的概念验证。虽然Linux确实允许用户构建和发送原始第2层和第3层数据包,但它要求Python脚本以root身份运行。
import sys
import struct
from scapy.all import *
def send_ipv6_option_packet(dest_ip):
ethernet_header = Ether()
ip_header = IPv6(dst=dest_ip)
options_header = IPv6ExtHdrDestOpt()
sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2:
print('Use: python3 script.py <target_ipv6_address>')
exit(-1)
send_ipv6_option_packet(sys.argv[1])
在tпciр!Ipv6pProcessOptions
上设置断点,然后运行脚本后,很明显,只需要发送一个带有空选项结构的IPv6数据包即可到达易受攻击的函数。然后我尝试在结构中添加一些无效选项,看看能否到达IppSendErrorList()
的调用。
简要的代码审查表明,几乎任何无效的选项格式都可能触发对 IppSendErrorList
的调用。所以,我决定使用具有无效长度(小于65535字节)的 Jumbo Packet 选项。
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
那么,IppSendErrorList()
实际上做了什么呢?其实代码非常简单。
IppSendErrorList 函数的全部代码。
代码遍历一个链表,并对列表中的每一项调用 IppSendError()
。再次说明,星星已经对齐,一切都很顺利。如果 IppSendErrorList
只是对列表中的每一项调用 IppSendError
,而补丁将 IppSendErrorList
的调用替换为 IppSendError
,那么问题就出现在当 IppSendError
被调用于除第一个之外的列表项时。
那么,这到底是什么列表,我们该如何创建它呢?
他在列清单,他检查了….52,567次
这部分从明显的容易变得异常困难,虽然我认为很大一部分原因是我仅有的两个可用脑细胞之一正忙于与严重的新冠感染作斗争。我花了几天时间理解代码的某些部分,然后睡着了,接着忘记了我已经弄清楚的东西。整个过程耗时超过一周来逆向工程 tcpip.sys 的部分内容,以弄清楚到底发生了什么。但 Axel 的博客文章提供了极大的帮助。
通过查看 Axel 逆向工程的函数和结构,以及它们被传递给的其他函数,很明显传递给 Ipv6pProcessOptions()
的唯一参数是文章中定义的相同 packet_t
结构。基本上,传递给 Ipv6pProcessOptions
并由 IppSendErrorList
遍历的指针是一个数据包的链表。
所以,我在 Ipv6pProcessOptions()
上设置了断点,并检查了列表。
列表中的 Next 条目为 NULL。
每次我的断点被触发时,列表中只包含一个数据包。我花了比我愿意承认的时间更长的时间试图弄清楚为什么以及如何让我的列表真正成为一个列表。我的第一个想法是 IPv6 分片:IPv6 允许发送方将大数据包拆分为单独的小数据包,这在列表中保持一致是合理的。
经过大逆向工程,我确认了我的假设是正确的,尽管碎片列表与我们在这里处理的列表无关。
事实上,我最终完全是偶然发现了答案。偶尔,列表会填充,但原因不清楚。经过多次兜圈子后,我意识到,当我的内核断点被触发时,它会暂停整个内核,导致网络适配器积累数据包。当内核恢复时,这些数据包被传递到 tcpip.sys 的堆栈中,形成了一个整齐的列表。只有在内核暂停期间发送但在下一个断点触发之前未处理的情况下,才会发生这种情况。
这种行为可能是一种性能优化,当吞吐量低时,内核单独处理数据包,但在高吞吐量时,数据包被组织成列表并批量处理。很可能列表是根据协议和源地址等因素进行分离,以加快处理速度,因此我们的列表应该只包含我们发送的 IPv6 数据包。
兄弟,我听说你喜欢DoS
既然我们知道在高吞吐量期间数据包会合并成列表,那么最简单的选择就显而易见了。我们的 DoS 演示恰好需要使用 DoS 来触发 DoS 条件。如果我们用 IPv6 数据包的突发流量淹没系统,我们应该能够让 IppSendErrorList()
得到一个不错的大列表。
一开始,不管我发送了多少数据包,我仍然只能在暂停内核时使列表中的 n > 1。但是……由于我们使用的是 Python(非常慢),在虚拟机中(双倍慢),我们可能需要调整一些设置。为了对抗我攻击系统中正在发生的 VM 嵌套,我决定简单地将目标虚拟机重新配置为仅使用一个 CPU 核心。
很好!数据包列表现在包含了很多条目!
所以,事实证明在 VM 中再嵌套 VM 并不是 DoS 的最佳选择,谁能想到?但我们最终让它工作了。现在,我们只需要弄清楚 IppSendError()
的作用以及问题出在哪里。
更逆向工程……又来了……永远……
经过一些深入的逆向工程,我更加清楚地理解了 IppSendError
的作用。在通常情况下,它只是通过将 net_buffer_list->Status 设置为 0xC000021B (STATUS_DATA_NOT_ACCEPTED) 来禁用数据包。然后,它会发送一个包含关于错误数据包的信息的 ICMP 错误回给发送方。
IppSendError 的两个相关部分。
我的第一个方法是查看 tcpip.sys 中是否有忽略 net_buffer_list->Status 值的函数。这将导致驱动程序处理处于未定义或意外状态的数据包,希望导致一个利用条件。
负责处理数据包的主循环。
由于调用所有解析函数的循环都被包装在一个错误检查中(意味着一旦设置了错误代码,我们就无法继续前进),我认为这是个错误的方向。相反,我决定回到 IppSendError
,看看是否有在设置错误代码之前修改数据包状态的代码路径,这可能导致竞态条件。
经过更多的逆向工程,我在 IppSendError
的最底部附近发现了以下代码。
IppSendError 中的一个代码路径,将数据包大小设置为零。
当 IppSendErrorList
,也就是 IppSendError
,被调用且参数 always_send_icmp
设置为 true 时,它似乎会尝试将 ICMP 错误发送给列表中的每个数据包。
然后,由于某种只有神知道的原因,它到达了一个代码块,在该代码块中,数据包的 packet_size
字段被设置为零。
为了将 always_send_icmp
设置为 true,我们只需要在处理选项头时通过将“Option Type”值设置为大于 0x80 的任何数字来引发特定错误。
def build_malicious_option(next_header, header_length, option_type, option_length):
dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0)
sendp(packet)
但是,将 packet_size
设置为零不是会破坏解析器吗?
负责处理数据包的主循环中的一段代码。
包处理程序只是根据 packet->next_header
值调用一个 VTable 函数,这个值在预解析时设置后就没有改变过。这使得包处理得以继续,甚至让我们能够控制进行的处理。
因为 packet->next_header
值是从 IPv6 包的“Next Header”字段中获取的,我们可以将其设置为任何有效的 IPv6 头部值,然后循环将调用相应的解析器。这为我们提供了广泛的攻击面。
IPv6 包格式
剩下的任务就是找到 IPv6 解析器中一个可以对 packet_size
字段做出错误处理的部分。
回到分片
我首先决定查看的是 IPv6 分片解析器,因为那里曾经有过 CVE-2021-24086 漏洞,所以这似乎是一个找到更多奇怪代码的好地方。
唉……差一点,但也差得远。
这里确实存在一个漏洞,但不是 RCE。
本质上,在大多数 CPU 上,寄存器是循环的。如果将寄存器值增加到其最大可能值之外,它会重新回到零。同样,如果将其减小到其最小可能值以下,它会绕回到最大可能值。这些行为分别称为整数溢出和整数下溢。对于有符号整数,这种行为稍有不同,但这里我们不处理有符号整数。
第一行 fragment_size = LOWORD(packet->packet_size) - 0x30
包含以下 ASM 代码:
计算片段大小的 ASM 代码
AX 是 EAX 寄存器的低 16 位。尽管 EAX 寄存器是 32 位的,但 AX 作为独立的 16 位寄存器运行,因此任何溢出或下溢都限制在 AX 中,不会影响 EAX 寄存器的其余部分。这非常方便,因为 EAX 寄存器下溢将导致值为 40 亿,从而尝试分配 4GB 的内存,而这可能会失败。
由于 packet->packet_size
的值为零,此代码将 AX 置为零,然后从中减去 0x30。
在正常情况下,包头为 0x30 字节,因此 packet_size - 0x30
是片段数据的大小。
在我们的案例中,packet->packet_size
为 0,因此即使从中减去 1 也会导致寄存器绕回到最大可能的 16 位整数值(0xFFFF)。由于我们减去的是 0x30,因此 AX 的值将下溢并变为 MAX_VALUE - 0x2F
,即 0xFFD0,等于 65,488。
不幸的是,由于相同的计算既用于内存分配也用于复制数据,我们并没有得到缓冲区溢出。我相信 RtlCopyMdlToBuffer()
也对源缓冲区执行了边界检查,因此我们甚至没有得到越界读取。不过,我们并不是完全没有收获。
由于 ExAllocatePoolWithTagPriority()
不会将分配的内存置零,而 RtlCopyMdlToBuffer()
只复制实际可用的数据量,因此我们得到了大约 65KB 的未初始化的内核内存。由于内存地址在释放后会被回收,因此缓冲区很可能充满了在重新分配前存储在该地址的内容。如果我们可以使用分片构造一个包,并将其发送回我们自己,比如 ICMP 回显请求,我们可能会泄露随机内核内存,从而绕过 ASLR。
此外,代码还将 reassembly->fragment_size
设置为下溢的 16 位整数(65,488),因此我们现在有两个独立的变量,可以用来潜在地引发缓冲区溢出。
遭受挫败,但未被打倒
不幸的是,(或者说幸运的是,这可能为我节省了很多时间),有人抢在我之前找到了答案。在我能找到使用这些下溢整数引发缓冲区溢出的地方之前,ynwarcs 找到了答案,并发布了一个 PoC。这解决了我拼图的最后一块。
解决方案(或至少是其中之一)是 Ipv6pReassemblyTimeout()
。虽然我们无法在最初的片段处理时引发溢出,但我们似乎可以在清理时引发溢出。
IPv6 片段会保留在内存中,直到发生以下三种情况之一:
-
我们的分片处理得非常糟糕,以至于系统告诉我们该停止了。 -
我们发送了一个 More
字段设置为 0 的片段,这表明这是最后一个片段,系统将开始重新组装。 -
我们在超时时间(60 秒)到期前未发送最后一个片段,系统丢弃了这些片段。
Ipv6pReassemblyTimeout()
在第三种情况下被调用,让我们看看这如何被利用。
这正是我们需要的!
我们之前的问题是代码在内存分配和复制操作中使用了相同的计算。而这段代码则没有。让我们深入研究一下 ASM,以了解它是如何被利用的。
负责计算分配大小的汇编代码
如你所见,计算的第一部分(fragment_list->net_buffer_length + reassembly->packet_length + 8
)使用的是 16 位的 DX 寄存器。
如果你还记得我们之前的讨论,我们使 reassembly->packet_length
下溢至 0xFFD0。因此在加上 8 字节后,DX 寄存器的值为 0xFFD8。如果 fragment_list->net_buffer_length
大于 0x27(39 字节),DX 寄存器将溢出并重置为零。
fragment_list->net_buffer_length
应该约为 0x38 字节,因此它将导致 DX 寄存器溢出至 8。再加上 0x28 字节后,我们将获得仅 48 字节的内存分配。
由于随后的 memmove()
调用只是使用未经修改的 reassembly->packet_length
值作为大小,它将导致从 reassembly->payload
复制 65,488 字节到一个 30 字节的缓冲区中。一个很大的额外好处是,大部分复制的数据来自我们控制的片段负载,它可以是任意格式的任意数据,因此我们得到了一个相当可控的内核池缓冲区溢出。
为了有机会触发漏洞,我们需要在 IppSendErrorList
被调用时,确保一个或多个片段包位于链接列表中恶意选项包之后。然而,根据我的测试,这似乎并不能保证利用成功。我认为还有一些其他条件需要满足。我怀疑但尚未证实,IppSendError
中的同步代码意味着我们还必须赢得一个竞争条件。
目前就是这些
我本想发布一个 DoS 漏洞概念验证程序,但触发该漏洞的难度极高,无法进行大范围利用。虽然我利用 ynwarcs 提供的最后一块拼图使我的 PoC 生效,但它要求目标系统故意被限制,以适应 Python 的低吞吐能力。
我感觉可能存在更好且更一致的方法来确保包合并发生,可能是通过发送特别设计的包,即使在低流量情况下也能阻塞解析器……但我不确定还要在这上面花多少时间。我已经学到了很多,PoC 也成功了,文章也写好了。所以我认为是时候收工了(技术上讲,这其实是几周的工作),回到其他工作上了。
总之,我希望你喜欢这篇文章,并从我的研究中学到了一些东西!
原文始发于微信公众号(3072):CVE-2024-38063 tcpip.sys RCE漏洞分析