0x01 背景介绍
虚拟化软件VirtualBox是一个非常有趣的目Header。模拟硬件设备和将数据安全地传递到真实硬件的复杂和难度非常大。正应了那句话:哪里有复杂性,哪里就有漏洞。
对于 Pwn2Own,以仿真组件为目Header是一个比较好的选择。在我看来,网络硬件仿真似乎是正确的途径。我从一个默认组件开始:.NET 格式的 NAT 仿真代码在/src/VBox/Devices/Network/DrvNAT.cpp中。
在审计代码的过程中,我发现了一些引起我注意的代码:
static DECLCALLBACK(void) drvNATSendWorker(PDRVNAT pThis, PPDMSCATTERGATHER pSgBuf) { #if 0 /* Assertion happens often to me after resuming a VM -- no time to investigate this now. */ Assert(pThis->enmLinkState == PDMNETWORKLINKSTATE_UP); #endif if (pThis->enmLinkState == PDMNETWORKLINKSTATE_UP) { struct mbuf *m = (struct mbuf *)pSgBuf->pvAllocator; if (m) { /* * A normal frame. */ pSgBuf->pvAllocator = NULL; slirp_input(pThis->pNATState, m, pSgBuf->cbUsed); } else { /* * GSO frame, need to segment it. */ /** @todo Make the NAT engine grok large frames? Could be more efficient... */ #if 0 /* this is for testing PDMNetGsoCarveSegmentQD. */ uint8_t abHdrScratch[256]; #endif uint8_t const *pbFrame = (uint8_t const *)pSgBuf->aSegs[0].pvSeg; PCPDMNETWORKGSO pGso = (PCPDMNETWORKGSO)pSgBuf->pvUser; uint32_t const cSegs = PDMNetGsoCalcSegmentCount(pGso, pSgBuf->cbUsed); Assert(cSegs > 1); for (uint32_t iSeg = 0; iSeg < cSegs; iSeg++) { size_t cbSeg; void *pvSeg; m = slirp_ext_m_get(pThis->pNATState, pGso->cbHdrsTotal + pGso->cbMaxSeg, &pvSeg, &cbSeg); if (!m) break; #if 1 uint32_t cbPayload, cbHdrs; uint32_t offPayload = PDMNetGsoCarveSegment(pGso, pbFrame, pSgBuf->cbUsed, iSeg, cSegs, (uint8_t *)pvSeg, &cbHdrs, &cbPayload); memcpy((uint8_t *)pvSeg + cbHdrs, pbFrame + offPayload, cbPayload); slirp_input(pThis->pNATState, m, cbPayload + cbHdrs); #else ...
用于从guest向网络发送数据包的函数,包含用于通用分段卸载 (GSO)帧的单独代码路径,并调用memcpy用于合并数据片段。
在审计了各种代码路径并为所有限制因素编写了一个简单的基于 Python 的约束求解器之后,当使 VirtIO 的半虚拟化网络设备时,发现了一个重大问题。
0x02 半虚拟化网络
完全模拟设备的另一种方法是使用半虚拟化。与完全虚拟化不同,在完全虚拟化中,guest完全不知道自己是guest,半虚拟化让guest安装驱动程序,这些驱动程序知道它们在guest机器中运行,以便与主机一起以更快、更多的速度传输数据。
VirtIO 是一个可用于开发半虚拟化驱动程序的接口。有一个这样的驱动程序叫virtio-net,它与 Linux 源代码一起提供并用于网络通信。
https://github.com/torvalds/linux/blob/master/drivers/net/virtio_net.c
VirtualBox 与许多其他虚拟化软件一样,支持将其作为网络适配器:
适配器类型选项图
VirtIO 网络通过使用ring形缓冲区在guest和主机(在这种情况下称为 Virtqueues 或 VQueues)之间传输数据来工作。但是,与 e1000 漏洞不同的是,VirtIO 不使用带有头尾寄存器的单个ring进行传输,而是使用三个独立的数组:
◼一个 Descriptor 数组,每个描述符包含以下数据:
·Address - 正在传输的数据的物理地址。
·Length – 地址处数据的长度。
·Flags – 确定 Next 字段是否正在使用以及缓冲区是读取还是写入的Header志。
·Next – 在有链接时使用。
◼可用ring– 一个数组,其中包含正在使用且可由主机读取的描述符数组的索引。
◼一个已使用的ring– 主机已读取的 Descriptor 数组中的索引数组。
结构如下所示:
当guest希望向网络发送数据包时,它会在描述符表中添加一个条目,将这个描述符的索引添加到可用ring中,然后增加可用索引指针:
完成此操作后,guest通过将 VQueue 索引写入队列通知寄存器来通知主机。这会触发主机开始处理可用ring中的描述符。处理完描述符后,将其添加到已用ring中,并增加已用索引:
0x03 通用分段卸载
接下来,需要一些 GSO 的背景知识。要了解对 GSO 的需求,重要的是要了解它为网卡解决的问题。
最初,在计算传输层校验和或将它们分割成更小的以太网数据包时,CPU 将处理所有繁重的工作。由于此过程在处理大量传出网络流量时可能非常缓慢,因此硬件制造商开始为这些操作实施卸载,从而减轻操作系统的压力。
对于分段,这意味着操作系统不必通过网络堆栈传递许多小得多的数据包,操作系统只需传递一个数据包一次。
这种优化可以应用于其他协议,TCP 和 UDP 之外的协议,无需硬件支持,方法是将分段延迟到网络驱动程序接收消息之前,会创建 GSO。
由于 VirtIO 是半虚拟化设备,驱动程序知道它在guest机器中,因此可以在guest和主机之间应用 GSO。GSO 在 VirtIO 中通过在网络缓冲区的开头添加上下文描述符头来实现,可以在以下结构中看到此Header头:
struct VNetHdr { uint8_t u8Flags; uint8_t u8GSOType; uint16_t u16HdrLen; uint16_t u16GSOSize; uint16_t u16CSumStart; uint16_t u16CSumOffset; };
VirtIO 头可以被认为是与 e1000漏洞中的上下文描述符类似的概念。
收到此Header头后,将验证参数vnetR3ReadHeader某些级别的有效性。然后函数vnetR3SetupGsoCtx用于填充 VirtualBox 在所有网络设备上使用的标准HeaderGSO 结构:
typedef struct PDMNETWORKGSO { /** The type of segmentation offloading we're performing (PDMNETWORKGSOTYPE). */ uint8_t u8Type; /** The total header size. */ uint8_t cbHdrsTotal; /** The max segment size (MSS) to apply. */ uint16_t cbMaxSeg; /** Offset of the first header (IPv4 / IPv6). 0 if not not needed. */ uint8_t offHdr1; /** Offset of the second header (TCP / UDP). 0 if not not needed. */ uint8_t offHdr2; /** The header size used for segmentation (equal to offHdr2 in UFO). */ uint8_t cbHdrsSeg; /** Unused. */ uint8_t u8Unused; } PDMNETWORKGSO;
构建完成后,VirtIO 代码就会创建一个 scatter-gatherer 来从各种描述符组装数据帧:
/* Assemble a complete frame. */ for (unsigned int i = 1; i < elem.cOut && uSize > 0; i++) { unsigned int cbSegment = RT_MIN(uSize, elem.aSegsOut[i].cb); PDMDevHlpPhysRead(pDevIns, elem.aSegsOut[i].addr, ((uint8_t*)pSgBuf->aSegs[0].pvSeg) + uOffset, cbSegment); uOffset += cbSegment; uSize -= cbSegment; }
数据帧与新的 GSO 结构一起传递给 NAT 代码,现在达到了最初引起我兴趣的地方。
0x04 漏洞分析
1.CVE-2021-2145 – Oracle VirtualBox NAT 整数下溢权限提升漏洞
当 NAT 代码收到 GSO 帧时,它会获取完整的以太网数据包并将其作为mbuf消息传递给Slirp(用于 TCP/IP 仿真的库)。为了做到这一点,VirtualBox 分配了一条mbuf新消息并将数据包复制过去。分配函数获取一个大小并从三个不同的存储桶中选择一个最大的分配大小:
MCLBYTES(0x800 字节)
MJUM9BYTES(0x2400 字节)
MJUM16BYTES(0x4000 字节)
struct mbuf *slirp_ext_m_get(PNATState pData, size_t cbMin, void **ppvBuf, size_t *pcbBuf) { struct mbuf *m; int size = MCLBYTES; LogFlowFunc(("ENTER: cbMin:%d, ppvBuf:%p, pcbBuf:%p\n", cbMin, ppvBuf, pcbBuf)); if (cbMin < MCLBYTES) size = MCLBYTES; else if (cbMin < MJUM9BYTES) size = MJUM9BYTES; else if (cbMin < MJUM16BYTES) size = MJUM16BYTES; else AssertMsgFailed(("Unsupported size")); m = m_getjcl(pData, M_NOWAIT, MT_HEADER, M_PKTHDR, size); ...
如果提供的大小大于MJUM16BYTES,就会触发断言。不幸的是,此断言仅在使用RT_STRICT宏时才编译,而在发布版本中不这样。这意味着在命中此断言后将继续执行,从而为分配选择大小为 0x800 的存储桶。由于实际数据量较大,这会导致用户数据复制到mbuf.
/** @def AssertMsgFailed * An assertion failed print a message and a hit breakpoint. * * @param a printf argument list (in parenthesis). */ #ifdef RT_STRICT # define AssertMsgFailed(a) \ do { \ RTAssertMsg1Weak((const char *)0, __LINE__, __FILE__, RT_GCC_EXTENSION __PRETTY_FUNCTION__); \ RTAssertMsg2Weak a; \ RTAssertPanic(); \ } while (0) #else # define AssertMsgFailed(a) do { } while (0) #endif
2.CVE-2021-2310 - 基于 Oracle VirtualBox NAT 堆的缓冲区溢出权限提升漏洞
在整个代码中,使用了一个调用的函数PDMNetGsoIsValid来验证guest提供的 GSO 参数是否有效。但是无论何时使用它,它都会放在断言中。例如:
DECLINLINE(uint32_t) PDMNetGsoCalcSegmentCount(PCPDMNETWORKGSO pGso, size_t cbFrame) { size_t cbPayload; Assert(PDMNetGsoIsValid(pGso, sizeof(*pGso), cbFrame)); cbPayload = cbFrame - pGso->cbHdrsSeg; return (uint32_t)((cbPayload + pGso->cbMaxSeg - 1) / pGso->cbMaxSeg); }
如前所述,像这样的断言不会在发布版本中编译。这会导致允许无效的 GSO 参数;给定的大小可能会导致错误计算slirp_ext_m_get,使其小于for 循环中的memcpy复制总量。在我的PoC中,我用于计算pGso->cbHdrsTotal + pGso->cbMaxSeg的参数cbMin导致分配了 0x4000 字节,但计算cbPayload导致了memcpy对 0x4065 字节的调用,从而使分配的区域溢出。
3.CVE-2021-2442 - Oracle VirtualBox NAT UDP Header头越界漏洞
另一种卸载机制也很脆弱:校验和卸载。校验和卸载可应用于在其消息头中有校验和的各种协议。模拟时,VirtualBox 支持 TCP 和 UDP。
为了访问此功能,GSO 帧需要u8Flags设置成员的第一位以指示需要校验和卸载。在 VirtualBox 的情况下,必须始终设置此位,因为它无法在不执行校验和卸载的情况下处理 GSO。当 VirtualBox 使用 GSO 处理 UDP 数据包时,它可能会在某些情况下在PDMNetGsoCarveSegmentQD函数中结束:
case PDMNETWORKGSOTYPE_IPV4_UDP: if (iSeg == 0) pdmNetGsoUpdateUdpHdrUfo(RTNetIPv4PseudoChecksum((PRTNETIPV4)&pbFrame[pGso->offHdr1]), pbSegHdrs, pbFrame, pGso->offHdr2);
该函数pdmNetGsoUpdateUdpHdrUfo使用offHdr2来指示 UDP 报头在数据包结构中的位置。最终这会调用一个名为RTNetUDPChecksum的函数:
RTDECL(uint16_t) RTNetUDPChecksum(uint32_t u32Sum, PCRTNETUDP pUdpHdr) { bool fOdd; u32Sum = rtNetIPv4AddUDPChecksum(pUdpHdr, u32Sum); fOdd = false; u32Sum = rtNetIPv4AddDataChecksum(pUdpHdr + 1, RT_BE2H_U16(pUdpHdr->uh_ulen) - sizeof(*pUdpHdr), u32Sum, &fOdd); return rtNetIPv4FinalizeChecksum(u32Sum); }
这就是漏洞所在。在此函数中,该uh_ulen属性是完全可信的,无需任何验证,这会导致大小超出缓冲区范围,或者因减去sizeof(*pUdpHdr)而导致整数下溢。
rtNetIPv4AddDataChecksum 接收大小值和数据包头指针并继续计算校验和:
/* iterate the data. */ while (cbData > 1) { u32Sum += *pw; pw++; cbData -= 2; }
从开发的角度来看,将大量越界数据添加在一起似乎不是特别有趣。但是,如果攻击者能够为连续的 UDP 数据包重新分配相同的堆位置,并且每次添加两个字节的 UDP 大小参数,则可以计算每个校验和的差异并泄露越界数据。
最重要的是,还可以利用此漏洞对网络中的其他虚拟机造成拒绝服务:
https://twitter.com/i/status/1421859745380638727
卸载支持在现代网络设备中很常见,因此虚拟化软件模拟设备也很自然地做到这一点。虽然大多数公共研究都集中在其主要组件上,例如环形缓冲区,卸载模块并没有受到过多的关注。
本文翻译自:https://www.sentinelone.com/labs/gsoh-no-hunting-for-vulnerabilities-in-virtualbox-network-offloads/如若转载,请注明原文地址