背景介绍
今天分享国外一位18岁白帽小哥如何在 VirtualBox 中发现一个相当古老(2019 年)的漏洞,该漏洞允许虚拟机到宿主机的逃逸,漏洞编号为CVE-2019-2703。本文将带你了解这位白帽小哥的思想链,从头到尾展示整个思考过程,最终引导他发现该漏洞。
注意:本文中使用的 VirtualBox 版本为 6.0.4
研究灵感
首先这白帽小哥从 @_niklasb 和他的 VirtualBox 研究中收获了不少灵感。
- https://www.exploit-db.com/exploits/43878
- https://www.youtube.com/watch?v=fFaWE3jt7qU&t=460s&ab_channel=scrt.insomnihack
- https://phoenhex.re/2018-07-27/better-slow-than-sorry
首先尝试找出哪些子系统可以通过虚拟机控制的输入来访问,最初的研究线索受到 Niklas 研究成果的启发,白帽小哥注意到有一个名为 RT_UNTRUSTED_VOLATILE_GUEST
的宏,它标记了到达宿主机的客户控制数据 – 这看起来是一个很好的入手点。
在阅读了“Unboxing your Virtualbox”演示文稿后,其中 RT_UNTURESTED_VOLATILE_GUEST
宏的结果和演示文稿的交集之一是 VBVA 子系统。
VBCA(Virtual Box Video Acceleration) 子系统
当白帽小哥开始查看这个 Video Acceleration 代码时,他知道虚拟机中的视频子系统是一个危险的漏洞陷阱 – 大量的偏移/复制/缓冲区,所以从统计学角度来看 – 这里可能潜藏着大量的漏洞。
Video Acceleration & VM 逃逸漏洞
Video Acceleration子系统的本质是“让视频为虚拟机服务”,这基本上意味着实现绘制像素和绘制图像 – 并且它明显涉及到大量缓冲区/复制所述图像和像素的内存 – 就内存破坏漏洞而言,这从来都不是一个好主意!
因此白帽小哥决定好好深入研究这个子系统,白帽小哥认为最著名的 VM 逃逸研究之一称为“Cloudburst” – Immunity(是的,老牌 Windows 调试器)于2008年进行的一项研究,该研究在BlackHat 2009上进行了展示,当时他们已经瞄准了视频子系统!
Niklas(和其他 VirtualBox 研究人员)已经公布了不少脚本和内核模块,因此可以使用它们来快速访问 VBVA 子系统的某些区域,因此没必要重新发明轮子,直接尝试专注于虚拟机控制的输入以及使用现有脚本/内核模块可以轻松访问的代码。这样也可以保证,如果在代码中发现漏洞,那么触发漏洞就不会像其他攻击面那么困难。
体力活
浏览 RT_UNTRUSTED_VOLATILE_GUEST
宏的搜索结果,并手动将它们一一检查,有两个结果引起了白帽小哥的注意:
crVBoxServerCrCmdClrFillProcess()
crVBoxServerCrCmdBltProcess()
它们既有相似之处,也有不同之处。
引起注意的一个结果是在文件“server_presenter.cpp”中,它从函数 crVBoxServerCrCmdClrFillProcess()
开始,如下所示:
int8_t crVBoxServerCrCmdClrFillProcess(VBOXCMDVBVA_CLRFILL_HDR const RT_UNTRUSTED_VOLATILE_GUEST *pCmdTodo, uint32_t cbCmd)
{
VBOXCMDVBVA_CLRFILL_HDR const *pCmd = (VBOXCMDVBVA_CLRFILL_HDR const *)pCmdTodo;
uint8_t u8Flags = pCmd->Hdr.u8Flags;
uint8_t u8Cmd = (VBOXCMDVBVA_OPF_CLRFILL_TYPE_MASK & u8Flags);
switch (u8Cmd)
{
case VBOXCMDVBVA_OPF_CLRFILL_TYPE_GENERIC_A8R8G8B8:
{
// ...
return crVBoxServerCrCmdClrFillGenericBGRAProcess((const VBOXCMDVBVA_CLRFILL_GENERIC_A8R8G8B8*)pCmd, cbCmd);
}
// ...
}
}
这里需要注意的是将 pCmdTodo
参数标记为虚拟机缓冲区 ( RT_UNTRUSTED_VOLATILE_GUEST
) 的宏。可以看出,虚拟机控制的缓冲区被传递给一个名为 crVBoxServerCrCmdClrFillGenericBGRAProcess()
的内部函数 – 那么让我们看看这个函数的作用(不重要的代码部分已做过滤):
static int8_t crVBoxServerCrCmdClrFillGenericBGRAProcess(const VBOXCMDVBVA_CLRFILL_GENERIC_A8R8G8B8 *pCmd, uint32_t cbCmd)
{
uint32_t cRects;
const VBOXCMDVBVA_RECT *pPRects = pCmd->aRects;
// ...
RTRECT *pRects = crVBoxServerCrCmdBltRecsUnpack(pPRects, cRects);
// ...
int8_t i8Result = crVBoxServerCrCmdClrFillVramGenericProcess(pCmd->dst.Info.u.offVRAM, pCmd->dst.u16Width, pCmd->dst.u16Height, pRects, cRects, pCmd->Hdr.u32Color);
// ...
return 0;
}
本质上,有 2 个函数被调用,其输入源自访客控制的数据:
crVBoxServerCrCmdBltRecsUnpack()
– 不会深入研究这个,因为另一个才是本文章漏洞的有趣函数crVBoxServerCrCmdClrFillVramGenericProcess()
– 该函数尝试填充 VRAM(视频 RAM – 表示视频图像的内存部分,以像素填充)
- 它以一种相当通用的方式做到这一点
查看第二个函数 ( crVBoxServerCrCmdClrFillVramGenericProcess()
) 调用行 – 可以看到许多参数都是虚拟机控制的,有趣的是:
-
offVRAM
– 由虚拟机控制的uint32_t
-
u16Width
– 由虚拟机控制的uint16_t
-
u16Height
– 由虚拟机控制的uint16_t
本质上是希望将虚拟机发送指定宽度和高度尺寸的图像放置在 offVRAM
指定的偏移量中。看一下实际的函数内容:
static int8_t crVBoxServerCrCmdClrFillVramGenericProcess(VBOXCMDVBVAOFFSET offVRAM, uint32_t width, uint32_t height, const RTRECT *pRects, uint32_t cRects, uint32_t u32Color)
{
CR_BLITTER_IMG Img;
int8_t i8Result = crFbImgFromDimOffVramBGRA(offVRAM, width, height, &Img);
// ...
CrMClrFillImg(&Img, cRects, pRects, u32Color);
return 0;
}
同样,从这里调用了 2 个函数:
crFbImgFromDimOffVramBGRA()
– 第一个调用,先看看它会做什么CrMClrFillImg()
– 第二个调用,稍后再看
深入研究 crFbImgFromDimOffVramBGRA()
,事情开始变得愈加有趣了:
static int8_t crFbImgFromDimOffVramBGRA(VBOXCMDVBVAOFFSET offVRAM, uint32_t width, uint32_t height, CR_BLITTER_IMG *pImg)
{
uint32_t cbBuff = width * height * 4;
if (offVRAM >= g_cbVRam
|| offVRAM + cbBuff >= g_cbVRam)
{
WARN(("invalid param"));
return -1;
}
uint8_t *pu8Buf = g_pvVRamBase + offVRAM;
crFbImgFromDimPtrBGRA(pu8Buf, width, height, pImg);
return 0;
}
请记住以下限制:
-
height
和width
是由虚拟机控制的uint16_t
值 -
offVRAM
是由虚拟机控制的uint32_t
解决整数溢出的一种可能方法是将结果保存在更大的存储变量中,从而防止结果溢出。在这种情况下,宽度和高度都是 uint16_t
,相乘后并保存到 uint32_t
变量中。
在以上的例子中,仍然存在一个问题 – 2 个值并不是乘法中唯一的组成部分,它们被乘以 4 – 这就意味两个 uint16_t
变量的结果可能会溢出!
- 进行乘法的原因是这里使用的 BPP(每像素位数)为 32字节(每个像素使用 4 个字节)
函数中紧接着是验证 offVRAM
处写入 height * width * 4
字节偏移量,不会超出 VRAM 缓冲区 – 但由于 cbBuf
的计算错误,并且可能小于实际要写入的字节数,此处的检查错误!
- 这里需要注意的一点是,实际写入的字节数取决于
width
和height
,而不是上面计算的cbBuff
if 语句之后调用的第二个函数将构建一个结构体(称为 Img
)来保存“请求”的信息。它将包含:
- 在哪里写入数据(本质上是
VRAM + offVRAM
) - 尺寸(提供的
width
和height
) - 每像素位数 (32)
- 等等
执行操作的代码:
static void crFbImgFromDimPtrBGRA(void *pvVram, uint32_t width, uint32_t height, CR_BLITTER_IMG *pImg)
{
pImg->pvData = pvVram;
pImg->cbData = width * height * 4;
pImg->enmFormat = GL_BGRA;
pImg->width = width;
pImg->height = height;
pImg->bpp = 32;
pImg->pitch = width * 4;
}
如何利用它做一些有趣的事?
现在我们已经发现了整数溢出,这恰好允许我们绕过边界检查 – 最大的问题是如何才能让它真正做一些有趣的事情?
回到上面提到的一个函数, CrMClrFillImg()
,该函数是通过在易受攻击的函数内恶意构建的 Img
来调用的:
static int8_t crVBoxServerCrCmdClrFillVramGenericProcess(VBOXCMDVBVAOFFSET offVRAM, uint32_t width, uint32_t height, const RTRECT *pRects, uint32_t cRects, uint32_t u32Color)
{
CR_BLITTER_IMG Img;
int8_t i8Result = crFbImgFromDimOffVramBGRA(offVRAM, width, height, &Img);
// ...
CrMClrFillImg(&Img, cRects, pRects, u32Color); // NOTE: here Img is malicious
return 0;
}
前面要回忆的另一件重要的事情是, pRects
和 cRects
是也是由虚拟机控制的“矩形”(可以在之前的代码片段中看到,在函数 crVBoxServerCrCmdClrFillGenericBGRAProcess() 中)
OK,现在 CrMClrFillImg()
有了虚拟机控制的恶意 矩形
Img,让我们看看 RTRECT
结构是怎样的:
/**
* Rectangle data type, double point.
*/
typedef struct RTRECT
{
/** left X coordinate. */
int32_t xLeft;
/** top Y coordinate. */
int32_t yTop;
/** right X coordinate. (exclusive) */
int32_t xRight;
/** bottom Y coordinate. (exclusive) */
int32_t yBottom;
} RTRECT;
基本上它具有允许我们绘制矩形的坐标,深入了解 CrMClrFillImg()
来看看我们的恶意参数是如何使用的。
void CrMClrFillImg(CR_BLITTER_IMG *pImg, uint32_t cRects, const RTRECT *pRects, uint32_t u32Color)
{
RTRECT Rect;
Rect.xLeft = 0;
Rect.yTop = 0;
Rect.xRight = pImg->width;
Rect.yBottom = pImg->height;
RTRECT Intersection;
/*const RTPOINT ZeroPoint = {0, 0}; - unused */
for (uint32_t i = 0; i < cRects; ++i)
{
const RTRECT * pRect = &pRects[i];
VBoxRectIntersected(pRect, &Rect, &Intersection);
if (VBoxRectIsZero(&Intersection))
continue;
CrMClrFillImgRect(pImg, &Intersection, u32Color);
}
}
观察这个我们可以看到,它只是遍历我们传递给它的所有矩形,并尝试将它们中的每一个与“整个图像”(在上面的代码中称为 Rect)相交。如下所示:
DECLINLINE(void) VBoxRectIntersect(PRTRECT pRect1, PCRTRECT pRect2)
{
Assert(pRect1);
Assert(pRect2);
pRect1->xLeft = RT_MAX(pRect1->xLeft, pRect2->xLeft);
pRect1->yTop = RT_MAX(pRect1->yTop, pRect2->yTop);
pRect1->xRight = RT_MIN(pRect1->xRight, pRect2->xRight);
pRect1->yBottom = RT_MIN(pRect1->yBottom, pRect2->yBottom);
/* ensure the rect is valid */
pRect1->xRight = RT_MAX(pRect1->xRight, pRect1->xLeft);
pRect1->yBottom = RT_MAX(pRect1->yBottom, pRect1->yTop);
}
回想一下 X/Y 轴的工作原理,它与计算机科学中使用的常用坐标系完全相同(Y 向下增长,X 向右增长),如下所示:
假设 width = 11, height = 9
,在 CrMClrFillImg
中设置的 Img
矩形将如上图所示。
相交后,代码会验证公共区域是否为空(VBoxRectIsZero())
,如果不是,它会使用我们恶意制作的 Img 和相交的矩形调用 CrMClrFillImgRect()
。
查看 CrMClrFillImgRect()
代码:
void CrMClrFillImgRect(CR_BLITTER_IMG *pDst, const RTRECT *pCopyRect, uint32_t u32Color)
{
int32_t x = pCopyRect->xLeft;
int32_t y = pCopyRect->yTop;
int32_t width = pCopyRect->xRight - pCopyRect->xLeft;
int32_t height = pCopyRect->yBottom - pCopyRect->yTop;
Assert(x >= 0);
Assert(y >= 0);
uint8_t *pu8Dst = ((uint8_t*)pDst->pvData) + pDst->pitch * y + x * 4;
crMClrFillMem((uint32_t*)pu8Dst, pDst->pitch, width, height, u32Color);
}
回想一下,Img
的指针没有正确设置,具体来说,pvData
指向 VRAM
中的偏移量,这样如果我们从那里写入宽度 高度 4 个字节,就会越界。
总结该函数的作用如下:
-
计算
pCopyRect
指定矩形的宽度和高度(在这种情况下,它始终是当前的矩形,与整个Img
相交) -
调用
crMClrFillMem()
从计算的位置开始,使用指定的颜色和计算的尺寸填充矩形(可能超出范围!)
相交点的精彩部分主要基于以下事实:
-
恶意
Img
的尺寸(考虑 32 bpp)不适合 VRAM! -
由于我们可以将它与不同的矩形相交,所以我们可以从伪造的图像中“选择特定区域”并将数据写入其中!
OOB 写入
事实上,Img
的大矩形和我们制作的矩形之间存在交集,这意味着我们可以选择一个较小的矩形,并且该矩形也超出 VRAM 的界限,然后将我们的数据写入那里。
- 上面所说的“小”是指它可以是任意大小和偏移量(某种程度上;仍然存在一些限制,例如一次写入 4 个字节)
鉴于与 VRAM 不相符的大尺寸默认矩形,可以在任何我们希望的特定偏移量处写入我们想要的任何颜色 – 就像在任意偏移量处“绘制一个像素”一样。
我们可以在计算出的已经超出界限的矩形内的任何点绘制一个像素,通过指定坐标 – 交点将导致以受控的长度和受控的偏移量写入 OOB。
例如,如果在图片中指定从 xLeft=3, xRight=5, yTop=4, yBottom=5
开始仅绘制该矩形,使我们能够避免通配符复制并在任何给定偏移量处获得 OOB 写入。
接下来是查看其它结果,看看是否可以通过这种方法找到其它有趣的原语!
白帽小哥查看了早期 RT_UNTRUSTED_VOLATILE_GUEST
宏,搜索(位于同一子系统中)引起注意的其他结果。
另一个函数是 crVBoxServerCrCmdBltProcess()
,代码流的结构非常相似,查看该函数中的代码,同样可以使用虚拟机控制的数据获取 crVBoxServerCrCmdBltGenericBGRAProcess()
:
/** @todo RT_UNTRUSTED_VOLATILE_GUEST */
int8_t crVBoxServerCrCmdBltProcess(VBOXCMDVBVA_BLT_HDR const RT_UNTRUSTED_VOLATILE_GUEST *pCmdTodo, uint32_t cbCmd)
{
VBOXCMDVBVA_BLT_HDR const *pCmd = (VBOXCMDVBVA_BLT_HDR const *)pCmdTodo;
uint8_t u8Flags = pCmd->Hdr.u8Flags;
uint8_t u8Cmd = (VBOXCMDVBVA_OPF_BLT_TYPE_MASK & u8Flags);
switch (u8Cmd)
{
// ...
case VBOXCMDVBVA_OPF_BLT_TYPE_GENERIC_A8R8G8B8:
{
//...
return crVBoxServerCrCmdBltGenericBGRAProcess((const VBOXCMDVBVA_BLT_GENERIC_A8R8G8B8 *)pCmd, cbCmd);
}
看一下 crVBoxServerCrCmdBltGenericBGRAProcess()
,跳到有趣的案例,其中同样包含虚拟机控制的参数:
if (u8Flags & VBOXCMDVBVA_OPF_BLT_DIR_IN_2)
crVBoxServerCrCmdBltVramToVram(pCmd->alloc1.Info.u.offVRAM, pCmd->alloc1.u16Width, pCmd->alloc1.u16Height, pCmd->alloc2.Info.u.offVRAM, pCmd->alloc2.u16Width, pCmd->alloc2.u16Height, &Pos, cRects, pRects);
它是一个将图像从一个位置复制到另一个位置的函数 – 它被称为 crVBoxServerCrCmdBltVramToVram()
通过阅读此函数,可以看到到达一个代码路径,该路径将虚拟机控制的维度传递给构建源/目标 Img
-s函数,使用了之前存在漏洞的函数!
rc = crVBoxServerCrCmdBltVramToVramMem(offSrcVRAM, srcWidth, srcHeight, offDstVRAM, dstWidth, dstHeight, pPos, cRects, pRects);
if (RT_FAILURE(rc))
{
WARN(("crVBoxServerCrCmdBltVramToVramMem failed, %d", rc));
return -1;
}
通过查看 crVBoxServerCrCmdBltVramToVramMem()
的实现方式(以及调用的易受攻击函数),可以看到:
static int8_t crVBoxServerCrCmdBltVramToVramMem(VBOXCMDVBVAOFFSET offSrcVRAM, uint32_t srcWidth, uint32_t srcHeight, VBOXCMDVBVAOFFSET offDstVRAM, uint32_t dstWidth, uint32_t dstHeight, const RTPOINT *pPos, uint32_t cRects, const RTRECT *pRects)
{
CR_BLITTER_IMG srcImg, dstImg;
int8_t i8Result = crFbImgFromDimOffVramBGRA(offSrcVRAM, srcWidth, srcHeight, &srcImg);
// ...
i8Result = crFbImgFromDimOffVramBGRA(offDstVRAM, dstWidth, dstHeight, &dstImg);
// ...
CrMBltImg(&srcImg, pPos, cRects, pRects, &dstImg);
return 0;
}
这与之前几乎相同,但这次它将数据从一个位置复制到另一个位置 – 由于我们 crFbImgFromDimOffVramBGRA()
中的问题,目标/源都可能被恶意构造!
然后恶意的 Img
-s 被传递给负责实际复制的 CrMBltImg()
,再次与虚拟机控制的矩形相交!但这次它将数据从 VRAM
中我们可以控制的一个位置(可以是 OOB)复制到 VRAM
中的另一个偏移量。
然后,虚拟机可以读取该数据 – 因此我们可以获得 OOB 读取和 VRAM 的信息泄漏
白帽小哥没有从这里编写完整的漏洞利用程序,因为他看到了具有类似原语的多个漏洞利用程序(特别是基于 VRAM 的 OOB),确认这是可以利用的。具体来说,Niklas 的此类漏洞还利用了与 VRAM 缓冲区相关的 OOB r/w,这也在上面提到过:
Crash
上图是白帽小哥调试主机操作系统的 VirtualBox 进程并触发崩溃的旧截图,在这张截图中,rax
相对于 VRAM
缓冲区超出了界限(已经到达未映射区域),而 edx 完全受到控制,从而触发了 OOB 写入。
为了触发这个漏洞,白帽小哥在很大程度上依赖于 Niklas 的内核模块和 Python 脚本。
收获
通过本文中的思考和研究过程,最大的收获是“即使是一个古老的漏洞发现也可以从过程本身学到很多有用的东西!“
-
依赖‘前辈们’先前的研究(例如本文中的脚本/内核模块)是完全合法的,同时也可以节省大量时间
-
有时使用“标记虚拟机控制数据的宏”之类的东西作为线索来找到要研究的内容是可行的,使用任何必要的手段来确保你的成功!
-
错误就是错误,不要太快抛弃它——在这种情况下,最初的问题是整数溢出,这并不是最复杂的错误类型,在大多数情况下,这只是一个毫无意义的乱拷贝,这里给我们的启示是,即使线索通常不会产生结果,也不要过快的抛弃线索
以上内容由骨哥翻译并整理。