白帽故事 · 2024年5月8日

18 岁少年如何发现虚拟机逃逸漏洞

背景介绍

今天分享国外一位18岁白帽小哥如何在 VirtualBox 中发现一个相当古老(2019 年)的漏洞,该漏洞允许虚拟机到宿主机的逃逸,漏洞编号为CVE-2019-2703。本文将带你了解这位白帽小哥的思想链,从头到尾展示整个思考过程,最终引导他发现该漏洞。

注意:本文中使用的 VirtualBox 版本为 6.0.4

研究灵感

首先这白帽小哥从 @_niklasb 和他的 VirtualBox 研究中收获了不少灵感。

  1. https://www.exploit-db.com/exploits/43878
  2. https://www.youtube.com/watch?v=fFaWE3jt7qU&t=460s&ab_channel=scrt.insomnihack
  3. 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 宏的搜索结果,并手动将它们一一检查,有两个结果引起了白帽小哥的注意:

  1. crVBoxServerCrCmdClrFillProcess()
  2. 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 个函数被调用,其输入源自访客控制的数据:

  1. crVBoxServerCrCmdBltRecsUnpack() – 不会深入研究这个,因为另一个才是本文章漏洞的有趣函数
  2. crVBoxServerCrCmdClrFillVramGenericProcess() – 该函数尝试填充 VRAM(视频 RAM – 表示视频图像的内存部分,以像素填充)
  • 它以一种相当通用的方式做到这一点

查看第二个函数 ( crVBoxServerCrCmdClrFillVramGenericProcess() ) 调用行 – 可以看到许多参数都是虚拟机控制的,有趣的是:

  1. offVRAM – 由虚拟机控制的 uint32_t

  2. u16Width – 由虚拟机控制的 uint16_t

  3. 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 个函数:

  1. crFbImgFromDimOffVramBGRA() – 第一个调用,先看看它会做什么
  2. 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;
}

请记住以下限制:

  1. heightwidth 是由虚拟机控制的 uint16_t

  2. offVRAM是由虚拟机控制的uint32_t

解决整数溢出的一种可能方法是将结果保存在更大的存储变量中,从而防止结果溢出。在这种情况下,宽度和高度都是 uint16_t ,相乘后并保存到 uint32_t 变量中。

在以上的例子中,仍然存在一个问题 – 2 个值并不是乘法中唯一的组成部分,它们被乘以 4 – 这就意味两个 uint16_t 变量的结果可能会溢出!

  • 进行乘法的原因是这里使用的 BPP(每像素位数)为 32字节(每个像素使用 4 个字节)

函数中紧接着是验证 offVRAM 处写入 height * width * 4 字节偏移量,不会超出 VRAM 缓冲区 – 但由于 cbBuf 的计算错误,并且可能小于实际要写入的字节数,此处的检查错误!

  • 这里需要注意的一点是,实际写入的字节数取决于 widthheight ,而不是上面计算的 cbBuff

if 语句之后调用的第二个函数将构建一个结构体(称为 Img )来保存“请求”的信息。它将包含:

  1. 在哪里写入数据(本质上是 VRAM + offVRAM
  2. 尺寸(提供的 widthheight
  3. 每像素位数 (32)
  4. 等等

执行操作的代码:

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;
}

前面要回忆的另一件重要的事情是, pRectscRects 是也是由虚拟机控制的“矩形”(可以在之前的代码片段中看到,在函数 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 向右增长),如下所示:

img

假设 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 个字节,就会越界。

总结该函数的作用如下:

  1. 计算 pCopyRect 指定矩形的宽度和高度(在这种情况下,它始终是当前的矩形,与整个 Img 相交)

  2. 调用 crMClrFillMem() 从计算的位置开始,使用指定的颜色和计算的尺寸填充矩形(可能超出范围!)

相交点的精彩部分主要基于以下事实:

  1. 恶意 Img 的尺寸(考虑 32 bpp)不适合 VRAM!

  2. 由于我们可以将它与不同的矩形相交,所以我们可以从伪造的图像中“选择特定区域”并将数据写入其中!

OOB 写入

事实上,Img 的大矩形和我们制作的矩形之间存在交集,这意味着我们可以选择一个较小的矩形,并且该矩形也超出 VRAM 的界限,然后将我们的数据写入那里。

  • 上面所说的“小”是指它可以是任意大小和偏移量(某种程度上;仍然存在一些限制,例如一次写入 4 个字节)

鉴于与 VRAM 不相符的大尺寸默认矩形,可以在任何我们希望的特定偏移量处写入我们想要的任何颜色 – 就像在任意偏移量处“绘制一个像素”一样。

我们可以在计算出的已经超出界限的矩形内的任何点绘制一个像素,通过指定坐标 – 交点将导致以受控的长度和受控的偏移量写入 OOB。

例如,如果在图片中指定从 xLeft=3, xRight=5, yTop=4, yBottom=5 开始仅绘制该矩形,使我们能够避免通配符复制并在任何给定偏移量处获得 OOB 写入。

writing_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

img

上图是白帽小哥调试主机操作系统的 VirtualBox 进程并触发崩溃的旧截图,在这张截图中,rax 相对于 VRAM 缓冲区超出了界限(已经到达未映射区域),而 edx 完全受到控制,从而触发了 OOB 写入。

为了触发这个漏洞,白帽小哥在很大程度上依赖于 Niklas 的内核模块和 Python 脚本。

收获

通过本文中的思考和研究过程,最大的收获是“即使是一个古老的漏洞发现也可以从过程本身学到很多有用的东西!“

  1. 依赖‘前辈们’先前的研究(例如本文中的脚本/内核模块)是完全合法的,同时也可以节省大量时间

  2. 有时使用“标记虚拟机控制数据的宏”之类的东西作为线索来找到要研究的内容是可行的,使用任何必要的手段来确保你的成功!

  3. 错误就是错误,不要太快抛弃它——在这种情况下,最初的问题是整数溢出,这并不是最复杂的错误类型,在大多数情况下,这只是一个毫无意义的乱拷贝,这里给我们的启示是,即使线索通常不会产生结果,也不要过快的抛弃线索

以上内容由骨哥翻译并整理。

原文:https://j0nathanj.github.io//Dusting-off-the-VM-Escape