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

本文详细分析了CVE-2024-38063漏洞的发现与利用过程,这是一个CVSS评分9.8的Windows内核远程漏洞,涉及IPv6选项处理中的内存损坏问题,可导致内核内存泄露和缓冲区溢出。

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

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

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

最简单的补丁分析

通常,仅仅逆向工程补丁以找出与漏洞对应的代码更改就需要数天甚至数周,但这次是瞬间完成的。

事实上,这太容易了,以至于社交媒体上的多个人告诉我我错了,说漏洞在别处。我是否真的听了他们的话,然后浪费了一整天逆向错误的驱动程序?我们可能永远不会知道。

整个驱动程序文件中只做了一个更改,结果证明这确实是漏洞。

tcpip.sys补丁前后的bindiff概览

整个驱动程序中只有一个函数被修改。通常,我可能需要花一整天浏览20多个不同的函数更改才能确定应该查看哪一个,但这次不是。

补丁前的Ipv6pProcessOptions()

补丁后的Ipv6pProcessOptions()

不仅只有一个函数被更改,而且只有一行代码。

极长名称的Feature_2660322619__private_IsEnabledDeviceUsage_3()函数是微软有时添加的,用于启用部分补丁回滚。该调用检查全局标志或注册表设置的存在,如果设置,将导致函数返回false,从而执行原始代码而不是修补版本。

考虑到这一点,很明显这个补丁所做的只是将调用IppSendErrorList()替换为IppSendError(),这暗示问题与某种列表有关。最简单的补丁差异分析(或者我这么认为)。

漏洞可选,利用必须

逆向工程补丁以找到更改的代码只是挑战的一半(或者在这种情况下不到0.1%)。过程的其余部分包括逆向工程足够的代码库以理解发生了什么,弄清楚修补了什么类型的漏洞,如何制作请求到达目标代码,以及什么状态导致可利用条件。

第一部分足够简单。更改在Ipv6pProcessOptions()中,这告诉我们它涉及IPv6和处理选项。因此,快速查阅RFC告诉我们IPv6选项是什么以及在哪里可以找到它。

来自维基百科的目标选项头布局

好的,很酷。我们要找的似乎是目标选项头,它直接位于主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 Packet选项。

1
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数据包。

Yo dawg,我听说你喜欢DoS

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

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

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

但是……由于我们使用Python( painfully slow),在VM中(双重 painfully slow),我们可能需要调整一些设置。

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

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

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

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

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

经过一些广泛的逆向工程,IppSendError的作用变得更加清晰。在通常情况下,它只是通过设置net_buffer_list->Status0xC000021B(STATUS_DATA_NOT_ACCEPTED)来禁用数据包。然后,它传输一个包含错误数据包信息的ICMP错误回发送方。

IppSendError的两个相关部分

我的第一个停靠点是查看tcpip.sys中是否有任何函数忽略net_buffer_list->Status值。这将导致驱动程序处理处于未定义或意外状态的数据包, hopefully 导致可利用条件。

负责处理数据包的主循环

由于调用所有解析函数的循环被包裹在错误检查中(意味着一旦设置错误代码我们就无法去任何地方),我认为这是错误的兔子洞。

相反,我决定回到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工作,并写了一篇很酷的文章。所以,我认为是时候收工了( technically ,实际上是几周),回去工作了。

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

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