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模式下运行。