我喜欢 VirtualBox,但这与我发布一个 0day 漏洞无关。原因在于我对当今信息安全领域,尤其是安全研究和漏洞赏金现状的不认同:
我对前两点感到厌倦,因此我的行动是完全公开。信息安全,请向前迈进。
受影响软件: VirtualBox 5.2.20 及更早版本。
宿主机操作系统: 任意(漏洞存在于共享代码库中)。
客户机操作系统: 任意。
虚拟机配置: 默认(唯一要求是网络适配器为 Intel PRO/1000 MT Desktop (82540EM),且模式为 NAT)。
在修补后的 VirtualBox 版本发布之前,你可以将虚拟机的网络适配器改为 PCnet(两种之一)或 Paravirtualized Network。如果无法更改,则将模式从 NAT 改为其他。前一种方式更安全。
VirtualBox 默认的虚拟网络设备是 Intel PRO/1000 MT Desktop (82540EM),默认网络模式是 NAT。我们将其称为 E1000。
E1000 存在一个漏洞,允许客户机中具有 root/管理员权限的攻击者逃逸到宿主机的 ring3。然后,攻击者可以利用现有技术通过 /dev/vboxdrv 将权限提升至 ring 0。
为了发送网络包,客户机的操作与普通 PC 相同:配置网络适配器并将网络包提供给它。这些包包括数据链路层帧以及更高级的头。提供给适配器的包被封装在 Tx 描述符(Tx 表示发送)中。Tx 描述符是一种数据结构,在 82540EM 数据手册(317453006EN.PDF,修订版 4.0)中有描述。它存储诸如包大小、VLAN 标签、TCP/IP 分段启用标志等元信息。
82540EM 数据手册提供了三种 Tx 描述符类型:遗留、上下文、数据。我认为遗留类型已被弃用。另外两种一起使用。我们关心的只是上下文描述符设置最大包大小并启用 TCP/IP 分段,而数据描述符保存网络包的物理地址及其大小。数据描述符的包大小必须小于上下文描述符的最大包大小。通常,上下文描述符在数据描述符之前提供给网络适配器。
为了向网络适配器提供 Tx 描述符,客户机将它们写入 Tx 环。这是一个位于物理内存预定义地址的环形缓冲区。当所有描述符都写入 Tx 环后,客户机更新 E1000 的 MMIO TDT 寄存器(发送描述符尾部),以告知主机有新描述符需要处理。
考虑以下 Tx 描述符数组:``` [context_1, data_2, data_3, context_4, data_5]
让我们如下分配其结构字段(字段名是假设的,便于人类阅读,但直接映射到82540EM规范):```
context_1.header_length = 0
context_1.maximum_segment_size = 0x3010
context_1.tcp_segmentation_enabled = true
data_2.data_length = 0x10
data_2.end_of_packet = false
data_2.tcp_segmentation_enabled = true
data_3.data_length = 0
data_3.end_of_packet = true
data_3.tcp_segmentation_enabled = true
context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true
data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true
我们将在逐步分析中了解它们为何应该如此。
假设上述描述符按指定顺序写入 Tx Ring,并且 TDT 寄存器由客户机更新。现在,主机将在 src/VBox/Devices/Network/DevE1000.cpp 文件中执行 e1kXmitPending 函数(为便于阅读,大部分注释已被且将继续被移除):```c static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread) { ... while (!pThis->fLocked && e1kTxDLazyLoad(pThis)) { while (e1kLocateTxPacket(pThis)) { fIncomplete = false; rc = e1kXmitAllocBuf(pThis, pThis->fGSO); if (RT_FAILURE(rc)) goto out; rc = e1kXmitPacket(pThis, fOnWorkerThread); if (RT_FAILURE(rc)) goto out; }
e1kTxDLazyLoad 将从 Tx Ring 读取所有 5 个 Tx 描述符。然后第一次调用 e1kLocateTxPacket。该函数遍历所有描述符以设置初始状态,但并未实际处理它们。在我们的例子中,第一次调用 e1kLocateTxPacket 将处理 context_1、data_2 和 data_3 描述符。剩下的两个描述符,context_4 和 data_5,将在 while 循环的第二次迭代中处理(我们将在下一节讨论第二次迭代)。这种两部分数组划分对于触发漏洞至关重要,因此我们来分析原因。
e1kLocateTxPacket 看起来像这样:```c
static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
...
for (int i = pThis->iTxDCurrent; i < pThis->nTxDFetched; ++i)
{
E1KTXDESC *pDesc = &pThis->aTxDescriptors[i];
switch (e1kGetDescType(pDesc))
{
case E1K_DTYP_CONTEXT:
e1kUpdateTxContext(pThis, pDesc);
continue;
case E1K_DTYP_LEGACY:
...
break;
case E1K_DTYP_DATA:
if (!pDesc->data.u64BufAddr || !pDesc->data.cmd.u20DTALEN)
break;
...
break;
default:
AssertMsgFailed(("Impossible descriptor type!"));
}
第一个描述符(context_1)的类型为 E1K_DTYP_CONTEXT,因此调用了 e1kUpdateTxContext 函数。如果该描述符启用了 TCP 分段,则此函数会更新 TCP 分段上下文。对于 context_1 而言确实如此,因此 TCP 分段上下文将被更新。(TCP 分段上下文更新的具体内容并不重要,我们仅用它来指代下方代码。)
第二个描述符(data_2)的类型为 E1K_DTYP_DATA,因此会执行一些与当前讨论无关的操作。
第三个描述符(data_3)同样为 E1K_DTYP_DATA 类型,但由于 data_3.data_length == 0,不执行任何操作。
此时三个描述符已初步处理完毕,剩余两个。接下来关键点在于:switch 语句之后会检查描述符的 end_of_packet 字段是否已设置。对于 data_3 描述符来说,该条件为真(data_3.end_of_packet == true)。代码执行一些操作后从函数返回:```c if (pDesc->legacy.cmd.fEOP) { ... return true; }
如果 data_3.end_of_packet 为假,那么剩余的 context_4 和 data_5 描述符将被处理,漏洞就会被绕过。下面你将看到为什么该函数的返回导致了该错误。
在 e1kLocateTxPacket 函数的末尾,我们有以下描述符准备好解开网络数据包并发送到网络:context_1, data_2, data_3。然后 e1kXmitPending 的内部循环调用 e1kXmitPacket。这个函数遍历所有描述符(本例中为5个)以实际处理它们:```c
static int e1kXmitPacket(PE1KSTATE pThis, bool fOnWorkerThread)
{
...
while (pThis->iTxDCurrent < pThis->nTxDFetched)
{
E1KTXDESC *pDesc = &pThis->aTxDescriptors[pThis->iTxDCurrent];
...
rc = e1kXmitDesc(pThis, pDesc, e1kDescAddr(TDBAH, TDBAL, TDH), fOnWorkerThread);
...
if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP)
break;
}
对于每个描述符,都会调用 e1kXmitDesc 函数:```c static int e1kXmitDesc(PE1KSTATE pThis, E1KTXDESC *pDesc, RTGCPHYS addr, bool fOnWorkerThread) { ... switch (e1kGetDescType(pDesc)) { case E1K_DTYP_CONTEXT: ... break; case E1K_DTYP_DATA: { ... if (pDesc->data.cmd.u20DTALEN == 0 || pDesc->data.u64BufAddr == 0) { E1kLog2(("% Empty data descriptor, skipped.\n", pThis->szPrf)); } else { if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg))) { ... } else if (!pDesc->data.cmd.fTSE) { ... } else { STAM_COUNTER_INC(&pThis->StatTxPathFallback); rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread); } } ...
传递给 e1kXmitDesc 的第一个描述符是 context_1。该函数不对上下文描述符执行任何操作。
传递给 e1kXmitDesc 的第二个描述符是 data\_2。由于我们所有数据描述符的 tcp\_segmentation\_enable 都为 true(上述 pDesc->data.cmd.fTSE),因此我们调用 e1kFallbackAddToFrame,在处理 data\_5 时会发生整数下溢。```c
static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc, bool fOnWorkerThread)
{
...
uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS;
/*
* Carve out segments.
*/
int rc = VINF_SUCCESS;
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);
}
else
{
...
}
pDesc->data.u64BufAddr += cb;
pDesc->data.cmd.u20DTALEN -= cb;
} while (pDesc->data.cmd.u20DTALEN > 0 && RT_SUCCESS(rc));
if (pDesc->data.cmd.fEOP)
{
...
pThis->u16TxPktLen = 0;
...
}
return VINF_SUCCESS; /// @todo consider rc;
}
这里最重要的变量是 u16MaxPktLen、pThis->u16TxPktLen 和 pDesc->data.cmd.u20DTALEN。
我们绘制一个表格,指定在执行 e1kFallbackAddToFrame 函数前后,这两个数据描述符的这些变量值。
| Tx 描述符 | 执行前/后 | u16MaxPktLen | pThis->u16TxPktLen | pDesc->data.cmd.u20DTALEN |
|---|---|---|---|---|
| data_2 | 执行前 | 0x3010 | 0 | 0x10 |
| - | 执行后 | 0x3010 | 0x10 | 0 |
| data_3 | 执行前 | 0x3010 | 0x10 | 0 |
| - | 执行后 | 0x3010 | 0x10 | 0 |
你只需要注意,当处理 data_3 时,pThis->u16TxPktLen 等于 0x10。