3D GPU: 加速QEMU逃逸
概述
2020年冬天,我心血来潮,突然想研究下QEMU逃逸。考虑到之前花了很多时间研究安卓手机GPU,所以想先看看QEMU的GPU。我发现QEMU使用了virglrenderer库来实现3D加速。关于这个库,过去有研究人员做过相关研究。作为新人,我觉得这是一个很好的研究对象。经过一番审计,我发现Ubuntu使用的virglrenderer库存在4个漏洞,我使用其中的两个漏洞完成了QEMU的逃逸。
这篇博客主要介绍:
- 分析漏洞根因;
- 如何实现QEMU逃逸。在此之前,有相关议题介绍了如何利用virglrenderer的漏洞实现逃逸。幸运的是,我的方法和它有些不同:P
- 在研究的过程中,我发现了一些非技术问题。所谓“千里之堤,溃于蚁穴”,这些问题的存在使得漏洞难以得到及时修复。
攻击面分析
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_CREATE | virgl_cmd_context_create() |
VIRTIO_GPU_CMD_CTX_DESTROY | virgl_cmd_context_destroy() |
VIRTIO_GPU_CMD_RESOURCE_CREATE_2D | virgl_cmd_create_resource_2d() |
VIRTIO_GPU_CMD_RESOURCE_CREATE_3D | virgl_cmd_create_resource_3d() |
VIRTIO_GPU_CMD_SUBMIT_3D | virgl_cmd_submit_3d() |
VIRTIO_GPU_CMD_TRANSFER_TO_HOST_2D | virgl_cmd_transfer_to_host_2d() |
VIRTIO_GPU_CMD_TRANSFER_TO_HOST_3D | virgl_cmd_transfer_to_host_3d() |
VIRTIO_GPU_CMD_TRANSFER_FROM_HOST_3D | virgl_cmd_transfer_from_host_3d() |
VIRTIO_GPU_CMD_RESOURCE_ATTACH_BACKING | virgl_resource_attach_backing() |
VIRTIO_GPU_CMD_RESOURCE_DETACH_BACKING | virgl_resource_detach_backing() |
VIRTIO_GPU_CMD_SET_SCANOUT | virgl_cmd_set_scanout() |
VIRTIO_GPU_CMD_RESOURCE_FLUSH | virgl_cmd_resource_flush() |
VIRTIO_GPU_CMD_RESOURCE_UNREF | virgl_cmd_resource_unref() |
VIRTIO_GPU_CMD_CTX_ATTACH_RESOURCE | virgl_cmd_ctx_attach_resource() |
VIRTIO_GPU_CMD_CTX_DETACH_RESOURCE | virgl_cmd_ctx_detach_resource() |
VIRTIO_GPU_CMD_GET_CAPSET_INFO | virgl_cmd_get_capset_info() |
VIRTIO_GPU_CMD_GET_CAPSET | virgl_cmd_get_capset() |
VIRTIO_GPU_CMD_GET_DISPLAY_INFO | virtio_gpu_get_display_info() |
VIRTIO_GPU_CMD_GET_EDID | virtio_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的类型,它们的关系如下:
Target | Samples | Type |
---|---|---|
PIPE_TEXTURE_1D | – | GL_TEXTURE_1D |
PIPE_TEXTURE_2D | 0 | GL_TEXTURE_2D |
PIPE_TEXTURE_2D | > 0 | GL_TEXTURE_2D_MULTISAMPLE |
PIPE_TEXTURE_3D | – | GL_TEXTURE_3D |
PIPE_TEXTURE_RECT | – | GL_TEXTURE_RECTANGLE_NV |
PIPE_TEXTURE_CUBE | – | GL_TEXTURE_CUBE_MAP |
PIPE_TEXTURE_1D_ARRAY | – | GL_TEXTURE_1D_ARRAY |
PIPE_TEXTURE_2D_ARRAY | 0 | GL_TEXTURE_2D_ARRAY |
PIPE_TEXTURE_2D_ARRAY | > 0 | GL_TEXTURE_2D_MULTISAMPLE_ARRAY |
PIPE_TEXTURE_CUBE_ARRAY | – | GL_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逃逸
整个逃逸过程分为三步:
- 泄露QEMU某个符号的地址,借此绕过ASLR;
- 构造读写原语;
- 劫持QEMU控制流,实现任意命令执行。
第二步包含以下细节:
- 利用堆特性来猜测resource的地址;
- 如何构建读原语,由于内存模型的限制,需要一些技巧来构造该原语。如果我没理解错的话,这篇BlackHat议题没有实现该原语;
绕过ASLR
由于堆的大小我们可以控制,因此,目标对象的选择会灵活些。我希望能够泄露QEMU的基地址(而不是某个so的地址),它有以下好处:
- 它不依赖于其他库;
- 不需要泄露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命令实现读写原语。要完成上述方法,我们需要解决以下问题:
- 使受害者对象紧邻越界写对象;
- 需要知道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对象的地址。实际上,我们可以基于堆的特性来计算它的地址:
- top chunk新分配的chunk地址是连续的;
- 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议题已经公开信息泄露漏洞。我觉得造成这种情况的原因可能有:
- 议题作者没有向主线披露信息泄露漏洞,只披露了其他漏洞;
- 开发人员很少关注漏洞信息;
问题2:Ubuntu未能及时修复已公开的漏洞
在我向Ubuntu披露整型下溢漏洞前5个月,主线已经修复了vrend_decode_set_shader_buffers()中的问题。我觉得出现这种情况的原因可能有:
- 上游开发者在修复问题时,未对漏洞进行标识。这会给下游维护者带来困难:他们要在诸多补丁中准确识别漏洞。当代码差异到一定程度时,确认难度可想而知;
- 上游开发者不会主动申请CVE,这会导致漏洞难以得到有效追踪;
在我看来,上游开发者应该主动为漏洞申请CVE(比如通过RedHat),并定期在相关页面进行公告,方便下游开发者及时了解漏洞信息。
总结
本文首先分析了作者在virglrenderer中发现的四个漏洞,然后介绍如何实现QEMU逃逸,最后指出作者发现的一些非技术问题,这些问题的存在导致漏洞难以及时被修复。作者希望上游开发者能够为漏洞申请CVE,并及时公告漏洞信息,以方便下游及时修复漏洞。