3D GPU: 加速QEMU逃逸

作者: 2freeman(姚俊) 分类: 安全研究,漏洞分析 发布时间: 2022-03-21 07:13

概述

2020年冬天,我心血来潮,突然想研究下QEMU逃逸。考虑到之前花了很多时间研究安卓手机GPU,所以想先看看QEMU的GPU。我发现QEMU使用了virglrenderer库来实现3D加速。关于这个库,过去有研究人员做过相关研究。作为新人,我觉得这是一个很好的研究对象。经过一番审计,我发现Ubuntu使用的virglrenderer库存在4个漏洞,我使用其中的两个漏洞完成了QEMU的逃逸。

这篇博客主要介绍:

  1. 分析漏洞根因;
  2. 如何实现QEMU逃逸。在此之前,有相关议题介绍了如何利用virglrenderer的漏洞实现逃逸。幸运的是,我的方法和它有些不同:P
  3. 在研究的过程中,我发现了一些非技术问题。所谓“千里之堤,溃于蚁穴”,这些问题的存在使得漏洞难以得到及时修复。

攻击面分析

3D GPU(virtio-gpu)的架构如图所示:

架构的最上层是Guest系统里的应用程序,它通过设备节点(例如:/dev/dri/renderDxxx)与内核驱动通信。DRM_VIRTIO_GPU驱动支持多种命令,下面列举了最常用到的几种请求命令:

命令说明
VIRTGPU_GET_CAPS获取GPU的能力信息
VIRTGPU_GETPARAM获取GPU的特性信息
VIRTGPU_RESOURCE_CREATE创建resource
VIRTGPU_RESOURCE_INFO获取resource信息
VIRTGPU_MAP映射资源到用户空间(零拷贝)
VIRTGPU_EXECBUFFER向GPU发送命令
VIRTGPU_TRANSFER_FROM_HOST从GPU中读取数据
VIRTGPU_TRANSFER_TO_HOST向GPU写入数据

如果需要的话,驱动会将请求转发给GPU。具体来说,驱动通常会将VIRTGPU_EXECBUFFER命令进行转发,这就使得Host向Guest暴露了攻击面。当然,这是从Guest的应用层的角度来看,如果攻击者自己实现驱动,攻击面会进一步扩大。

vring作为驱动和硬件通信的桥梁。驱动使用virtio_gpu_queue_ctrl_buffer()和virtio_gpu_queue_cursor()通过vring与GPU进行通信。这两个函数分别向GPU发送控制请求和光标请求。对应地,virtioio-gpu中virtio_gpu_ctrl_bh()和virtio_gpu_cursor_bh()分别处理这两个请求。因为控制请求更复杂,出现问题的可能性更大,所以我主要关注控制请求的处理。 它的处理过程如下:

virtio_gpu_ctrl_bh()
|
|-> virtio_gpu_handle_ctrl()
    |
    |-> virtio_gpu_process_cmdq()
        |
        | /* 2D */
        |-> virtio_gpu_simple_process_cmd()
        |
        | /* 3D */
        |-> virtio_gpu_virgl_process_cmd()

作为消费者,virtio_gpu_process_cmdq()从vring队列中读取请求数据。如果支持3D特性,它会调用virtio_gpu_virgl_process_cmd()来处理请求。否则,它会调用virtio_gpu_simple_process_cmd()。当3D特性开启时,硬件支持更多的命令:

命令处理函数
VIRTIO_GPU_CMD_CTX_CREATEvirgl_cmd_context_create()
VIRTIO_GPU_CMD_CTX_DESTROYvirgl_cmd_context_destroy()
VIRTIO_GPU_CMD_RESOURCE_CREATE_2Dvirgl_cmd_create_resource_2d()
VIRTIO_GPU_CMD_RESOURCE_CREATE_3Dvirgl_cmd_create_resource_3d()
VIRTIO_GPU_CMD_SUBMIT_3Dvirgl_cmd_submit_3d()
VIRTIO_GPU_CMD_TRANSFER_TO_HOST_2Dvirgl_cmd_transfer_to_host_2d()
VIRTIO_GPU_CMD_TRANSFER_TO_HOST_3Dvirgl_cmd_transfer_to_host_3d()
VIRTIO_GPU_CMD_TRANSFER_FROM_HOST_3Dvirgl_cmd_transfer_from_host_3d()
VIRTIO_GPU_CMD_RESOURCE_ATTACH_BACKINGvirgl_resource_attach_backing()
VIRTIO_GPU_CMD_RESOURCE_DETACH_BACKINGvirgl_resource_detach_backing()
VIRTIO_GPU_CMD_SET_SCANOUTvirgl_cmd_set_scanout()
VIRTIO_GPU_CMD_RESOURCE_FLUSHvirgl_cmd_resource_flush()
VIRTIO_GPU_CMD_RESOURCE_UNREFvirgl_cmd_resource_unref()
VIRTIO_GPU_CMD_CTX_ATTACH_RESOURCEvirgl_cmd_ctx_attach_resource()
VIRTIO_GPU_CMD_CTX_DETACH_RESOURCEvirgl_cmd_ctx_detach_resource()
VIRTIO_GPU_CMD_GET_CAPSET_INFOvirgl_cmd_get_capset_info()
VIRTIO_GPU_CMD_GET_CAPSETvirgl_cmd_get_capset()
VIRTIO_GPU_CMD_GET_DISPLAY_INFOvirtio_gpu_get_display_info()
VIRTIO_GPU_CMD_GET_EDIDvirtio_gpu_get_edid()

上述大部分的命令会被转发到virglrenderer,以VIRTIO_GPU_CMD_SUBMIT_3D命令为例,它的处理过程如下:

综上所述,Guest应用可以通过发送VIRTGPU_EXECBUFFER命令直接请求virtio-gpu,virtio-gpu会将部分命令转发给virglrenderer,这使得virglrenderer暴露了攻击面。

漏洞

经过一番审计,我发现了四个漏洞,包括:越界写(CVE-2022-0135)、信息泄露(CVE-2022-0175)以及整型下溢。目前这些漏洞已经得到正确修复,披露时间线如下:

2022年2月1日    Ubuntu公开细节
2021年12月29日    向redhat披露
2021年12月14日    主线完成漏洞修复
2021年11月27日    向virglrenderer主线披露
2021年11月12日    向Ubuntu官方披露 

CVE-2022-0135 (越界写)

越界写漏洞与VIRTIO_GPU_CMD_SUBMIT_3D命令处理相关。这个命令包含许多子命令,其中之一是VIRGL_CCMD_RESOURCE_INLINE_WRITE。它的处理过程如下:

virgl_renderer_submit_cmd()
|
|-> vrend_decode_block()
    |
    | /* VIRGL_CCMD_RESOURCE_INLINE_WRITE */
    |-> vrend_decode_resource_inline_write()
        |
        |-> vrend_transfer_inline_write()
            |
            |-> check_transfer_bounds()
            |
            |-> vrend_renderer_transfer_write_iov()

vrend_transfer_inline_write()只是个包装函数,它调用vrend_renderer_transfer_write_iov()来完成真正的写入操作。如果resource的存储类型不是VREND_STORAGE_GL_BUFFER,它会分配一块堆内存,堆的大小计算如下:

src/vrend_renderer.c

7019 vrend_renderer_transfer_write_iov(ctx, res, iov, num_iovs, info)
7023 {
7096     send_size = util_format_get_nblocks(res->base.format, info->box->width,
7097                                        info->box->height) * elsize;
7098     if (res->target == GL_TEXTURE_3D ||
7099        res->target == GL_TEXTURE_2D_ARRAY ||
7100        res->target == GL_TEXTURE_CUBE_MAP_ARRAY)
7101        send_size *= info->box->depth;
7103     if (need_temp) {
7104         data = malloc(send_size);
7107     read_transfer_data(iov, num_iovs, data, res->base.format, info->offset,
7108                    stride, layer_stride, info->box, invert);
7280 }

首先,util_format_get_nblocks()根据resource的类型、长度和宽度计算出所需空间大小(行7096)。如果类型是GL_TEXTURE_3D、GL_TEXTURE_2D_ARRAY或者GL_TEXTURE_CUBE_MAP_ARRAY,它还会考虑深度(行7101)。从这里可以看出,除了上述三种类型,其他类型的深度为1。之后,read_transfer_data()将数据拷贝到堆上:

src/vrend_renderer.c

6796 read_transfer_data(iov, num_iovs, data, format, offset, box, invert)
6805 {
6808     send_size = util_format_get_nblocks(format, box->width,
6809                                        box->height) * blsize * box->depth;
6814     if ((send_size == size || bh == 1) && !invert && box->depth == 1)
6815        vrend_read_from_iovec(iov, num_iovs, offset, data, send_size);
6816     else {
6817        for (d = 0; d < box->depth; d++) {
6818            myoffset = offset + d * src_layer_stride;
6819            for (h = bh - 1; h >= 0; h--) {
6820                void *ptr = data + (h * bwx) + d * (bh * bwx);
6821                vrend_read_from_iovec(iov, num_iovs, myoffset, ptr, bwx);
6822                myoffset += src_stride;
6823            }
6824         }
6825     }
6837 }

奇怪的是:read_transfer_data()在计算send_size时没有考虑类型,直接将深度参与计算(行6809)。

如果存在一种类型,它不属于上述三种类型,但是它的深度可以大于1,那么vrend_renderer_transfer_write_iov()和read_transfer_data()计算出的send_size会不一致,最终导致越界写(行6821)。要验证是否存在这种类型,我们需要知道都有哪些类型。实际上,resource的类型是在它创建时由tgsitargettogltarget()确定的:

vrend_renderer_resource_create(args)
|
|-> vrend_renderer_resource_copy_args(args, gr)
|
|-> vrend_renderer_resource_allocate_texture(gr)
    |
    |-> gr->target = tgsitargettogltarget()

这个函数有两个参数:target和nr_samples,这两个参数决定了resource的类型,它们的关系如下:

TargetSamplesType
PIPE_TEXTURE_1DGL_TEXTURE_1D
PIPE_TEXTURE_2D0GL_TEXTURE_2D
PIPE_TEXTURE_2D> 0GL_TEXTURE_2D_MULTISAMPLE
PIPE_TEXTURE_3DGL_TEXTURE_3D
PIPE_TEXTURE_RECTGL_TEXTURE_RECTANGLE_NV
PIPE_TEXTURE_CUBEGL_TEXTURE_CUBE_MAP
PIPE_TEXTURE_1D_ARRAYGL_TEXTURE_1D_ARRAY
PIPE_TEXTURE_2D_ARRAY0GL_TEXTURE_2D_ARRAY
PIPE_TEXTURE_2D_ARRAY> 0GL_TEXTURE_2D_MULTISAMPLE_ARRAY
PIPE_TEXTURE_CUBE_ARRAYGL_TEXTURE_CUBE_MAP_ARRAY

有趣的是:resource的类型有时由nr_samples决定。例如,如果target是PIPE_TEXTURE_2D_ARRAY,nr_samples值是1时,resource的类型是GL_TEXTURE_2D,而nr_samples的值大于1时,resource的类型是GL_TEXTURE_2D_MULTISAMPLE_ARRAY。

在执行写入操作之前,vrend_transfer_inline_write()调用check_transfer_bounds()来检查应用传入的数据是否合法。这个函数的代码片段如下:

src/vrend_renderer.c

6881 check_transfer_bounds(res, info)
6883 {
6908    if (res->base.target == PIPE_TEXTURE_3D) {
6909        int ldepth = u_minify(res->base.depth0, info->level);
6910        if (info->box->depth > ldepth || info->box->depth < 0)
6911            return false;
6916    } else {
6917        if (info->box->depth > (int)res->base.array_size)
6918            return false;
6923    }
6925    return true;
6926 }

从中可以看到,当res->base.target不是PIPE_TEXTURE_3D时,resource的深度受res->base.array_size限制(行6917)。这两个变量是在vrend_renderer_resource_copy_args()中赋值的:

src/vrend_renderer.c

6347 vrend_renderer_resource_copy_args(args, gr)
6349 {
6361     gr->base.nr_samples = args->nr_samples;
6362     gr->base.array_size = args->array_size;
6363 }

args参数由应用控制。如果array_size的值为3,那么类型为GL_TEXTURE_2D_MULTISAMPLE_ARRAY的resource的深度可以大于1!我们可以通过这种类型触发越界写。

在我看来,程序应该尽可能避免假设。这些假设可能随着时间的推移、代码的演进被遗忘,最终被打破,从而导致漏洞。

CVE-2022-0175(信息泄露)

在创建resource时,驱动会请求GPU同步resource的数据到驱动缓冲区:

/* Driver */
virtio_gpu_object_create()
|
|-> virtio_gpu_object_attach()
    |
    |-> virtio_gpu_cmd_resource_attach_backing()

/* virtio-gpu */
virtio_gpu_virgl_process_cmd
|
| VIRTIO_GPU_CMD_RESOURCE_ATTACH_BACKING
|
|-> virgl_cmd_ctx_attach_resource()
    |
    |-> virgl_renderer_resource_attach_iov()

/* virglrenderer */
virgl_renderer_resource_attach_iov()
|
|-> vrend_renderer_resource_attach_iov()

最终vrend_renderer_resource_attach_iov()将resource数据拷贝到驱动中。它的代码片段如下:

src/vrend_renderer.c

6057 int vrend_renderer_resource_attach_iov(iov, num_iovs)
6059 {
6073    if (has_bit(res->storage_bits, VREND_STORAGE_HOST_SYSTEM_MEMORY)) {
6074        vrend_write_to_iovec(res->iov, res->num_iovs, 0,
6075                            res->ptr, res->base.width0);
6076    }
6078    return 0;
6079 }

其中res->ptr指向resource自己的缓冲区(行6074),这段缓冲区在vrend_renderer_resource_create()分配:

src/vrend_renderer.c

6618 int vrend_renderer_resource_create(args, iov, num_iovs, image_oes)
6620 {
6645    if (args->target == PIPE_BUFFER) {
6646        if (args->bind == VIRGL_BIND_CUSTOM) {
6648            gr->storage_bits |= VREND_STORAGE_HOST_SYSTEM_MEMORY;
6649            gr->ptr = malloc(args->width);
6654        }
6704    }
6706    ret = vrend_resource_insert(gr, args->handle);
6711    return 0;
6712 }

如果resource的target是PIPE_BUFFER,且bind字段是VIRGL_BIND_CUSTOM,那么resource的缓冲区由malloc()分配(行 6649)。这种情况下,堆上遗留的旧数据并未清空。这些旧数据最终被拷贝到驱动内存中。Guest应用通过mmap系统调用,可以读取这些数据,从而导致信息泄露。由于堆的大小由Guest应用控制,因此,我们可以泄露多种大小堆的内容。

整型下溢

这里有两个整型下溢漏洞。第一个位于vrend_decode_set_shader_images():

src/vrend_decode.c
1210 int vrend_decode_set_shader_images(ctx, length)
1211 {
1213     uint32_t shader_type, start_slot;
1226     if (start_slot > PIPE_MAX_SHADER_IMAGES ||
1227        start_slot > PIPE_MAX_SHADER_IMAGES - num_images)
1228        return EINVAL;
1230     for (uint32_t i = 0; i < num_images; i++) {
1236        vrend_set_single_image_view(ctx->grctx, shader_type, start_slot + i, format, access,
1237                                    layer_offset, level_size, handle);
1238     }
1239     return 0;
1240 }

如果num_images大于PIPE_MAX_SHADER_IMAGES,那么将发生整型下溢。假设PIPE_MAX_SHADER_IMAGES的值是32,num_images的值是33,减法的结果是-1,由于start_slot的类型是uint32_t,-1被解释成0xFFFFFFFF,它大于start_slot的值。之后,循环变量i的值会超过PIPE_MAX_SHADER_IMAGES,导致vrend_set_single_image_view()发生越界写:

src/vrend_renderer.c

2941 void vrend_set_single_image_view(ctx, shader_type, index, ..., handle)
2947 {
2948     struct vrend_image_view *iview = &ctx->sub->image_views[shader_type][index];
2951     if (handle) {
2955        res = vrend_renderer_ctx_res_lookup(ctx, handle);
2960         iview->texture = res;
2961         iview->format = tex_conv_table[format].internalformat;
2962         iview->access = access;
2963         iview->u.buf.offset = layer_offset;
2964         iview->u.buf.size = level_size;
2971     }

image_views是一个二维数组,它有PIPE_SHADER_TYPES * PIPE_MAX_SHADER_IMAGES个元素。由于index大于PIPE_MAX_SHADER_IMAGES,所以iview 指针指向的位置越界(行2948),之后,发生越界写入问题(行 2960-2964)。类似的问题发生在vrend_decode_set_shader_buffers()中,它的代码片段如下:

src/vrend_decode.c
1151 int vrend_decode_set_shader_buffers(ctx, length)
1152 {
1168    if (start_slot > PIPE_MAX_SHADER_BUFFERS ||
1169        start_slot > PIPE_MAX_SHADER_BUFFERS - num_ssbo)
1170        return EINVAL;
1172    for (uint32_t i = 0; i < num_ssbo; i++) {
1176        vrend_set_single_ssbo(ctx->grctx, shader_type, start_slot + i, offset, buf_len,
1177                            handle);
1178    }
1179    return 0;
1180 }

最终导致vrend_set_single_ssbo()发生越界写。

QEMU逃逸

整个逃逸过程分为三步:

  1. 泄露QEMU某个符号的地址,借此绕过ASLR;
  2. 构造读写原语;
  3. 劫持QEMU控制流,实现任意命令执行。

第二步包含以下细节:

  1. 利用堆特性来猜测resource的地址;
  2. 如何构建读原语,由于内存模型的限制,需要一些技巧来构造该原语。如果我没理解错的话,这篇BlackHat议题没有实现该原语;

绕过ASLR

由于堆的大小我们可以控制,因此,目标对象的选择会灵活些。我希望能够泄露QEMU的基地址(而不是某个so的地址),它有以下好处:

  1. 它不依赖于其他库;
  2. 不需要泄露libc的基地址,我们可以通过plt表实现执行任意命令;

我选取的对象是BlkAioEmAIOCB,它的字段如下:

block/block-backend.c

1325 typedef struct BlkAioEmAIOCB {
1326    BlockAIOCB common;
1327    BlkRwCo rwco;
1328    int bytes;
1329    bool has_returned;
1330 } BlkAioEmAIOCB;

include/block/aio.h

31 struct BlockAIOCB {
32        const AIOCBInfo *aiocb_info;
33        BlockDriverState *bs;
34        BlockCompletionFunc *cb;
35        void *opaque;
36        int refcnt;
37 };

其中common字段保存了回调函数的地址(行34、1326),它指向dma_blk_cb()。我们可以通过打开目录来迫使QEMU分配该对象:

unsigned long leak_address(void)
{
    unsigned long dma_off;
    struct BlkAioEmAIOCB *aio;
    while (1) {
        dir = opendir(DIR_PATH);
        closedir(dir);
        res = create_resource();
        ptr = map_resource(res);
        aio = (struct BlkAioEmAIOCB*)ptr;
        dma_off = (unsigned long)aio->common.cb - dma_blk_cb;
        if (dma_off % 0x1000 == 0)
            return (unsigned long)aio->common.cb;
    }
    return 0;
}

构造读写原语

我使用vrend_resource对象来构造原语,整个过程如下:

通过堆风水,受害者对象紧邻越界写对象。我们借助漏洞篡改受害者对象的ptr指针,使其指向power对象。正常情况下,ptr指向数据缓冲区,应用可以写入任意内容。现在我们将它指向power对象,那么,我们可以控制power对象所有的字段。这里之所以没有直接将ptr指向目标地址,是因为我们无法构造出读原语。为了克服这个问题,我们需要通过power对象来进一步转化。具体的方式是使res->iov指向res->mipmap_offsets[0]。逻辑上mipmap_offsets[0]表示iov的起始地址,而mipmap_offsets[1]表示iov的长度。此时,我们可以通过VIRGL_CCMD_COPY_TRANSFER3D命令实现读写原语。要完成上述方法,我们需要解决以下问题:

  1. 使受害者对象紧邻越界写对象;
  2. 需要知道power对象的地址;

正常情况下,两个resource之间会插入其他对象。为了使resource对象相邻,我们可以先分配一些使用相同chunk的对象,然后将这些对象释放,从而迫使libc保留一些未使用的chunk。在两个resource之间分配的对象可以使用这些chunk,从而确保resource相邻。我们选取的对象是pipe_depth_stencil_alpha_state,可以通过VIRGL_CCMD_CREATE_OBJECT和VIRGL_CCMD_DESTROY_OBJECT命令来分配和释放它:

src/vrend_decode.c

vrend_decode_block()
|
| /* VIRGL_CCMD_CREATE_OBJECT */
|-> vrend_decode_create_object()
|    |
|    |/* VIRGL_OBJECT_DSA */
|    |-> vrend_decode_create_dsa()
|
| /* VIRGL_CCMD_DESTROY_OBJECT */
|-> vrend_decode_destroy_object()

为了构造原语,我们需要知道power对象的地址。实际上,我们可以基于堆的特性来计算它的地址:

  1. top chunk新分配的chunk地址是连续的;
  2. fastbin使用单链表来维护chunk,我们可以借助漏洞来泄露chunk的地址;

命令执行

拥有读写原语后,有多种方法实现命令执行。我使用的是QEMUTimer,它的调用路径如下:

til/qemu-timer.c

qemu_clock_run_all_timers()
|
|-> qemu_clock_run_timers()
    |
    |-> timerlist_run_timers()
    |
    | cb = ts->cb; /* callback function */
    | opaque = ts->opaque;
    | cb(opaque);  /* command execution */

非技术性问题

当我在分析、处理这些漏洞的过程中,我发现了一些非技术性问题。这些问题的存在,导致漏洞难以及时修复。不单单是下游产品未能及时修复漏洞,甚至上游也是如此。我个人觉得未修复的Nday漏洞危害要比0day更大:

1. 漏洞细节已经公开,甚至利用代码都可以下载;

2. 随着时间的推移,这些漏洞会被遗忘,成为“幽灵”;

问题1:两年前公开的漏洞至今仍未修复

在我完成逃逸后的某天,我突然发现两年前的BlackHat议题已经公开信息泄露漏洞。我觉得造成这种情况的原因可能有:

  1. 议题作者没有向主线披露信息泄露漏洞,只披露了其他漏洞;
  2. 开发人员很少关注漏洞信息;

问题2:Ubuntu未能及时修复已公开的漏洞

在我向Ubuntu披露整型下溢漏洞前5个月,主线已经修复了vrend_decode_set_shader_buffers()中的问题。我觉得出现这种情况的原因可能有:

  1. 上游开发者在修复问题时,未对漏洞进行标识。这会给下游维护者带来困难:他们要在诸多补丁中准确识别漏洞。当代码差异到一定程度时,确认难度可想而知;
  2. 上游开发者不会主动申请CVE,这会导致漏洞难以得到有效追踪;

在我看来,上游开发者应该主动为漏洞申请CVE(比如通过RedHat),并定期在相关页面进行公告,方便下游开发者及时了解漏洞信息。

总结

本文首先分析了作者在virglrenderer中发现的四个漏洞,然后介绍如何实现QEMU逃逸,最后指出作者发现的一些非技术问题,这些问题的存在导致漏洞难以及时被修复。作者希望上游开发者能够为漏洞申请CVE,并及时公告漏洞信息,以方便下游及时修复漏洞。