导语:近日,俄罗斯安全研究人员Sergey Zelenyuk发布了有关VirtualBox 5.2.20及早期版本的0 day漏洞的详细信息,这些版本可以让攻击者逃离虚拟机并在主机上执行RING 3层的代码。然后,攻击者可以利用传统的攻击技术将权限提升至RING 0层。
近日,俄罗斯安全研究人员Sergey Zelenyuk发布了有关VirtualBox 5.2.20及早期版本的0 day漏洞的详细信息,这些版本可以让攻击者逃离虚拟机并在主机上执行RING 3层的代码。然后,攻击者可以利用传统的攻击技术将权限提升至RING 0层。
下面是Sergey Zelenyuk在他的Github中披露的关于该漏洞的全部细节:
为什么要披露漏洞详情
我喜欢VirtualBox,它与我为什么发布0 day漏洞无关。原因是我对当代信息安全状态有不同的意见,尤其是安全研究的漏洞奖励:
1.从提交漏洞开始等待半年,直到修补了漏洞为止。
2.在bug赏金描述中:
· 等待最多一个月,直到验证了提交的漏洞,然后才决定购买或不购买。
· 动态的改变决定。今天你在软件中找出了bug赏金计划中列出的购买漏洞,但一周之后你可能会发现,他们已经对这些漏洞和漏洞利用程序“失去感兴趣”。
· 没有一个精确的软件列表来说明bug赏金规则。对于bug赏金发布者很方便,但对于安全研究人员来说这很尴尬。
· 没有精确的漏洞价格下限和上限。影响价格的因素有很多,但安全研究人员需要知道什么是值得研究的,什么不是。
3.妄想的废话:命名漏洞并为他们创建网站; 一年内召开上千次会议; 夸大自己作为安全研究员的工作的重要性; 认为自己是“世界救世主”。
我已经厌倦了前两个事情,因此我的作风是全面披露。Infosec,请继续向前进!
一般信息
易受攻击的软件: VirtualBox 5.2.20及以前的版本。
宿主机操作系统: 任何操作系统,这个漏洞是在共享代码库中。
虚拟机操作系统: 任何操作系统。
虚拟机配置: 默认(唯一的要求是网卡需要是Intel PRO/1000 MT Desktop(82540EM),网卡模式是NAT)。
如何保护自己
在VirtualBox的安全补丁构建完成之前,你可以将虚拟机的网卡的模式更改为 PCnet(两者之一)或者是半虚拟化网络。如果不能,请将模式从NAT更改为另一个模式。前一种方式更安全。
介绍
默认的VirtualBox虚拟网络设备是Intel PRO/1000 MT Desktop(82540EM),默认的网络模式是NAT。我们将其称为E1000。
E1000有一个漏洞,利用这个漏洞攻击者在虚拟机中拿到了root或者administrator权限后,就可以逃逸到宿主机的RING3层。然后,攻击者可以使用现有的提权技术通过/dev/vboxdrv将权限升级为RING0。
漏洞详细信息
E1000 101
如果要发送网络数据包,虚拟机需要像常见的PC那样操作:需要配置网卡并为虚拟机提供网络数据包。数据包是数据链路层帧和其他更高级别的网络协议头。提供给适配器的数据包包装在Tx描述符中(Tx表示发送)。Tx描述符是一个在82540EM数据表(317453006EN.PDF,Revision 4.0)中描述的数据结构。它存储了数据包大小,VLAN标签,启用TCP/IP分段的标志等元信息。
82540EM数据表提供了三种Tx描述符类型:遗留(legacy),上下文(context),数据(data)。legacy应该已被弃用。另外两个需要一起使用。我们唯一关心的是上下文描述符设置的最大数据包大小并切换TCP/IP分段,并且数据描述符保存了网络数据包的物理地址及其大小。数据描述符的数据包大小必须小于上下文描述符的最大数据包大小。通常将上下文描述符在数据描述符之前提供给网卡。
为了向网卡提供Tx描述符,虚拟机将它写入了Tx Ring。这是一个驻留在预定义地址的物理内存中的RING缓冲区。当所有描述符被写入Tx RING时,虚拟机会更新E1000 MMIO TDT寄存器(Transmit Descriptor Tail)来告知宿主机有新的描述符要处理。
输入
假设有以下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
我们将逐步分析,了解它们为什么是这样被填充的。
根本原因分析
[context_1,data_2,data_3]处理过程
假设上面的描述符以指定的顺序写入Tx RING,并且虚拟机更新TDT寄存器。现在宿主机将在src/VBox/Devices/Network/DevE1000.cpp文件中执行e1kXmitPending函数(为了便于阅读代码,已经删掉了大部分注释):
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的代码看起来像这样:
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)也是如此。代码执行了一些操作并从函数返回:
if (pDesc->legacy.cmd.fEOP) { ... return true; }
如果data_3.end_of_packet为false,则将处理剩余的context_4和data_5描述符,并且将绕过该漏洞。下面你会看到为什么从函数返回会触发漏洞。
在e1kLocateTxPacket函数结束时,我们准备好这几个描述符来解包网络数据包并发送到网络中:context_1,data_2,data_3。然后e1kXmitPending的内部循环会调用e1kXmitPacket。这个函数迭代遍历所有的描述符(在我们的例子中是第五个描述符)来实际处理它们:
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函数:
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时会出现整数下溢。
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函数之前和之后指定的一些变量的值的变化情况。
你只需要注意,当处理data_3时,pThis-> u16TxPktLen等于0x10。
接下来是最重要的部分。请再看一下e1kXmitPacket函数代码的结尾:
if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP) break;
由于data_3的类型!= E1K_DTYP_CONTEXT并且data_3.end_of_packet == true,我们从循环中断,尽管还有context_4和data_5要处理。它为什么如此重要呢?理解漏洞的关键是要了解所有上下文描述符都是在数据描述符之前处理的。在e1kLocateTxPacket中的TCP分段上下文更新期间处理上下文描述符。稍后在e1kXmitPacket函数内的循环中处理数据描述符。开发人员的意图是在处理一些数据后禁止更改u16MaxPktLen以防止代码中的整数下溢:
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
但我们能够绕过这种保护:回想一下,在e1kLocateTxPacket中,由于data_3.end_of_packet == true,我们强制函数返回。因此,我们有两个描述符(context_4和data_5)还在等待处理,尽管pThis-> u16TxPktLen是0x10,而不是0.所以有可能使用context_4.maximum_segment_size来改变u16MaxPktLen来产生整数下溢。
[context_4,data_5]处理过程
现在,当处理前三个描述符时,我们再次进入e1kXmitPending的内部循环:
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; }
这里我们可以看作是e1kLocateTxPacket对context_4和data_5描述符进行初始化处理。我们可以将context_4.maximum_segment_size设置为小于已读取数据的大小,即小于0x10。回想一下我们输入的Tx描述符:
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
作为调用e1kLocateTxPacket的结果,我们可以将最大分段大小的值设置为等于0xF,而已读取的数据大小到值设置为0x10。
最后,当处理data_5时,我们再次进入e1kFallbackAddToFrame并具有以下变量值:
因此我们有一个整数下溢:
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen; => uint32_t cb = 0xF - 0x10 = 0xFFFFFFFF;
整数下溢导致以下检查为真,因为0xFFFFFFFF> 0x4188:
if (cb > pDesc->data.cmd.u20DTALEN) { cb = pDesc->data.cmd.u20DTALEN; rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread); }
接下来将调用e1kFallbackAddSegment函数,传入的cb的大小为0x4188。如果没有此漏洞,则无法使用cb值的大小超过0x3FA0(E1K_MAX_TX_PKT_SIZE == 0x3FA0)来调用e1kFallbackAddSegment,因为在e1kUpdateTxContext中的TCP分段上下文更新期间,会检查最大段大小是否小于或等于0x3FA0:
DECLINLINE(void) e1kUpdateTxContext(PE1KSTATE pThis, E1KTXDESC *pDesc) { ... uint32_t cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; /*VTAG*/ if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE)) { pThis->contextTSE.dw3.u16MSS = E1K_MAX_TX_PKT_SIZE - pThis->contextTSE.dw3.u8HDRLEN - 4; /*VTAG*/ ... }
缓冲区溢出
我们使用大小为0x4188调用了e1kFallbackAddSegment。这怎么可以被滥用?我发现至少有两种可能性。首先,数据将从客户虚拟机读入堆缓冲区:
static int e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr, uint16_t u16Len, bool fSend, bool fOnWorkerThread) { ... PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr, pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);
这里pThis-> aTxPacketFallback是大小为0x3FA0的缓冲区,u16Len是0x4188 —“可以明显导致溢出,例如,函数指针覆盖。
其次,如果我们深入挖掘,发现e1kFallbackAddSegment调用了e1kTransmitFrame,可以通过一定的E1000寄存器配置调用e1kHandleRxPacket函数。此函数分配一个大小为0x4000的堆栈缓冲区,然后将指定长度的数据(在我们的例子中为0x4188)复制到缓冲区而不进行任何检查:
static int e1kHandleRxPacket(PE1KSTATE pThis, const void *pvBuf, size_t cb, E1KRXDST status) { #if defined(IN_RING3) uint8_t rxPacket[E1K_MAX_RX_PKT_SIZE]; ... if (status.fVP) { ... } else memcpy(rxPacket, pvBuf, cb);
如你所见,我们将整数下溢转换为了经典的堆栈缓冲区溢出。上面的两个溢出——堆和堆栈,我们将在下一篇文章的漏洞利用阶段中使用。