以下文档详细描述了 VirtualBox v5.1.22 中发现的一个漏洞(已在 v5.1.24 中修复),该漏洞位于客户设备仿真组件 DevE1000(Intel 82540EM 以太网控制器仿真)的函数 e1kFallbackAddToFrame 中,当客户操作系统被攻击者控制时,该漏洞会导致宿主机上的缓冲区溢出。
Oracle 已在 2017年7月的CPU 中确认了该漏洞,并发布了 CVE-2017-10235。
该漏洞已在运行 Linux(Ubuntu 16.04)和 Windows(v8.1)宿主机的环境中,以及运行 Linux(同样是 Ubuntu 16.04)客户机的环境下得到验证,但在许多不同的宿主机/客户机组合中也可能被触发。所有场景均假设使用默认网络配置:只有一个网络适配器,连接到 NAT,类型为 Intel PRO/1000 MT Desktop (82540EM)。
由于控制结构(包括函数指针)可能被攻击者控制的数据覆盖,因此可以合理假设在许多场景下可以实现远程代码执行。Oracle 为该漏洞分配了较低的 CVSS 分数,因为它认为该漏洞的机密性风险为 None,完整性风险为 Low,但我们认为这并未反映该漏洞的全部潜在危害(下文将解释 RCE 的可能性)。
VirtualBox 实现 [Intel 82540EM 以太网控制器][intel_manual] 仿真的代码(在 [src/VBox/Devices/Network/DevE1000.cpp][DevE1000_cpp] 中的函数 [e1kFallbackAddToFrame][e1kFallbackAddToFrame])实现了硬件 TCP 分段:
static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc,
bool fOnWorkerThread)
{
#ifdef VBOX_STRICT
PPDMSCATTERGATHER pTxSg = pThis->CTX_SUFF(pTxSg);
Assert(e1kGetDescType(pDesc) == E1K_DTYP_DATA);
Assert(pDesc->data.cmd.fTSE);
Assert(!e1kXmitIsGsoBuf(pTxSg));
#endif
uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN +
pThis->contextTSE.dw3.u16MSS;
Assert(u16MaxPktLen != 0);
Assert(u16MaxPktLen < E1K_MAX_TX_PKT_SIZE);
该函数正确检查了最大 TX 数据包长度 (u16MaxPktLen) 是否低于标准最大值 16288 字节 (E1K_MAX_TX_PKT_SIZE),但检查是通过 Assert 宏实现的,该宏在发布版本中会被禁用,实际上使得该检查对最终用户无效。与之对比,类似函数 [e1kAddToFrame][e1kAddToFrame] 使用了显式的 if 而非 Assert 来强制检查:
static bool e1kAddToFrame(PE1KSTATE pThis, RTGCPHYS PhysAddr,
uint32_t cbFragment)
{
PPDMSCATTERGATHER pTxSg = pThis->CTX_SUFF(pTxSg);
bool const fGso = e1kXmitIsGsoBuf(pTxSg);
uint32_t const cbNewPkt = cbFragment + pThis->u16TxPktLen;
if (RT_UNLIKELY( !fGso && cbNewPkt > E1K_MAX_TX_PKT_SIZE ))
{
E1kLog(("%s Transmit packet is too large: %u > %u(max)\n",
pThis->szPrf, cbNewPkt, E1K_MAX_TX_PKT_SIZE));
return false;
}
正常函数 (e1kAddToFrame) 与回退函数 (e1kFallbackAddToFrame) 之间的使用选择在 e1kXmitDesc() 中决定,取决于两个因素:TSE 标志在数据/上下文描述符中是否启用(由客户机操作系统控制),以及 GSO 标志是否禁用。后者取决于多种因素,因此有多种方式可禁用 GSO,但最方便的方式是启用环回模式,通过接收控制寄存器(RCTL.LBM 位)进行配置,该寄存器同样由客户机操作系统控制。
启用环回模式将使函数 [e1kXmitAllocBuf][e1kXmitAllocBuf] 使用 aTxPacketFallback 缓冲区(用于 TSE 回退和环回的传输数据包缓冲区)来分配 PDM 散聚缓冲区,其长度为 16288 字节(E1K_MAX_TX_PKT_SIZE),并指示 GSO 将被禁用(通过设置 pvUser 为 NULL)。
if (RT_LIKELY(GET_BITS(RCTL, LBM) != RCTL_LBM_TCVR))
{
...
}
else
{
/* Create a loopback using the fallback buffer and preallocated SG. */
AssertCompileMemberSize(E1KSTATE, uTxFallback.Sg, 8 * sizeof(size_t));
pSg = &pThis->uTxFallback.Sg;
pSg->fFlags = PDMSCATTERGATHER_FLAGS_MAGIC |
PDMSCATTERGATHER_FLAGS_OWNER_3;
pSg->cbUsed = 0;
pSg->cbAvailable = 0;
pSg->pvAllocator = pThis;
pSg->pvUser = NULL; /* No GSO here. */
pSg->cSegs = 1;
pSg->aSegs[0].pvSeg = pThis->aTxPacketFallback;
pSg->aSegs[0].cbSeg = sizeof(pThis->aTxPacketFallback);
}
这将导致函数 e1kXmitIsGsoBuf(在 [e1kXmitDesc][e1kXmitDesc] 内部)返回 False,并且由于数据描述符中启用了 TSE,执行流程将转向 [e1kFallbackAddToFrame][e1kFallbackAddToFrame_call](而不是具有正确检查的更安全函数 e1kAddToFrame)。
/*
* Add the descriptor data to the frame. If the frame is complete,
* transmit it and reset the u16TxPktLen field.
*/
if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg)))
{
...
}
else if (!pDesc->data.cmd.fTSE)
{
...
}
else
{
STAM_COUNTER_INC(&pThis->StatTxPathFallback);
rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread);
}
在 e1kFallbackAddToFrame 内部,由于前述检查在发布版本中被禁用,MSS 可以任意大(最多可达 64K 减去 HDRLEN),从而允许将任意大的 DTALEN 传递给 [e1kFallbackAddSegment][e1kFallbackAddSegment_call]:
/*
* Carve out segments.
*/
int rc;
do
{
/* Calculate how many bytes we have left in this TCP segment */
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
if (cb > pDesc->data.cmd.u20DTALEN)
{
/* This descriptor fits completely into current segment */
cb = pDesc->data.cmd.u20DTALEN;
rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb,
pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread);
函数 [e1kFallbackAddSegment][e1kFallbackAddSegment] 将使用该值(现在作为参数 u16Len),通过 PDMDevHlpPhysRead 将客户机内存复制到宿主机内存中的 aTxPacketFallback 缓冲区,而无需对该长度进行进一步检查,从而导致缓冲区溢出(缓冲区容量为 16288 字节,但可能写入高达 64K 的内存)。
static int e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr,
uint16_t u16Len, bool fSend, bool fOnWorkerThread)
{
int rc = VINF_SUCCESS;
/* TCP header being transmitted */
struct E1kTcpHeader *pTcpHdr = (struct E1kTcpHeader *)
(pThis->aTxPacketFallback + pThis->contextTSE.tu.u8CSS);
/* IP header being transmitted */
struct E1kIpHeader *pIpHdr = (struct E1kIpHeader *)
(pThis->aTxPacketFallback + pThis->contextTSE.ip.u8CSS);
E1kLog3(("%s e1kFallbackAddSegment: Length=%x, remaining payload=%x,
header=%x, send=%RTbool\n", pThis->szPrf, u16Len,
pThis->u32PayRemain, pThis->u16HdrRemain, fSend));
Assert(pThis->u32PayRemain + pThis->u16HdrRemain > 0);
PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr,
pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);
为了使该漏洞更有可能实现 RCE,应当注意缓冲区之后紧邻的变量是其索引(u16TxPktLen),用于向缓冲区写入数据(作为 PDMDevHlpPhysRead 参数的偏移量)。通过初始缓冲区溢出(由长度为 E1K_MAX_TX_PKT_SIZE + 2 字节的第一个数据描述符引起)控制该值,则允许在第二次调用 PDMDevHlpPhysRead(使用第二个数据描述符)时,向距离缓冲区最多 64K 的任何内存地址写入数据,而无需覆盖中间的所有内存(否则会使攻击更复杂,且需避免潜在的崩溃)。
在目标缓冲区 [aTxPacketFallback][aTxPacketFallback] 附近,向下几行且在 64K 范围内,定义了 [g_aE1kRegMap][g_aE1kRegMap] 结构体,其中包含一个函数指针向量,用于实现读写处理程序(pfnRead 和 pfnWrite),这将是第二次缓冲区溢出以实现 RCE 的理想目标。
e1kXmitAllocBuf 中的错误为完整起见,有必要提及此攻击向量的一个(次要)复杂性:函数 [e1kXmitAllocBuf][e1kXmitAllocBuf] 中似乎存在一个错误,在环回模式下,[cbTxAlloc][cbTxAlloc](下一个数据包的字节数)没有被重置为零,而在正常情况(其 if 的另一分支)下会重置。这导致线程卡在 e1kLocateTxPacket 的 while 循环中(位于 e1kXmitPending 内部):
while (e1kLocateTxPacket(pThis))
{
fIncomplete = false;
/* Found a complete packet, allocate it. */
rc = e1kXmitAllocBuf(pThis, pThis->fGSO);
/* If we're out of bandwidth we'll come back later. */
if (RT_FAILURE(rc))
goto out;
/* Copy the packet to allocated buffer and send it. */
rc = e1kXmitPacket(pThis, fOnWorkerThread);
/* If we're out of bandwidth we'll come back later. */
if (RT_FAILURE(rc))
goto out;
}
这似乎是因为 [e1kLocateTxPacket][e1kLocateTxPacket] 在 cbTxAlloc 不为零时过早地返回 True,而没有执行检查 iTxDCurrent 是否等于 nTxDFetched 的代码(这是所有描述符都已处理的通常情况),而正常情况下这会使函数返回 False,从而终止上述循环。
static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
LogFlow(("%s e1kLocateTxPacket: ENTER cbTxAlloc=%d\n",
pThis->szPrf, pThis->cbTxAlloc));
/* Check if we have located the packet already. */
if (pThis->cbTxAlloc)
{
LogFlow(("%s e1kLocateTxPacket: RET true cbTxAlloc=%d\n",
pThis->szPrf, pThis->cbTxAlloc));
return true;
}
这意味着在设置环回模式后发送给设备的第一个数据包必须是触发溢出的数据包,否则虚拟机将挂起(最终表现为 DoS 而非 RCE)。
由于网络设备的设置远非简单,且为了避免为其构建自定义驱动程序,我们修改了通用 Linux 内核的 E1000 驱动程序,以生成触发溢出的描述符(包括上下文描述符和数据描述符)。此修改后的内核可从本仓库[下载][poc_download]。该内核已在 Ubuntu 16.04 客户机上进行测试,导致 Linux 和 Windows 宿主机均发生崩溃。详细说明请见此处。
该漏洞已在 [变更集 67974][Changeset_67974](bugref:8881)中修复。e1kFallbackAddToFrame 中以 Assert 形式进行的检查已转换为显式的 if 语句,现在在发布版本中保持激活(类似于 e1kAddToFrame 中已有的做法)。此外,cbTxAlloc 现在在 e1kXmitAllocBuf 的两个分支(环回模式和正常模式)中均被设置为零。