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_region的 start_pfn中。
上面的流程意味着连续映射区域的相对地址是可预测的,例如连续分配的内存区域很可能是连续的。
1.5 Mapping Pages to GPU¶
kbase_context有自己的GPU地址空间和管理着自己的GPU页表(四层页表)。这四层页表的最顶层称为PGD(Page Table Global Directory),大小为512个元素,储存在 mmut的 pgd域中,最底层存着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溢出,,从而让别名区域小于其内存页数量。
假设申请和分配三个内存区域,每个区域具有三个内存页,分别命名为 region1、region2和 region3。
假设 region1的起始地址为 0x12000,那么 region2的起始地址为 0x9000,region3的起始地址为 0x6000,地址向高位增长。示意图如下:
| Text Only | |
|---|---|
此时,传入的 stride为 2 ** 63 + 1,nents为 2,stride * nents会溢出,变为 2。
在这种情况下,别名区域的大小会变成2 pages,从高位向低位寻找空闲的空间时会发现 0x6000-0x4000可以放下两页内存,因此别名区域的起始地址会变成 0x4000,并向上增长,由于这个别名区域的内存页是 region1的内存页,实际上被映射的内存页有三页,示意图如下:
| Text Only | |
|---|---|
在这个例子中,0x7000-0x6000这段地址同时被 region3和 region1映射,而 0x15000-0x14000这段地址是同时被 region1和 alias 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 | |
|---|---|
对于Pixel 6来说,这个值是 16384。
利用流程如下:
- 在当前处于空的
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来映射到任意的物理内存页,从而实现任意物理内存的读写。
总结一下步骤:
- 申请和分配三个内存区域,每个区域具有三个内存页,分别命名为
region1、region2和region3,然后用stride为2 ** 63 + 1,nents为2的方式来创建一个别名区域(分别用region1和region2来支撑) - 申请16384个内存页,这样就会把
device memory pool中的内存页分配完 - 释放掉这16384个内存页,这样这些内存页就会被分配到
context memory pool中,把context memory pool填满,而device memory pool是空的 - 解除
region1和alias region的映射,这一步释放了3个内存页到device memory pool中 - 申请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模式下运行。