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代码如下:
在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¶
- 创建一个JIT memory(通过KBASE_IOCTL_MEM_ALLOC)
- 把这个JIT memory标记为evictable(通过设置KBASE_REG_DONT_NEED flag)
- 增加内存压力(通过mmap),把JIT memory释放掉,回到jit_pool_head,此时jit_alloc中多了一个指向这个JIT memory的指针
- 检查JIT memory是否被释放(通过KBASE_IOCTL_MEM_QUERY),没释放就重复步骤3
- 用KBASE_IOCTL_MEM_ALLOC创建新的GPU memory regions来替换被释放的JIT memory,即把JIT memory重新从jit_pool_head中取出来(通过KBASE_IOCTL_MEM_ALLOC来堆喷)
- 用KBASE_IOCTL_MEM_ALIAS创建一个别名alias region来访问新创建的GPU memory regions,这样新创建的GPU memory的内存页就可以用alias region来访问
- 递交一个BASE_JD_REQ_SOFT_JIT_FREE请求来释放JIT region,因为此时JIT region已经被释放,就会把新创建的GPU memory regions的内存页释放掉,但是alias region的GPU内存映射仍然存在(在步骤6中创建的),因此这个alias region可以用来访问这些内存页
- 重新使用这些内存页来作为PGD,以此重写PGD,就可以把任意物理内存页映射到GPU地址空间。
- 把内核代码映射到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。
利用流程如下:
- 在当前处于空的context memory pool(即mem_pool)的情况下,申请一个内存页,这个内存页会从device memory pool(即next_pool)中分配过来(如果满了就会从内核中分配)
- 申请16384个内存页,这样就会把device memory pool中的内存页分配完(因为对于Pixel 6而言,这里的device memory pool最多只有16384个内存页)
- 经过步骤2,context memory pool和device memory pool都是空的,此时释放掉这16384个内存页,这样这些内存页就会被分配到context memory pool中,把context memory pool填满,而device memory pool是空的
- 最后,释放掉第一步中申请的内存页,这样这些内存页就会被分配到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来映射到任意的物理内存页,从而实现任意物理内存的读写。