本文深入分析了 Mali GPU 中的两个内核漏洞,这些漏洞可从默认应用沙箱中触达,由我独立发现并报告给 Google。文中包含一个可实现任意内核读写(r/w)能力的内核利用程序。该利用程序可禁用 SELinux,并在运行以下 Android 14 版本的 Google Pixel 7 和 8 Pro 机型上提权至 root:
google/husky/husky:14/UD1A.231105.004/11010374:user/release-keysgoogle/cheetah/cheetah:14/UP1A.231105.003/11010452:user/release-keysgoogle/cheetah/cheetah:14/UP1A.231005.007/10754064:user/release-keysgoogle/panther/panther:14/UP1A.231105.003/11010452:user/release-keys(由 m4b4 (Marcel) 提供)该利用程序利用了以下两个漏洞:gpu_pixel_handle_buffer_liveness_update_ioctl ioctl 命令中因不完整补丁导致的整数溢出,以及时间线流(timeline stream)消息缓冲区中的信息泄露。
Google 已在此提交中修复了 gpu_pixel_handle_buffer_liveness_update_ioctl ioctl 命令中的一个整数溢出问题。起初,当我报告此问题时,我以为该漏洞是由之前所述补丁中的问题引起的。在审查报告后,我意识到我对该漏洞的分析并不准确。尽管我最初假设补丁不完整,但它确实有效解决了并防止了计算中的下溢。这让我怀疑该更改并未应用到生产构建中。然而,尽管我可以触发计算中的下溢,却无法引发溢出。这表明该 ioctl 命令已被部分修复,尽管不是通过上述补丁实现的。查看 IDA 后发现,生产版本中搭载了另一个不完整的补丁,而该补丁不存在于 mali GPU 内核模块的任何 git 分支中。
该漏洞最早在最新 Android 版本中被发现,并于 2023 年 11 月 19 日报告。Google 后来告知我,他们已在内部识别出该漏洞,并在 12 月 Android 安全公告中将其分配为 CVE-2023-48409,将其标记为重复问题。 尽管我能够确认该漏洞在我报告前数月就已在内部被识别(基于约 8 月 30 日的提交日期),但仍存在困惑。具体来说,最新设备在 10 月和 11 月的安全补丁级别(SPL)仍然受此漏洞影响,这很奇怪——我尚未调查这些版本之前的版本。因此,我无法最终确定这是否确实是重复问题,也无法确定相关补丁是否确实在我提交之前就已计划在 12 月发布,还是在解决此漏洞时存在疏忽。
无论如何,这个漏洞之所以强大,原因如下:
info.live_ranges 完全由用户控制。info.live_ranges 指针位于 buff 内核地址起始之前的任意偏移处。此漏洞与我于 2022 年在 iOS 15 内核中发现并利用的 DeCxt::RasterizeScaleBiasData() 缓冲区下溢漏洞 有相似之处。
Mali GPU 实现了一个自定义的 timeline stream,用于收集信息、序列化,然后按照特定格式将其写入环形缓冲区。用户可以调用 kbase_api_tlstream_acquire ioctl 命令获取文件描述符,从而读取该环形缓冲区。消息格式如下:
一个数据包头
一个消息 ID
一个序列化的消息缓冲区,其具体内容取决于消息 ID。
例如,__kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait 函数会将 kbase_kcpu_command_queue 和 dma_fence 内核指针序列化到消息缓冲区中,从而导致内核指针泄露给用户空间进程。```c
void __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait(
struct kbase_tlstream *stream,
const void *kcpu_queue,
const void *fence
)
{
const u32 msg_id = KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT;
const size_t msg_size = sizeof(msg_id) + sizeof(u64)
+ sizeof(kcpu_queue)
+ sizeof(fence)
;
char *buffer;
unsigned long acq_flags;
size_t pos = 0;
buffer = kbase_tlstream_msgbuf_acquire(stream, msg_size, &acq_flags);
pos = kbasep_serialize_bytes(buffer, pos, &msg_id, sizeof(msg_id)); pos = kbasep_serialize_timestamp(buffer, pos); pos = kbasep_serialize_bytes(buffer, pos, &kcpu_queue, sizeof(kcpu_queue)); pos = kbasep_serialize_bytes(buffer, pos, &fence, sizeof(fence));
kbase_tlstream_msgbuf_release(stream, acq_flags); }
该概念验证漏洞利用通过监视消息 ID `KBASE_TL_KBASE_NEW_KCPUQUEUE` 来泄露 `kbase_kcpu_command_queue` 对象地址;该消息由 `kbasep_kcpu_queue_new` 函数在新分配 kcpu 队列对象时发出。
谷歌告知我,该漏洞于 2023 年 3 月被报告,并已在其安全公告中被分配为 [CVE-2023-26083](https://source.android.com/docs/security/bulletin/2023-07-01)。尽管如此,我仍然能够在搭载 10 月和 11 月安全补丁级别(SPL)的最新 Pixel 设备上复现该问题,这表明修复措施未正确应用或根本没有应用。随后,谷歌在 12 月安全更新公告中迅速解决了该问题,但未提供致谢,并后来告知我该问题被视为重复报告。然而,将此问题标记为重复的理由仍然值得怀疑。
## 漏洞利用
---
因此,我有两个有趣的漏洞。第一个漏洞提供了强大的能力,可以修改位于已分配 ~buff~ 地址之前的任意 16 字节对齐内核地址的内容。第二个漏洞则提供了有关内核内存中对象潜在位置的提示。
### 关于 buffer_count 和 live_ranges_count 值的说明
由于可以完全控制 `buffer_count` 和 `live_ranges_count` 字段,我能够灵活地选择目标 slab 以及想要写入的精确偏移。然而,由于多种约束和因素,选择 `buffer_count` 和 `live_ranges_count` 的值需要仔细考虑:
- 这两个值是相互关联的,只有绕过所有新引入的检查后,溢出才会发生。
- 负偏移必须为 16 字节对齐这一要求,限制了向任意选定位置写入的能力。不过,这通常不会构成重大障碍。
- 选择较大的偏移会导致大量数据被写入可能并非预期目标的系统内存区域。例如,如果分配大小溢出到 `0x3004`,`live_ranges` 指针将相对于 `buff` 对象分配的空间设置为 `-0x4000` 字节。然后,`copy_from_user` 函数将根据 `update->live_ranges_count` 乘以 4 的计算结果写入 `0x7004` 字节。因此,该操作会导致用户控制的数据覆盖 `live_ranges` 指针与 `buff` 分配之间的内存区域。因此,务必仔细确保该范围内的关键系统对象不会被意外覆盖。考虑到该操作涉及 `copy_from_user` 调用,人们可能会考虑通过故意取消映射用户源缓冲区之后不需要的内存区域来触发 `EFAULT`,以防止数据写入敏感位置。然而,这种方法无效,因为如果 `raw_copy_from_user` 函数失败,它将把目标内核缓冲区中的剩余字节清零。实现此行为的目的是确保在因错误而部分复制的情况下,内核缓冲区的其余部分不会包含未初始化的数据。```c
static inline __must_check unsigned long
_copy_from_user(void *to, const void __user *from, unsigned long n)
{
unsigned long res = n;
might_fault();
if (!should_fail_usercopy() && likely(access_ok(from, n))) {
instrument_copy_from_user(to, from, n);
res = raw_copy_from_user(to, from, n);
}
if (unlikely(res))
memset(to + (n - res), 0, res);
return res;
}
考虑到这一点,我们需要仔细选择要覆写的对象以及要写入的数据。
由于我被这个不幸的检查卡住了,我的策略是找到一个对象,如果将其清零,不会产生任何不良后果。但是,在解决这个问题之前,还有另一个问题需要处理。还记得上一部分我曾说我可以选择任意分配大小,从而选择任意通用 slab 缓存分配器来为我的分配缓冲区提供服务吗?这并不正确,因为原因又出在 copy_from_user 上!这是由 CONFIG_HARDENED_USERCOPY 缓解机制导致的。它禁止指定一个与目标缓冲区(此处为堆对象)所对应的 slab 缓存大小不匹配的大小。它会判断缓冲区的页面是否为 slab 页;如果是,则获取匹配的 kmem_cache->size,并判断用户提供的大小是否不会超过该值;否则,内核就会因大小不匹配而直接崩溃。所以,换句话说,我无法攻击属于通用分配器的对象,但我仍然可以攻击具有较大尺寸的对象(即由页分配器直接服务的对象)。
我首先想到的是使用 pipe_buffer 技术,这是一种非常优雅的获取任意读写原语的技术。我不会深入讨论该技术,但建议读者阅读 Interrupt Labs 的这篇精彩博客。在构造管道对象时,pipe_buffer 对象最初创建为一个包含 16 个元素的数组;但是,可以使用 fcntl(F_SETPIPE_SZ) 调整数组大小。因此,pipe_buffer 数组的分配可以被调整,使其能够由页分配器提供服务,从而成为完美的攻击目标对象。
在选择 pipe_buffer 对象作为候选目标后,实现内核 r/w 的下一步是利用下溢漏洞覆写其内容,这将允许我读写任意内存位置,只要将这些位置的页覆写到 pipe_buffer->page 字段。
由于该漏洞允许我写入任意数据,我可以控制整个 pipe_buffer 的内容,包括其 page 字段。要做到这一点,我需要在易受攻击的 kbuff 对象之前分配 pipe_buffer 数组,并且它们必须彼此相邻。
我向内核内存喷射了大量 kbase_kcpu_command_queue 对象,随后喷射了一堆 pipe_buffer 数组。
由于 pipe_max_size 的限制,我不能仅靠 pipe_buffer 数组作为主要的喷射源。因此,我决定先用 kbase_kcpu_command_queue 对象进行喷射。选择 kbase_kcpu_command_queue 对象有两个原因:其分配大小为 0x38C8,因此由页分配器处理;而且我可以利用内核信息泄漏漏洞确定性地获得其内核地址,使其既适合作为喷射对象,也适合作为攻击目标(我们将在下一节看到)。
如前所述,我使用 fcntl(F_SETPIPE_SZ) 增大了 pipe_buffer 数组的分配大小,以便由页分配器提供服务。更具体地说,我选择将分配大小设为一个 ==0x4000 字节 (4 * PAGE_SIZE)==,以便与 kbase_kcpu_command_queue 的分配保持一致。
为了正确使用 pipe_buffer,需要页地址。能够识别一个我可以随意创建和销毁的 kbase_kcpu_command_queue 对象的内核地址,使其成为一个很好的候选对象,而要找到与其匹配的 struct page,可以通过使用 virt_to_page 来实现。
因此,pipe_buffer 对象如下:```c
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
如前所述,`page` 字段必须包含一个有效的页地址。`offset` 和 `len` 字段不能超过 `PAGE_SIZE`,否则管道会增加 head/tail 计数器,从而导致使用新的 `pipe_buffer` 对象,并失去对伪造管道缓冲区的控制。
此外,`flags` 必须为 `PIPE_BUF_FLAG_CAN_MERGE`,这样后续的 `pipe_write` 调用不会盲目地递增 head 计数器并使用下一个管道缓冲区,而是首先检查当前 `pipe_buffer` 中是否有足够的空间来容纳写入请求,如果有,它就会从 `len` 字段存储的值开始,将数据简单地追加到同一个管道缓冲区中。
为了避免在 `pipe_buf_confirm` 处使设备崩溃(`pipe_buf_confirm` 由 `pipe_write` 和 `pipe_read` 调用),`ops` 指针也必须是一个有效的内核地址,并且 `ops->confirm` 字段设置为 _NULL_。我可以直接使用已泄露的 `kbase_kcpu_command_queue` 对象中某个为 NULL 且在任何情况下都不会改变的偏移量。
### 为下溢选择最佳偏移值
虽然 `buff`、`kbase_kcpu_command_queue` 和 `pipe_buffer` 的分配大小约为 ~0x4000~ 字节,但我选择用 **0x8000** 字节对缓冲区进行下溢。为什么?