跳出思维定式 [2017年尘封草稿重见天日]
前言
来自未来的问候!
这篇博文最初起草于2017年初。我写完了计划中的前半部分(关于利用 CVE-2017-3558 从虚拟机逃逸到 VirtualBox 主机用户空间进程),但一直没来得及写后半部分(从 VirtualBox 主机用户空间进程到主机内核),最终几乎忘记了这篇旧草稿……但让这篇旧草稿永远尘封似乎有点可惜,所以我决定在最初起草8年后的现在,把它放进我们的博文队列。我现在对它进行了非常轻微的编辑(添加了一些链接,修正了一些语法),但它基本上还是我当时起草的样子。
阅读本文时请记住,除非另有说明,本文描述的是2017年的情况。不过,文中描述的很多代码似乎自那时以来并没有太大变化……
引言
虚拟机软件通常提供多种网络模式,包括一种 NAT 模式,该模式使来自虚拟机的流量看起来像是来自主机系统的正常流量。QEMU 和 VirtualBox 都使用 Slirp 的分支来实现此功能。Slirp 在其主页上描述如下:
Slirp 通过普通终端模拟 PPP 或 SLIP 连接。这是一个实际的 PPP 或 SLIP 链接,为保护用户而设置了防火墙。它提供了一种通过 Unix 或 Linux 主机在互联网上快速连接 Palm Pilot 的方法!!!您无需在系统上修改 /etc/inetd.conf 或 /etc/ppp/options。
Slirp 是虚拟机网络的一个有用基础,因为它可以解析原始 IP 数据包(来自模拟的网络适配器),并使用主机操作系统的正常、非特权网络 API 将其内容转发到网络。因此,Slirp 可以在主机的用户空间中运行,不需要任何特殊的内核支持。
QEMU 和 VirtualBox 都不直接使用上游的 Slirp 代码,而是使用打了补丁的版本,例如移除了通过访问特定 IP 地址来设置端口转发的功能。特别是在 VirtualBox 中,Slirp 代码已被大量修改。
本文描述了 VirtualBox 中的一个问题以及如何利用它。某些部分特定于主机操作系统;在这些情况下,本文重点关注 Linux 上的情况。
VirtualBox 中的数据包堆
VirtualBox 版本的 Slirp 使用自定义的区域分配器来存储数据包数据,特别是传入的以太网帧。每个 NAT 网络接口都有自己的区域(zone_clust),包含 nmbclusters=1024+32*64=3072 个大小为 MCLBYTES=2048 的块。每个区域的初始空闲列表从区域的高地址端开始,并线性地向低地址端推进。
该堆使用内联元数据;每个块的前缀是以下结构:
struct item {
uint32_t magic; // (always 0xdead0001)
uma_zone_t zone; // (pointer to the zone; uma_zone_t is struct uma_zone *)
uint32_t ref_count;
struct {
struct type *le_next; // (next element)
struct type **le_prev; // (address of previous le_next)
} list; // (entry in the freelist or in used_items, the list of used heap chunks)
};
块通过方法 m_freem -> m_free -> mb_free_ext -> uma_zfree -> uma_zfree_arg -> slirp_uma_free 释放。uma_zfree_arg() 函数以实际区域结构和块数据指针作为参数,并在调用 slirp_uma_free() 作为 zone->pfFree() 之前检查一些断言:
void uma_zfree_arg(uma_zone_t zone, void *mem, void *flags) {
struct item *it;
[...]
it = &((struct item *)mem)[-1];
Assert((it->magic == ITEM_MAGIC));
Assert((zone->magic == ZONE_MAGIC && zone == it->zone));
zone->pfFree(mem, 0, 0); // (zone->pfFree is slirp_uma_free)
[...]
}
不幸的是,Assert() 在发布版本中被 #define 为不执行任何操作 —— 只有“严格”版本会检查该条件。VirtualBox 下载页面上提供的版本是正常的、非严格的发布版本。
接下来,执行 slirp_uma_free():
static void slirp_uma_free(void *item, int size, uint8_t flags) {
struct item *it;
uma_zone_t zone;
[...]
it = &((struct item *)item)[-1];
[...]
zone = it->zone;
[...]
LIST_REMOVE(it, list);
if (zone->pfFini)
{
zone->pfFini(zone->pData, item, (int /*sigh*/)zone->size);
}
if (zone->pfDtor)
{
zone->pfDtor(zone->pData, item, (int /*sigh*/)zone->size, NULL);
}
LIST_INSERT_HEAD(&zone->free_items, it, list);
}
slirp_uma_free() 从块头中获取区域指针。因为 Assert() 被编译掉了,所以没有验证来确保这个区域指针指向实际的区域 —— 能够覆盖块头的攻击者可以导致此方法使用任意的区域指针。然后,执行区域的成员 pfFini,对于能够将 it->zone 指向受控数据的攻击者来说,这意味着可以执行任意的类似这样的方法调用:
{controlled pointer}({controlled pointer}, {pointer to packet data}, {controlled u32});
因为 VirtualBox 二进制文件(至少在 Linux 上)不可重定位,并且其 PLT 部分包含 memcpy(),这可以通过使用 memcpy() 的 PLT 条目的静态地址作为函数地址来用作写原语:
memcpy(dest={controlled pointer}, src={packet data}, n={controlled u32})
这意味着,即使数据包堆不包含太多有趣的数据,影响块头的堆内存损坏仍然可以相当容易地被用来攻陷 VirtualBox 进程。
漏洞
在 变更集 23155 中,以下代码被添加到 ip_input() 的顶部,该方法是处理来自虚拟机的传入 IP 数据包的方法,在对 IP 头进行任何验证之前。m 指向包含数据包数据指针和数据包数据实际长度的缓冲区结构,ip 指向不可信数据包数据内的 IP 头。RT_N2H_U16() 执行字节序转换。
if (m->m_len != RT_N2H_U16(ip->ip_len))
m->m_len = RT_N2H_U16(ip->ip_len);
这将受信任的缓冲区长度覆盖为来自 IP 数据包的不可信长度字段的内容。这尤其糟糕,因为所有安全检查都假设 m->m_len 是正确的 —— 这两行添加的代码基本上使所有后续的长度检查失效。
后来,在 变更集 59063 中,在这些行的顶部添加了以下注释:
/*
* XXX: TODO: this is most likely a leftover spooky action at
* a distance from alias_dns.c host resolver code and can be
* g/c'ed.
*/
if (m->m_len != RT_N2H_U16(ip->ip_len))
m->m_len = RT_N2H_U16(ip->ip_len);
滥用此问题的一种直接方法是向地址 10.0.2.3 发送一个小的 ICMP_ECHO 数据包,其中包含一个大的 ip_len,导致 Slirp 发回一个更大的 ICMP_ECHOREPLY,其中包含越界的堆数据。然而,Slirp 会验证 ICMP 校验和的正确性,这意味着攻击者必须猜测他们试图泄露的越界堆数据的 16 位校验和。虽然可以暴力破解此校验和,但这并不优雅。
泄露堆数据的一种更简单的方法是使用 UDP,并借助 NAT 另一侧的辅助机器,例如互联网上的机器。UDP 也有一个基于数据包数据的 16 位校验和,但与 ICMP 不同,UDP 将校验和值 0 视为“不检查校验和”。因此,通过发送一个校验和为 0 且在 IP 头中带有虚假长度的 UDP 数据包,可以可靠地泄露越界的堆数据。由于 ip_len 可能大于块大小,这也允许泄露后续块的头(和内容),从而泄露有关堆状态、堆位置和 struct uma_zone 位置的信息。
下一步是以某种方式利用该漏洞破坏块头。大部分代码只从传入的数据包中读取;然而,当带有 IP 选项的数据包到达 udp_input() 或 tcp_input() 时,IP 有效载荷(即 TCP 或 UDP 数据包头及其后的所有内容)会使用 ip_stripoptions() 移动到 IP 选项之上:
void ip_stripoptions(struct mbuf *m, [...])
{
register int i;
struct ip *ip = mtod(m, struct ip *);
register caddr_t opts;
int olen;
NOREF(mopt); /** @todo do we really will need this options buffer? */
olen = (ip->ip_hl<<2) - sizeof(struct ip);
opts = (caddr_t)(ip + 1);
i = m->m_len - (sizeof(struct ip) + olen);
memcpy(opts, opts + olen, (unsigned)i);
m->m_len -= olen;
ip->ip_hl = sizeof(struct ip) >> 2;
}
这意味着,通过发送一个带有 IP 选项和虚假长度(大于堆块)的 TCP 或 UDP 数据包,可以将后续堆块的数据包有效载荷移动到相应的堆块头上。
利用:上升到主机用户空间
在本文的这一部分,我将展示如何使用 system() 突破虚拟机并在主机系统上运行任意 shell 命令。
假设数据包堆的足够大部分未被使用,分配器的行为可以通过分配所有碎片化的堆内存来简化,只留下一个原始的、线性向下分配的空闲列表(如文章顶部所示)。可以通过发送设置了“更多分片”位的 IP 数据包来分配堆块;此类 IP 数据包必须存储在内存中,直到收到剩余分片或达到待处理分片的最大数量。为获得最大可靠性而优化的攻击可能会走更复杂的路线,并使用一种即使在任意碎片化的堆上仍然有效的方法。
第一步是将要传递给 system() 的命令放入内存,并确定它被放置在哪个地址。为此,假设空闲列表线性向下增长,攻击者可以先发送一个包含 shell 命令的 IP 分片(导致 IP 分片被存储),然后发送一个精心构造的 UDP 数据包来泄露数据:
(注意:le_prev 和 le_next 现在是已使用堆块列表(free_items)上的指针,而不是空闲列表,因此 le_next 指针指向上方。)
虽然泄露的数据不包含指向包含 shell 命令的块的指针,但它包含指向相邻块头的指针,这些指针可用于计算 shell 命令的地址。
下一个重要步骤是找出 system() 的地址。因为 system() 没有 PLT 条目,所以攻击者没有固定的地址可以跳转来调用该函数。然而,使用全局偏移表的内容,攻击者可以先计算 libc 符号之间的偏移量,并用它们来识别 libc 版本,然后使用一个 GOT 条目以及在该 libc 版本中 system() 相对于 GOT 条目指向地址的已知偏移量来计算 system() 的地址。不幸的是,似乎没有好的方法可以直接使用该漏洞从 GOT 读取,所以必须以某种不太优雅的方式完成。
可以通过调用 memcpy() 来使用该漏洞作为写原语,如“VirtualBox 中的数据包堆”部分所述。通常,可以使用该漏洞调用函数,如下所示:
首先,攻击者在内存中放置一个假的 struct uma_zone(区域头),并确定假 struct uma_zone 的地址,就像将 shell 命令放入内存一样。接下来,攻击者发送一个包含假 struct vmox_heap_item(块头)的数据包,并使用一个带有虚假长度字段和 IP 选项的相邻 UDP 数据包将其移动到真实的块头上:
结果是一个具有攻击者控制的头的块,该头指向假的 struct uma_zone:
接下来,可以通过发送相应的第二个 IP 分片来释放此块,导致假 uma_zone 的成员 pfFini 被调用,参数为 zone->pData(攻击者控制)、item(假块头正后面的数据)和 zone->size(再次攻击者控制)。
在 memcpy() 的情况下,这里的一个问题是假 IP 头必须是有效的;否则,在分片重组期间可能无法识别该数据包。因此,只有通常被以太网头(14 字节长)占用的空间可以用来存储有效载荷;要写入更大的有效载荷,必须进行多次函数调用。
此时,使用写原语,可以通过如下方式覆盖内存来泄露 GOT 内容(红色部分为修改部分):
首先,一个假的堆块头被放置在 GOT 的开头,GOT 是可写的且位于固定地址。因为在 VirtualBox 进程启动后,只执行库代码,所以 GOT 开头的损坏不是问题。假块头的 le_next 指针指向一个合法的块,该块当前位于原始空闲列表的原始区域中。现在,攻击者可以覆盖区域头中的空闲列表头指针 free_items.lh_first,导致 GOT 中的假块在未来的合法分配中被返回。
此时,攻击者可以发送另一个在 IP 头中带有虚假长度字段的 UDP 数据包。这个 UDP 数据包将被放置在 GOT 的开头,数据包后面的越界数据将被泄露 —— 换句话说,就是剩余的普通 GOT 条目。
此时,攻击者可以确定 system() 的位置,并使用完全受控的参数调用 system()。
结论 [来自未来]
正如我在引言中指出的,自从我在2017年发现这个漏洞以来,相关代码似乎都没有太大变化 —— 我认为如果你今天在 VirtualBox 网络代码中发现类似的漏洞,它很可能仍然可以以类似的方式被利用。
VirtualBox 为数据包内存分配使用单独的内存区域 —— 这可能是出于性能优化的考虑。这种实现选择也应该使利用数据包内存 UAF 漏洞作为副作用变得更加困难,因为没有数据包包含指针,有点像 PartitionAlloc 或 kalloc_type。然而,通过利用已经验证的长度值等,仍然可能将数据包内存 UAF 作为 TOCTOU 来利用。
这也可能使利用数据包内存线性 OOB 写漏洞变得更加困难 —— 但选择使用内联元数据,并且完全不保护此元数据免受损坏,使得该分配器区域中的 OOB 写漏洞高度可利用。