整个驱动文件中仅有一处更改,结果证明,它确实就是漏洞所在。
安装补丁前后tcpip.sys的bindiff概览。
整个驱动中只有一个函数被修改。通常,我可能会花一整天时间检查20多个不同的函数变化,只为找出应该关注哪一个,但这次不用了。

Ipv6pProcessOptions() 补丁前。
Ipv6pProcessOptions() . 补丁后。
不仅只有一个函数被更改,而且只更改了一行代码。
名称极长的 Feature_2660322619__private_IsEnabledDeviceUsage_3() 函数是微软有时会添加的,用于实现部分补丁回滚。该调用检查是否存在全局标志或注册表设置,如果设置,该函数将返回false,导致原始代码被执行而非补丁版本。
微软这样做的原因是,安全补丁有时会意外破坏某些功能,因此此设置使管理员能够单独取消修补某个漏洞,而无需卸载整个月度补丁汇总,从而大幅削弱系统安全性。
考虑到这一点,很明显这个补丁所做的就是用 IppSendError() 替换了对 IppSendErrorList() 的调用,这给我们提供了一个线索:问题出在某种列表上。史上最简单的补丁差异(至少我是这么认为的)
逆向分析补丁以找到被更改的代码只是挑战的一半(在本次案例中甚至不到0.1%)。剩下的过程包括逆向分析足够的代码库以理解发生了什么,弄清楚被修补的是哪种漏洞,如何构造请求以到达目标代码,以及什么状态会导致可利用的条件。
第一部分很简单。更改发生在 Ipv6pProcessOptions() 中,这告诉我们它涉及IPv6和选项处理。所以,快速查阅RFC就能精确知道什么是IPv6选项以及在哪里可以找到它。
来自Wikipedia的目标选项头布局。
好的,明白了。我们要找的似乎是目标选项头,它直接位于主IPv6头之后。让我们用Python库‘scapy’来构造一个测试IPv6包。
注意:为缓解使用伪造IP地址的DDoS攻击,Windows限制了构造原始IP数据包的能力。因此,我选择使用Linux来开发我的概念验证。虽然Linux确实允许用户构造和发送原始层2和层3数据包,但需要以root身份运行Python脚本。
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])
tcpip!Ipv6pProcessOptions 设置断点后,运行脚本,很明显,要到达易受攻击的函数,只需发送一个带有空选项结构的IPv6包即可。然后我尝试在结构中添加一些无效选项,看看能否触发对 IppSendErrorList() 的调用。快速的代码审查表明,几乎任何无效的选项格式都可能触发对IppSendErrorList的调用。因此,我决定使用巨形包选项,并设置无效长度(小于65535字节)。
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
那么,IppSendErrorList() 实际上做了什么?好吧,代码非常简单。
整个IppSendErrorList函数。
代码遍历一个链表,并对列表中的每个元素调用 IppSendError()。再次,星辰排列,到目前为止一切顺利。如果IppSendErrorList只是对列表中的每个元素调用IppSendError,而补丁用IppSendError替换了对IppSendErrorList的调用,那么问题就发生在IppSendError被调用在列表中不是第一个元素上时。
这时事情从显而易见变得异常困难,不过我认为很大一部分原因是我的两个可用脑细胞中有一个正忙于对抗严重的COVID感染。我花了好几天理解代码的部分,然后睡着,接着忘记了我之前搞清楚了什么。整个过程需要超过一周的时间逆向分析tcpip.sys的部分内容来弄清楚发生了什么。但是Axel的博客文章非常有帮助。
通过查看Axel逆向分析的函数和结构,以及它们被传递给哪些其他函数,很明显,传递给 Ipv6pProcessOptions() 的唯一参数就是文章中定义的同一个 packet_t 结构。本质上,传递给 Ipv6pProcessOptions 并由 IppSendErrorList 遍历的指针,是一个数据包的链表。
所以,我在 Ipv6pProcessOptions() 上设置断点,并检查了列表。

list->Next 条目是 NULL。
每次我的断点被触发时,列表中只包含一个数据包。我花了比自己愿意承认的更长的时间试图弄清楚为什么以及如何让我的列表真正成为一个列表。我的第一个想法是IPv6分片:IPv6允许发送方将大数据包拆分为多个较小的数据包,将它们一起保存在列表中是有道理的。
经过大量逆向工程,我确认我的假设是正确的,尽管分片列表与我们这里处理的列表无关。
我实际上完全是偶然找到了答案。偶尔,列表会填充,但原因不清楚。经过很多周折,我意识到当我的内核断点被触发时,它会暂停整个内核,导致网络适配器积累数据包。当内核恢复时,这些数据包会被传递到tcpip.sys的栈中,形成一个整齐的列表。这只发生在内核暂停期间发送的数据包,但在下一个断点触发之前未被处理的情况下。
这种行为很可能是一种性能优化,在低吞吐量时,内核逐个处理数据包,但在高流量时,数据包被组织成列表并批量处理。很可能列表是根据协议和源地址等因素分隔的,以加快处理速度,所以我们的列表应该只包含我们发送的IPv6数据包。
现在我们知道数据包在高吞吐量期间会被合并成列表,很明显最简单的选项是什么。讽刺的是,我们的DoS概念验证将不得不使用DoS来触发DoS条件。如果我们用突发的大量IPv6数据包淹没系统,我们应该能够得到一个很大的列表传递给 IppSendErrorList()。
起初,无论我发送多少数据包,我仍然只能让列表的n > 1(如果我暂停内核的话)。但是……因为我们使用的是Python(慢得令人痛苦),在虚拟机中(加倍痛苦),我们可能需要调整一些设置。为了抵消我的攻击系统上发生的嵌套虚拟机效应,我决定简单地将目标虚拟机重新配置为只使用单个CPU核心。

太好了!数据包列表现在是一个包含很多条目的列表!
IppSendError() 做了什么以及问题出在哪个部分。经过一些广泛的逆向工程,IppSendError的功能变得更加清晰。在通常的情况下,它只是通过设置net_buffer_list->Status为0xC000021B(STATUS_DATA_NOT_ACCEPTED)来禁用数据包。然后,它会向发送方发送一个包含错误数据包信息的ICMP错误。
IppSendError的两个相关部分。
我的第一个目标是查看tcpip.sys中是否有任何函数忽略了net_buffer_list->Status值。这将导致驱动程序处理处于未定义或意外状态的数据包,希望导致可利用的条件。

负责处理数据包的主循环。
由于负责调用所有解析函数的循环被包裹在错误检查中(意味着一旦设置了错误代码,我们就无法继续前进),我认为这是错误的兔子洞。相反,我决定回到IppSendError,看看是否有一些代码路径在设置错误代码之前修改了数据包状态,这可能导致竞态条件。
经过更多的逆向工程,我在IppSendError的底部附近发现了以下代码。

IppSendError中将packet_size设置为零的代码路径。
当IppSendErrorList(以及因此IppSendError)以always_send_icmp参数为true调用时,它似乎试图向列表中的每个数据包发送ICMP错误。
然后,出于只有上帝知道的原因,它到达了一个代码块,其中packet->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字段做了些傻事。

嗯……如此接近,却又如此遥远
我们这里确实有一个漏洞,但它不是远程代码执行。
本质上,在大多数CPU上,寄存器是循环的。如果你将寄存器递增超过其最大可能值,它会绕回到零。同样,如果你将其递减低于其最低可能值,它会绕回到最大可能值。这分别被称为整数溢出和整数下溢。这种行为对于有符号整数略有不同,但我们这里不处理这些。
第一行,fragment_size = LOWORD(packet->packet_size) - 0x30,由以下汇编代码组成:

计算分片大小的汇编代码。
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 Echo请求,我们就有可能泄漏随机的内核内存,导致ASLR绕过。
除此之外,代码还将reassembly->fragment_size设置为下溢后的16位整数(65,488),因此现在我们有两个单独的变量可能被用来造成缓冲区溢出。
IPv6分片将在内存中保留,直到满足以下三个条件之一:
Ipv6pReassemblyTimeout() 在条件3下被调用,所以让我们检查如何利用它。

这正是我们需要的!

负责计算分配大小的汇编代码。
如你所见,计算的第一部分 (fragment_list->net_buffer_length + reassembly->packet_length + 8) 使用16位DX寄存器进行。
如果你还记得之前的内容,我们将reassembly->packet_length下溢为0xFFD0。所以DX寄存器,在加上8字节后,是0xFFD8。如果fragment_list->net_buffer_length大于0x27(39字节),DX将溢出并重置为零。
fragment_list->net_buffer_length 应该大约为0x38字节,所以它将导致DX寄存器溢出到8。在加上0x28字节后,我们将得到仅48字节的内存分配。
由于后续的 memmove() 调用只使用未修改的reassembly->packet_length值作为大小,它将导致65,488字节从reassembly->payload复制到一个30字节的缓冲区中。 - 一个很大的额外好处是,复制的数据大部分来自分片负载,我们控制着它,并且可以是任意格式的任意数据,因此我们得到了一个相当可控的内核池缓冲区溢出。
为了有机会触发漏洞,我们需要在调用IppSendErrorList时,链接列表中至少有一个或多个分片数据包位于畸形选项数据包之后。然而,根据我的测试,这似乎并不能保证利用成功。我相信还有一些其他条件需要满足。我怀疑(但尚未确认)IppSendError中的同步代码意味着我们还必须赢得一个竞态条件。
解决方案(或至少其中之一)是 Ipv6pReassemblyTimeout()。虽然我们不能在初始分片处理中造成溢出,但显然可以在清理期间做到。