通过IPv6远程利用Windows内核漏洞CVE-2024-38063

本文详细分析了CVE-2024-38063漏洞的发现和利用过程,涉及Windows内核tcpip.sys驱动中IPv6选项处理的缺陷,通过构造特殊IPv6数据包可实现内核内存泄露和缓冲区溢出,最终实现远程代码执行。

CVE-2024-38063 - 通过IPv6远程利用内核漏洞

Marcus Hutchins

自8月13日最新Windows补丁发布以来,我一直在深入研究tcpip.sys(负责处理TCP/IP数据包的内核驱动程序)。在Windows内核最易访问的部分出现CVSS评分9.8的漏洞,我实在不能错过这个机会。

我之前从未真正研究过IPv6(或负责解析它的驱动程序),所以我知道尝试逆向工程这个漏洞将极具挑战性,但也是一个很好的学习经历。

在大多数情况下,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数据包。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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字节)的Jumbo数据包选项。

1
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])

他在制作列表,他在检查它……52,567次

这就是事情从明显变得异常困难的地方,尽管我认为这很大程度上是因为我两个可用脑细胞中的一个正忙于对抗严重的新冠感染。

我花了几天时间理解部分代码,然后睡着,然后忘记了我弄明白了什么。整个过程花了一周多的时间逆向工程tcpip.sys的部分内容来弄清楚发生了什么。但Axel的博客文章非常有帮助。

通过查看Axel逆向工程的函数和结构,以及它们被传递给哪些其他函数,很明显传递给Ipv6pProcessOptions()的唯一参数与文章中定义的packet_t结构相同。

本质上,传递给Ipv6pProcessOptions并由IppSendErrorList迭代的指针是一个数据包链表。

所以,我在Ipv6pProcessOptions()上设置了一个断点并检查了列表。

列表->Next条目为NULL

每次我的断点被触发时,列表只包含一个数据包。

我花了比我愿意承认的更长的时间来弄清楚为什么以及如何让我的列表真正成为一个列表。

我的第一个想法是IPv6分片:IPv6允许发送方将大数据包分割成单独的小数据包,将它们保存在一个列表中是合理的。

经过大量的逆向工程,我确认我的假设是正确的,尽管分片列表与我们这里处理的列表无关。

我实际上完全是偶然找到了答案。

偶尔,列表会填充,但原因不清楚。在多次绕圈子之后,我意识到当我的内核断点被触发时,它会暂停整个内核,导致网络适配器累积数据包。当内核恢复时,这些数据包以整齐的列表形式传递到堆栈下的tcpip.sys。这只有在内核暂停时发送数据包,但在下一个断点被击中之前未处理的情况下才会发生。

这种行为很可能是一种性能优化,在低吞吐量时,内核单独处理数据包,但在高容量时,数据包被组织成列表并批量处理。

最有可能的是,列表根据协议和源地址等因素分开,以加速处理,所以我们的列表应该只包含我们发送的IPv6数据包。

Yo dawg,我听说你喜欢DoS

既然我们知道数据包在高吞吐量期间会合并成列表,那么最简单的选择就很清楚了。我们的DoS PoC讽刺性地必须使用DoS来触发DoS条件。

如果我们用突发的IPv6数据包淹没系统,我们应该能够获得一个很好的大列表传递给IppSendErrorList()

起初,无论我发送多少数据包,如果我不暂停内核,我仍然只能得到n > 1的列表。

但是……由于我们使用Python(慢得令人痛苦),在VM中(双重痛苦地慢),我们可能需要调整一些设置。

为了对抗我的攻击系统中发生的VM嵌套,我决定简单地重新配置目标VM只使用单个CPU核心。

数据包列表现在是一个包含许多条目的列表!

所以,事实证明,VM中的VM并不是DoS的最佳选择,谁曾想过?但我们最终让它工作了。

现在,我们只需要弄清楚IppSendError()做了什么,以及问题出在哪部分。

更多逆向工程……再次……永远

经过一些广泛的逆向工程,IppSendError的作用变得更加清晰。在通常情况下,它只是通过设置net_buffer_list->Status0xC000021BSTATUS_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的数字来在选项头处理中引起特定错误。

1
2
3
4
5
6
7
8
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 Echo请求,我们可能泄露随机内核内存,导致ASLR绕过。

除此之外,代码还将reassembly->fragment_size设置为下溢的16位整数(65,488),所以我们现在有两个单独的变量,我们可能用来引起缓冲区溢出。

被击败,但未放弃

不幸的是(或者幸运的是,因为它可能节省了我很多时间),有人抢先了一步。在我找到使用其中一个下溢整数触发缓冲区溢出的地方之前,@ynwarcs找到了答案并发布了PoC。这解决了我谜题的最后一块。

解决方案(或至少其中之一)是Ipv6pReassemblyTimeout()。虽然我们不能在初始分片处理中引起溢出,但显然可以在清理期间做到。

IPv6分片将保留在内存中,直到发生以下三个条件之一:

  1. 我们把分片搞砸得足够严重,系统告诉我们是时候停止了。
  2. 我们发送一个’More’字段设置为0的分片,表示这是最后一个分片,系统将开始重组。
  3. 我们在超时期限(60秒)到期前没有发送最后一个分片,系统丢弃分片。

Ipv6pReassemblyTimeout()在条件3下被调用,所以让我们检查一下这如何被利用。

Ipv6pReassemblyTimeout中的漏洞代码

这正是我们需要的!之前,我们的问题是代码对内存分配和复制操作使用完全相同的计算。然而,这段代码不是。让我们更深入地看看ASM,看看它是如何可利用的。

负责计算分配大小的汇编代码

如你所见,计算的第一部分(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中的同步代码意味着我们还必须赢得竞争条件。

到此为止

我本想发布一个DoS概念验证,但证明极难可靠地触发错误,使其不切实际广泛使用。虽然我能够使用ynwarcs的最后一部分让我的PoC工作,但它需要故意限制目标系统以应对python的低吞吐量能力。

我确实觉得可能有更好、更一致的方法来确保数据包合并发生,可能通过发送特殊制作的数据包,即使在低容量下也能挂起解析器……

但是,我不确定我想在这上面花更多时间。我已经学到了很多,让我的PoC工作,并写了一篇很酷的文章。所以,我认为是时候收工了(实际上,是几周),回去工作了。

无论如何,我希望你喜欢这篇文章并从我的研究中学到了一些东西!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计