Skip to content

A note of CVE-2022-20186 write-up

原文链接: Corrupting memory without memory corruption

1. Mali GPU

1.1 Why GPU?

GPU可以在不受信任的app中被访问;GPU驱动漏洞的受影响面非常广泛;GPU驱动通常包含大量的复杂的内存相关代码。

1.2 the Arm Mali GPU

Mali GPU驱动包含两个不同部分:内核驱动(开源)和专有用户空间驱动。本文只考虑内核驱动。

为了使用Mali 驱动,需要调用一系列的ioctl,来创建 kbase_context对象,这个对象定义了一个给用户空间应用和GPU交互的执行环境。每一个和GPU互动的设备文件都有一个独立的 kbase_context对象。这个对象定义了它自己的GPU地址空间,和管理用户空间和GPU内存共享。

1.3 Memory Management

用户从 kbase_context对象中分配内存(通过 KBASE_IOCTL_MEM_ALLOC)。这些内存是从 kbase_context对象中独属于这个上下文的内存池中分配的(mem_pools),而且没有立刻被映射到GPU和用户空间。这个ioctl返回一个cookie,用于作为offset来mmap设备文件并且映射这些内存到GPU和用户空间。当这个内存被通过munmap释放时,这个内存会被回收到内存池中(mem_pools)。

KBASE_IOCTL_MEM_ALLOC的内部实现是通过 kbase_mem_alloc来完成的。这个函数创建了一个 kbase_va_region对象来保存有关这个内存空间的数据。这个函数也通过调用 kbase_alloc_phy_pages来从 kbase_context对象的内存池中分配物理内存页。

当从64位进程中调用,被创建的内存区域储存于 kbase_context对象的 pending_regions中,而并没有立刻映射。与这个内存区域相关的cookie会被返回给用户空间,用户空间可以通过cookie来映射这个内存区域。

1.4 Mapping Memory to User Space

mmap被调用,kbase_context_get_unmapped_area函数会被调用来找到一个空闲内存区域来映射。这个函数不允许映射区域到带有 MAP_FIXED标志的地址。这个函数使用 kbase_unmapped_area_topdown来找到一个合适的区域来映射,因此会返回一个尽可能高而且可用的地址,并存到 kbase_va_regionstart_pfn中。

上面的流程意味着连续映射区域的相对地址是可预测的,例如连续分配的内存区域很可能是连续的。

1.5 Mapping Pages to GPU

kbase_context有自己的GPU地址空间和管理着自己的GPU页表(四层页表)。这四层页表的最顶层称为PGD(Page Table Global Directory),大小为512个元素,储存在 mmutpgd域中,最底层存着PTE(Page Table Entry)。

PGD和PTE都是在被需要时才创建。

访问对应不可用entry的PGD会使对应页的地址被添加到前一层的PGD中。页帧的申请是在所有上下文共享的全局内存池中被分配的(mem_pools)。

映射内存到GPU时,kbase_gpu_mmap调用 kbase_mmu_insert_pages来把内存页加到GPU页表中,放进 reg->start_pfn中,这也是用户空间的映射地址。

1.6 Memory Alias

KBASE_IOCTL_MEM_ALIAS允许多个内存区域使用同一组内存页。这个ioctl在 kbase_mem_alias中实现,它接收了一个 stride参数和一个 base_mem_aliasing_info数组来指定支撑这个别名区域的内存区域。

stride指定两个别名区域之间的间隔(开头到开头的距离),nents指定内存区域的数量,stride * nents是这个区域的内存页数量,但这不意味着所有区域都有映射。两个别名区域之间可能存在一些没有被映射的内存页(gap)。

2. The Vulnerability

虽然 nents * stride是有限制的,但没有整数溢出检查。这意味着可以通过传递一个很大的 stride来使 nents * stride溢出,,从而让别名区域小于其内存页数量。

假设申请和分配三个内存区域,每个区域具有三个内存页,分别命名为 region1region2region3

假设 region1的起始地址为 0x12000,那么 region2的起始地址为 0x9000region3的起始地址为 0x6000,地址向高位增长。示意图如下:

Text Only
1
2
3
| region1 | 0x15000-0x12000 |
| region2 | 0x12000-0x9000  |
| region3 | 0x9000-0x6000   |

此时,传入的 stride2 ** 63 + 1nents2stride * nents会溢出,变为 2

在这种情况下,别名区域的大小会变成2 pages,从高位向低位寻找空闲的空间时会发现 0x6000-0x4000可以放下两页内存,因此别名区域的起始地址会变成 0x4000,并向上增长,由于这个别名区域的内存页是 region1的内存页,实际上被映射的内存页有三页,示意图如下:

Text Only
1
2
3
4
5
| region1 | 0x15000-0x12000 |
| region2 | 0x12000-0x9000  |
| region3 | 0x9000-0x7000   |
| region3 | 0x7000-0x6000   |   | alias | 0x7000-0x6000 | <-backed by region1
                                | alias | 0x6000-0x4000 | <-backed by region1

在这个例子中,0x7000-0x6000这段地址同时被 region3region1映射,而 0x15000-0x14000这段地址是同时被 region1alias region所拥有,如果释放这个内存页,那么 0x7000-0x6000这段地址的内存页就会变成一个被释放的内存页,意味着GPU仍然可以访问这个内存页(通过 region3中没有释放的那个内存页)。

特别注意的是,被释放的内存页会被放回到 mem_pools中(该上下文的内存池)。

3. Breaking out of the Context

申请内存时先从 kbase_mem_pool中分配(即 mem_pools),如果没有足够的内存页,就会从 pool->next_pool中寻找(即 next_pool,全局内存)。如果 next_pool也没有足够的内存页,就会调用 kbase_mem_alloc_page来从内核中分配内存页。释放时也同理。

只要我们让释放内存页时 mem_pools处于满的状态,那么释放的内存页就会被放回到 next_pool中。如果刚好 next_pool中只有一个内存页,PGD需要一个新的内存页的时候,就只能拿到这个内存页,从而可以通过PGD来映射到任意的物理内存页。

内存池的大小可以通过以下指令获取:

Bash
cat /sys/module/mali_kbase/drivers/platform\:mali/1c500000.mali/mempool/max_size

对于Pixel 6来说,这个值是 16384

利用流程如下:

  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 pooldevice 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来映射到任意的物理内存页,从而实现任意物理内存的读写。

总结一下步骤:

  1. 申请和分配三个内存区域,每个区域具有三个内存页,分别命名为 region1region2region3,然后用 stride2 ** 63 + 1nents2的方式来创建一个别名区域(分别用 region1region2来支撑)
  2. 申请16384个内存页,这样就会把 device memory pool中的内存页分配完
  3. 释放掉这16384个内存页,这样这些内存页就会被分配到 context memory pool中,把 context memory pool填满,而 device memory pool是空的
  4. 解除 region1和alias region的映射,这一步释放了3个内存页到 device memory pool
  5. 申请512 * 3个内存页,保证能够创建3个PGD内存页,其中的一个内存页可以通过 region3的GPU地址来访问

4. Writing to GPU Memory

可以编译一个shader程序,让它跑在GPU上,但这其实没必要。

可以使用 KBASE_IOCTL_JOB_SUBMIT来传递一系列任务给GPU。

5. Conclusion

graph TD
    A[触发漏洞] --> B[创建三个内存区域region1/2/3]
    B --> C[创建alias region stride=2^63+1, nents=2]
    C --> D[整数溢出导致alias region大小异常]
    D --> E[解除映射region1和alias region]
    E --> F[region3仍保留被释放的物理页]

    F --> G[分配16384页耗尽设备内存池]
    G --> H[释放页面到全局内存池]
    H --> I[物理页被重新分配为GPU页表项]

    I --> J[映射512页触发新PGD分配]
    J --> K[被释放的物理页成为PGD]
    K --> L[通过region3的GPU地址修改PGD]

    L --> M[建立任意物理内存映射]
    M --> N[使用MALI_JOB_TYPE_WRITE_VALUE任务]
    N --> O[覆写内核内存实现提权]
    N --> P[禁用SELinux和修改凭证]

    style A fill:#ffcccc,stroke:#ff0000
    style K fill:#ccffcc,stroke:#00ff00
    style N fill:#ccccff,stroke:#0000ff

Appendix: Bypassing SELinux

启用SELinux后,它可以在permissive模式下运行(即不会阻止任何操作,但会记录下来),也可以在enforcing模式下运行(即会阻止不安全的操作)。模式的切换是通过 selinux_enforcing这个变量来控制的。当这个变量是0,SElinux在permissive模式下运行。