Skip to content

A note of CVE-2022-38181 write-up

原文链接:Pwning the all Google phone with a non-Google bug

1. Introduction to JIT Memory

KBASE_IOCTL_JOB_SUBMIT通常是向GPU提供作业,但也有给CPU的software jobs(在内核中实现,在CPU上运行)。

KBASE_IOCTL_JOB_SUBMIT包含BASE_JD_REQ_SOFT_JIT_ALLOC和BASE_JD_REQ_SOFT_JIT_FREE

BASE_JD_REQ_SOFT_JIT_ALLOC调用kbase_jit_allocate_process,kbase_jit_allocate_process调用kbase_jit_allocate。

kbase_context内核对象有三个list_head字段:jit_active_head、jit_pool_head和jit_destroy_head。

kbase_jit_allocate会先从jit_pool_head中找reg放入jit_active_head,如果找不到就创建一个新的reg放入jit_active_head。这个reg随后会放在kbase_context内核对象的jit_alloc数组中。

BASE_JD_REQ_SOFT_JIT_FREE调用kbase_jit_free_finish,kbase_jit_free_finish调用kbase_jit_free。kbase_jit_free释放reg,但并不是直接把内存页返回给内核,而是先最大化减少占用内存区域,移除所有来自CPU的映射。

由于上述操作只是最大化减少占用内存区域,reg并没有真正释放,而且也不打算在此次操作中释放,所以在kbase_jit_free中并没有真正释放reg。

kbase_jit_free会把reg放入jit_pool_head,更有意思的是,reg还被移到了kbase_context内核对象的evict_list中。这是一个关键利用点。

kbase_jit_free完成后,它的调用者,即kbase_jit_free_finish,会清理reg在jit_alloc中的引用,即便reg仍旧有效。

被放入jit_pool_head的这个reg仍有机会被重用。

当内存告急时,evict_list内的reg会被释放。也就是说,evict_list内的reg有两种利用方式:一是等待内存告急时被释放,二是被Mali驱动重用。

Linux内核提供了一个机制来回收未使用的缓存内存,称为shrinker(内存收缩器)。shrinker会在内存告急时被调用,释放一些内存。

Mali驱动的shrinker通过kbase_mem_evictable_init方法来注册,这个shrinker有两个关键方法:count_objects(统计可以释放的reg数量)和 scan_objects(释放reg)。

其中,关键部分在于kbase_mem_evictable_reclaim_scan_objects,也就是负责释放reg的函数。它把jit_pool_head的reg移除,返回给内核。具体操作上,它遍历evict_list,解除GPU映射(还记得kbase_jit_free中说的移除所有来自CPU的映射吗?),然后调用kbase_jit_backing_lost。

kbase_jit_backing_lost会把reg移出jit_pool_head,然后移到jit_destroy_head。

紧接着,kbase_jit_destroy_worker会把jit_destroy_head中的kbase_va_region释放掉(驱动利用 kbase_va_region 表示一组物理内存,这组物理内存可以被 CPU 上的用户进程和 GPU 分别映射,映射的权限由 reg->flags 字段控制),并移除所有指向这个kbase_va_region的引用(除了一个小指针)。

kbase_mem_evictable_reclaim_scan_objects并不负责移除jit_alloc(即kbase_context内核对象的jit_alloc数组)中的引用,但问题不大,因为这个引用在kbase_jit_free_finish中会被清理(也就是在kbase_jit_free的调用者中,前面其实说过了)。

2. Introduction to CVE-2022-38181

evictable memory(即evict_list中的reg)是比较普遍的概念。其他类型的GPU内存也可以加入到evict_list中。这个过程可以通过调用kbase_mem_evictable_make来把一个reg加入到evict_list中,反之可以通过调用kbase_mem_evictable_unmake来把一个reg从evict_list中移除。用户层面上可以通过一个名为KBASE_IOCTL_MEM_FLAGS_CHANGE的ioctl来调用这两个函数。取决于KBASE_REG_DONT_NEED这个flag是否被传递,reg会被加入到或者移除出evict_list。

通过把一个reg加入到evict_list中,用户可以在内存告急时释放这个reg,也就是通过制造内存压力来触发kbase_mem_evictable_reclaim_scan_objects,从而释放这个reg,但有一个指针仍会被储存在jit_alloc中(因为这个过程跳过了kbase_jit_free_finish,指针没有被清理)。

接下来使用BASE_JD_REQ_SOFT_JIT_FREE调用kbase_jit_free_finish来使用这个被释放了的但仍在jit_alloc中有指针的reg。C代码如下:

C
static void kbase_jit_free_finish(struct kbase_jd_atom *katom)
{
    ...
    for (j = 0; j != katom->nr_extres; ++j) {
        if ((ids[j] != 0) && (kctx->jit_alloc[ids[j]] != NULL)) {
            ...
            if (kctx->jit_alloc[ids[j]] != KBASE_RESERVED_REG_JIT_ALLOC) {
                ...
                kbase_jit_free(kctx, kctx->jit_alloc[ids[j]]);  //<----- Use of the now freed jit_alloc[ids[j]]
            }
            kctx->jit_alloc[ids[j]] = NULL;
        }
    }
    ...
}

void kbase_jit_free(struct kbase_context *kctx, struct kbase_va_region *reg)
{
    ...
    old_pages = kbase_reg_current_backed_size(reg);
    if (reg->initial_commit < old_pages) {
        ...
        u64 delta = old_pages - new_size;
        if (delta) {
            mutex_lock(&kctx->reg_lock);
            kbase_mem_shrink(kctx, reg, old_pages - delta);  //<----- Free some pages in the region
            mutex_unlock(&kctx->reg_lock);
        }
    }
    ...
}

在kbase_jit_free_finish中,kbase_jit_free被调用,但是在kbase_jit_free中,reg并没有真正释放,而是只是释放了一部分内存页。这样,jit_alloc[ids[j]]指向的reg仍然有效,但是内存页已经被释放。这就是一个UAF漏洞。

3. Exploitation of CVE-2022-38181

如果让内核处于内存告急状态,内核就会通过shrinker机制调用kbase_mem_evictable_reclaim_scan_objects。在用户层面,可以通过mmap来映射一个大内存区域,但是并不能确定用多大的内存可以触发shrinker机制。直接用过大的内存区域可能会导致OOM,还会影响后续利用,因此需要一个更好的方法。

我们可以一点点地增加分配内存空间,然后检查JIT region是否被kbase_mem_evictable_reclaim_scan_objects释放,知道确定bug已经被触发。

Mali驱动提供了一个名为KBASE_IOCTL_MEM_QUERY的ioctl,可以查询特定地址的内存状态。如果目标地址无效,这个ioctl会返回error。这个ioctl可以用来检查reg是否被kbase_mem_evictable_reclaim_scan_objects释放。(因为reg被释放后,GPU的映射会被移除。)

shrinker实质上是在申请内存的用户进程中被调用的。绝大多数情况下,用户进程和释放内存的进程在相同的CPU上运行。而且这个内存空间kbase_va_region是一个在kmalloc-256 cache内存池中分配的内存区域,这类cache相对大,而且少用。

Replacing the freed object

KBASE_IOCTL_MEM_ALIAS这个ioctl可以允许多个reg共享同一组pages。这个ioctl可以创建一个同时被GPU内的reg和用户进程的内存映射的kbase_va_region。这样,释放的时候只有reg的内存映射被移除,而alias region的内存映射仍然存在,可以用来访问释放掉的backing pages。

为了防止kbase_mem_shrink被调用在alias JIT memory上,kbase_va_region会检查KBASE_REG_NO_USER_FREE这个flag,这样JIT memory就不会被aliased。

使用KBASE_IOCTL_MEM_ALLOC来创建一个不包含KBASE_REG_NO_USER_FREE flag的reg,然后使用KBASE_IOCTL_MEM_ALIAS来创建针对这个reg的内存映射,此时的reg还是valid的,但此时一个位于jit_alloc中的指向这个已创建映射的reg的指针仍然存在。

如果此时触发了shrinker机制,kbase_mem_evictable_reclaim_scan_objects会释放这个reg的一部分内存页,但在alias region中的内存映射仍然存在,可以用来访问这些内存页。

现在,我们已经拥有了访问已经被释放的kbase_va_region对象中的内存页的能力,我们可以重用这些内存页来获取对任意内存的读写能力。

为了理解这一点是怎么做到的,我们需要了解一下kbase_va_region的内存页是怎么分配的。

kbase_va_region的内存页是通过kbase_mem_pool_alloc_pages来分配的。这个函数先从当前的kbase_mem_pool中分配内存页,如果没有足够的内存页,就会到pool->next_pool中寻找。如果还是没有足够的内存页,就会调用kbase_mem_alloc_page来从内核中分配内存页。释放内存页的顺序也是一样的,先返回到当前的kbase_mem_pool,然后返回到pool->next_pool,最后返回到内核。

而pool->next_pool是一个被Mali驱动维护的、被所有kbase_va_region共享的内存池。这个内存池还被用来分为page table global directories(PGD,即页表的顶层目录)。也就是说,我们有机会把被释放的内存页分配给一个PGD,然后通过这个PGD来映射到一个任意的内存地址。

Summary

  1. 创建一个JIT memory(通过KBASE_IOCTL_MEM_ALLOC)
  2. 把这个JIT memory标记为evictable(通过设置KBASE_REG_DONT_NEED flag)
  3. 增加内存压力(通过mmap),把JIT memory释放掉,回到jit_pool_head,此时jit_alloc中多了一个指向这个JIT memory的指针
  4. 检查JIT memory是否被释放(通过KBASE_IOCTL_MEM_QUERY),没释放就重复步骤3
  5. 用KBASE_IOCTL_MEM_ALLOC创建新的GPU memory regions来替换被释放的JIT memory,即把JIT memory重新从jit_pool_head中取出来(通过KBASE_IOCTL_MEM_ALLOC来堆喷)
  6. 用KBASE_IOCTL_MEM_ALIAS创建一个别名alias region来访问新创建的GPU memory regions,这样新创建的GPU memory的内存页就可以用alias region来访问
  7. 递交一个BASE_JD_REQ_SOFT_JIT_FREE请求来释放JIT region,因为此时JIT region已经被释放,就会把新创建的GPU memory regions的内存页释放掉,但是alias region的GPU内存映射仍然存在(在步骤6中创建的),因此这个alias region可以用来访问这些内存页
  8. 重新使用这些内存页来作为PGD,以此重写PGD,就可以把任意物理内存页映射到GPU地址空间。
  9. 把内核代码映射到GPU地址空间,然后执行内核代码,重写进程的credentials(即提权),然后把SELinux关闭

Appendix: How to reuse the freed pages as PGD

通过KBASE_IOCTL_MEM_ALLOC来从kbase_context分配内存页,这个内存页会被分配到一个kbase_va_region中,这些内存页是从mem_pools中分配的。

因为kbase_va_region的内存页申请顺序是先从当前的mem_pool中分配,然后从pool->next_pool中分配,最后从内核中分配。

进程在刚创建好的时候,其mem_pool是空的,此时申请空间只能先从next_pool中开始找;如果此时next_pool的内存页被释放,也会优先分配到mem_pool中(因为mem_pool是空的)。这里的next_pool是一个全局的内存池,被所有kbase_va_region共享,也就是device memory pool。

利用流程如下:

  1. 在当前处于空的context memory pool(即mem_pool)的情况下,申请一个内存页,这个内存页会从device memory pool(即next_pool)中分配过来(如果满了就会从内核中分配)
  2. 申请16384个内存页,这样就会把device memory pool中的内存页分配完(因为对于Pixel 6而言,这里的device memory pool最多只有16384个内存页)
  3. 经过步骤2,context memory pool和device memory pool都是空的,此时释放掉这16384个内存页,这样这些内存页就会被分配到context memory pool中,把context memory pool填满,而device memory pool是空的
  4. 最后,释放掉第一步中申请的内存页,这样这些内存页就会被分配到device memory pool中,最后从device memory pool中就只有一个最一开始申请的内存页了

这意味着,当PGD需要内存页的时候,就会从device memory pool中分配,而这个device memory pool中只有一个内存页,这样就可以把这个内存页分配给PGD,然后通过PGD来映射到任意的物理内存页。

那么如何让PGD需要内存页呢?已知GPU页表是lazy allocated的,而且每个PGD包含512个PTE,所以只要我们映射512个物理内存页,就一定会触发PGD申请内存页的操作。

因此,就可以把这个内存页分配给PGD,然后通过PGD来映射到任意的物理内存页,从而实现任意物理内存的读写。