Mali GPU 内核 LPE
本文提供了对 Mali GPU 中两个内核漏洞的深入分析,这些漏洞可以从默认应用程序沙箱中访问,并且是我独立发现并向 Google 报告的。它包括一个内核漏洞利用,实现了任意内核读写能力。因此,它禁用了 SELinux 并在运行以下 Android 14 版本的 Google Pixel 7 和 8 Pro 型号上提升了 root 权限:
-
Pixel 8 Pro: google/husky/husky:14/UD1A.231105.004/11010374:user/release-keys
-
Pixel 7 Pro: google/cheetah/cheetah:14/UP1A.231105.003/11010452:user/release-keys
-
Pixel 7 Pro: google/cheetah/cheetah:14/UP1A.231005.007/10754064:user/release-keys
-
Pixel 7: google/panther/panther:14/UP1A.231105.003/11010452:user/release-keys
(由 m4b4 (Marcel))
漏洞
这个漏洞利用了两个漏洞:一个由于 gpu_pixel_handle_buffer_liveness_update_ioctl
ioctl 命令中的补丁不完整而导致的整数溢出,以及时间线流消息缓冲区中的信息泄露。
由于不正确的整数溢出修复导致 gpu_pixel_handle_buffer_liveness_update_ioctl() 中的缓冲区下溢
Google 在 这个提交 中解决了 gpu_pixel_handle_buffer_liveness_update_ioctl
ioctl 命令中的整数溢出问题。起初,当我报告这个问题时,我认为错误是由前面描述的补丁引起的。在审查报告后,我意识到我对漏洞的分析是不准确的。尽管我最初假设补丁不完整,但它有效地解决了并防止了计算中的下溢。这让我怀疑更改没有应用在生产构建中。然而,尽管我可以造成计算中的下溢,但不可能造成溢出。这表明 ioctl 命令已经被部分修复,尽管不是上面显示的补丁。通过 IDA 查看发现,另一个不完整的补丁被运送在生产发布中,这个补丁在任何 git 分支的 mali gpu 内核模块中都不存在。
这个漏洞最初是在最新版本的 Android 中发现的,并在 2023 年 11 月 19 日报告。Google 后来告诉我,他们已经在内部识别了这个问题,并在 12 月的 Android 安全公告中分配了 CVE-2023-48409,将其标记为重复问题。
尽管我能够验证这个漏洞在我之前的报告几个月前就已经被内部识别(基于大约 8 月 30 日的提交日期),但仍存在混淆。具体来说,奇怪的是,最新的设备在 10 月和 11 月的安全补丁级别(SPL)仍然受到这个漏洞的影响 — 我没有调查过这些之前的版本。因此,我无法最终确定这是否真的是一个重复问题,以及适当的补丁是否真的计划在我之前提交的 12 月之前安排,或者在解决这个漏洞时是否存在疏忽。
无论如何,这个错误的强大之处在于以下几点:
-
缓冲区 info.live_ranges
完全由用户控制。 -
溢出值是用户控制的输入,因此,我们可以溢出计算,以便 info.live_ranges
指针可以在buff
内核地址开始之前的任意偏移处。 -
分配大小也是用户控制的输入,这提供了从任何通用用途的 slab 分配器请求内存分配的能力。
这个漏洞与我在 2022 年发现并利用的 iOS 15 内核中的 DeCxt::RasterizeScaleBiasData() 缓冲区下溢漏洞 有相似之处。
时间线流消息缓冲区中的内核指针泄露
GPU Mali 实现了一个自定义的 时间线流
,旨在收集信息,将其序列化,然后按照特定格式将其写入环形缓冲区。用户可以调用 kbase_api_tlstream_acquire
ioctl 命令来获取文件描述符,使他们能够从此环形缓冲区读取。消息的格式如下:
-
一个 数据包头 -
一个 消息 ID -
一个序列化的消息缓冲区,其中特定内容取决于消息 ID。例如, __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait
函数将kbase_kcpu_command_queue
和dma_fence
内核指针序列化到消息缓冲区中,导致将内核指针泄露到用户空间进程。
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
对象地址,该消息 ID 由 kbasep_kcpu_queue_new
函数在每次分配新的 kcpu 队列对象时发出。
Google 告诉我,这个漏洞是在 2023 年 3 月报告的,并在他们的安全公告中分配了 CVE-2023-26083。尽管如此,我仍然能够在带有 10 月和 11 月安全补丁级别(SPL)的最新 Pixel 设备上复制这个问题,表明修复没有正确应用或根本没有应用。随后,Google 在 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
函数失败,它将清零目标内核缓冲区中剩余的字节。这种行为是为了确保在由于错误导致部分复制的情况下,其余的内核缓冲区不包含未初始化的数据。
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
对象作为目标候选后,实现内核读写的下一步是用下溢漏洞覆盖其内容,这将允许我读写任何覆盖了 pipe_buffer->page
字段的页面的内存位置。因为漏洞允许我写入任意数据,我可以控制整个 ‘pipe_buffer
‘ 的内容,包括其页面字段,为此,我需要在易受攻击的 kbuff
对象之前分配 pipe_buffer
数组,并且它们必须是相邻的。
将 pipe_buffer 和 buff 对象放置在一起
我用许多 kbase_kcpu_command_queue
对象喷洒了内核内存,然后是一堆 pipe_buffer
数组。我不能只使用 pipe_buffer
数组作为喷洒的主要来源,因为 pipe_max_size
强加的限制。因此,我决定用 kbase_kcpu_command_queue
对象开始喷洒。选择 kbase_kcpu_command_queue
对象有两个原因:它的分配大小是 0x38C8
,因此由页面分配器处理,并且我可以确定性地使用信息内核泄露漏洞获取其内核地址,使其成为喷洒和目标(如下一节所示)的好对象。
正如之前提到的,我使用 fcntl(F_SETPIPE_SZ)
增加了 pipe_buffer
数组分配的大小,使其可以由页面分配器服务。更具体地说,我选择了分配大小为 ==0x4000 字节(4 * PAGE_SIZE)==,以与 kbase_kcpu_command_queue
分配一致。
获取 struct page 地址
为了正确使用 pipe_buffer
,需要一个页面地址。能够确定我可以故意创建和销毁的 kbase_kcpu_command_queue
对象的内核地址,使其成为一个很好的候选对象,并且找到其匹配的 struct page
可以通过使用 virt_to_page
实现。
pipe_buffer 的内容编写
因此,pipe_buffer
对象如下所示:
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
,否则管道将增加头/尾计数器,导致使用新的 pipe_buffer
对象并失去对伪造管道缓冲区的控制。此外,flags
必须是 PIPE_BUF_FLAG_CAN_MERGE
,这样接下来的 pipe_write
调用不会盲目增加头计数器并使用下一个管道缓冲区,而是首先检查当前 pipe_buffer
是否有空间适合写入请求,如果有,它将简单地将数据追加到同一个管道缓冲区,从 len
字段存储的值开始。
为了避免在 pipe_write
和 pipe_read
调用的 pipe_buf_confirm
中使设备崩溃,ops
指针也必须是具有 ops->confirm
字段设置为 NULL 的有效内核地址。我可以简单地使用在任何情况下都为 NULL 且不会更改的泄露 kbase_kcpu_command_queue
对象内的偏移量。
为下溢选择最佳偏移值
虽然 buff
、kbase_kcpu_command_queue
和 pipe_buffer
的分配大小约为 ~0x4000~ 字节,我选择用 0x8000 字节下溢缓冲区。为什么?
让我们简要看一下读写操作期间 pipe_buffers
如何更新。假设我们可以将 pipe_buffer
塑造成这样:
struct pipe_buffer {
.page = virt_to_page(addr),
.offset = 0,
.len = 0x40,
.ops = kcpu_addr + 0x50,
.flags = PIPE_BUF_FLAG_CAN_MERGE,
unsigned long private = 0
};
虽然漏洞提供了对这个对象内容的任意控制能力,但它只这样做 一次,因为在 ioctl
调用完成后,下溢的对象会立即被释放。这实际上是一个问题,因为我需要手动更新 pipe_buffer
对象以使其再次可用,因为每次管道读写操作:
-
.page
字段没有更新;它保持不变,当缓冲区为空时,它会被释放,这不符合我的需求,因为.ops
字段没有正确设置。 -
由于 pipe_buffer
在读操作期间更新了.offset
字段,因此,我不能再读取相同的内存区域。 -
写入 pipe_buffer
的数据将从.len
值开始追加到缓冲区(假设设置了PIPE_BUF_FLAG_CAN_MERGE
标志),并且.len
会相应更新。也就是说,我们不能两次写入确切的地址。
因此,除非我在每次读写操作后正确更新 pipe_buffer
,否则我不能同时从同一个管道进行读写。这就是为什么使用 0x8000
字节下溢更实用,因为不是覆盖单个 pipe_buffer
,我将覆盖两个不同的管道对象的两个不同的 pipe_buffer 实例:一个将被考虑用于读取,另一个用于写入操作。
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* 可以合并缓冲区 */
pipe_read = (struct pipe_buffer *)( ptr);
pipe_read->page = virt_to_page(ta->kcpu_kaddr);
pipe_read->offset = 0;
pipe_read->len = 0xfff;
pipe_read->ops = (const void *)(ta->kcpu_kaddr + 0x50);
pipe_read->flags = PIPE_BUF_FLAG_CAN_MERGE;
pipe_read->private = 0;
pipe_write = (struct pipe_buffer *)( ptr + 0x4000);
pipe_write->page = virt_to_page(ta->kcpu_kaddr);
pipe_write->offset = 0;
pipe_write->len = 0; /* 这是 pipe_write 的起始位置 */
pipe_write->ops = (const void *)(ta->kcpu_kaddr + 0x50);
pipe_write->flags = PIPE_BUF_FLAG_CAN_MERGE;
pipe_write->private = 0;
pipe_read
是一个伪造的管道缓冲区,将用于从目标页面开始在 .offset = 0
到 0xfff
字节处读取数据,而 pipe_write
是一个伪造的 pipe_buffer
,将用于从 .len = 0
到 0xfff
字节处写入数据。
同样非常重要的是再次提到,写入超过 PAGE_SIZE
字节将推动管道增加头部计数器,因此使用一个新分配的 pipe_buffer
并失去对我们伪造的 pipe_write
的控制。另一方面,清空(从 fake_read
缓冲区读取 0xfff 数据)告诉内核通过调用 ops→release
释放实际页面,导致内核崩溃,因为我仍然没有内核文本地址。
尽管我设法隔离了管道读写操作,使得在一个管道端执行写入不会干扰另一个管道缓冲区,反之亦然,但我仍然没有解决核心问题:如何可靠地更新管道缓冲区?显然的答案是,在每次管道读写调用后重复喷洒过程。这没有意义,因为这将对漏洞利用的可靠性产生重大影响。在接下来的部分中,我将把目标分成两个子目标:首先,我将专注于 .page
字段,然后是 .len/.offset
字段。
修改 pipe_buffer→page 字段
令我惊讶的是,我没有必要更新 .page
,因为我可以覆盖 pipe_buffer→page
指向泄露的 kbase_kcpu_command_queue
的页面地址。因此,我所要做的就是释放 kbase_kcpu_command_queue
对象,并用一个新的 pipe_buffer
对象与之重叠。是的!现在我有一个指向合法 pipe_buffer
对象的 pipe_buffer→page
!
用 pipe_buffer
替换 kbase_kcpu_command_queue
使我们能够操纵一个合法的管道缓冲区,而不必定期更新 .page
字段。然而,我仍然必须处理 .len
和 .offset
字段。
修改 pipe_buffer→len/offset 字段
正如我之前提到的,执行管道读写操作会更新 .len
和 .offset
字段,使得即使通过两个不同的管道执行,在同一页面上的后续读写操作也无法使用。这里有一个技巧:有一种技术可以在不触及 .len/.offset
字段的情况下读写数据!。通过在 pipe_read/write
上引发 copy_page_from_iter
和 copy_page_to_iter
调用的故障,就可以实现这一点!是的,就像 copy_to/from_user
一样,copy_page_to/from_iter
通过 iov_iter
结构从用户空间复制数据,它可能会引发故障。
继续以前的例子,如果我们想将 8 字节的数据写入地址,提供的用户提供缓冲区大小必须是 8,然后是内存的未映射或不可读区域,然后传递 9
作为大小参数到 write
系统调用,表示我们想要写的数据量。这个操作将写入 8 字节,并在遇到未映射/不可读的内存位置时失败。因此,数据已经有效地写入目标内核缓冲区,而 .len
字段没有被修改。pipe_write
内核函数将返回而不更新 buf->len
字段。
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
ret = pipe_buf_confirm(pipe, buf);
if (ret)
goto out;
ret = copy_page_from_iter(buf->page, offset, chars, from);
if (unlikely(ret < chars)) {
ret = -EFAULT;
goto out;
}
buf->len += ret;
if (!iov_iter_count(from))
goto out;
}
对于读操作也是如此;如果我们想读取 8 字节,使缓冲区的第九个字节不可读,然后声称我们要读取 9 字节,数据将被复制到用户缓冲区而不改变 .offset
字段。
因此,我们可以在不经过重复喷洒过程的情况下,对任何内核内存地址进行无限次的读写操作。
获取 root 权限
现在我有了强大的任意读写原语,我只需查看 VMEMMAP_START
数组中的所有 struct page
以确定内核文本起始地址,使用 Interrupt Labs 博客文章中概述的技术。然后我意识到 init_task
在 Android November Security Updates 中被清零了,所以我改用 kthreadd_task
。有了 kthreadd_task
内核地址,我可以遍历 task->tasks
列表,获取我自己的 current
任务内核地址,然后将 cred
结构清零以获取 root 权限。
后来,我意识到扫描所有页面地址是不必要的,因为我已经有了从 pipe_buffer 对象中的 anon_pipe_buf_ops
内核文本地址。有了这些信息,我可以推断出内核文本基地址,有效地绕过了 KASLR。
禁用 SELinux
该漏洞也禁用了 SELinux,有了内核文本基地址,我只需要找到 selinux_state
全局结构体的位置,然后将 .enforcing
值清零。
概念验证
随报告一起提供的概念验证在运行 Android 14 的 Pixel 7 和 8 Pro 设备上进行了测试,使用 10 月和 11 月的 ASBs,成功率接近 100%。
还需要提到的是,由于使用了一些硬编码的偏移量,该漏洞不会在其他设备上立即奏效。为了增加对新设备的支持,必须提供以下信息:
-
从内核基地址到 kthreadd_task
的偏移量。 -
从内核基地址到 selinux_state
的偏移量。 -
task_struct->cred
、task_struct->pid
和task_struct->tasks
结构的偏移量。 -
从内核基地址到 anon_pipe_buf_ops
的偏移量。
编译
要将漏洞利用编译为独立二进制文件,请使用以下命令,然后使用 adb shell
运行它:
$ aarch64-linux-androidXX-clang++ -static-libstdc++ -w -Wno-c++11-narrowing -DUSE_STANDALONE -o poc poc.cpp -llog
$ adb push poc /data/local/tmp/
$ adb shell /data/local/tmp/poc
您也可以通过将此目录嵌入 Android Studio 应用程序来运行漏洞利用,并确保通过向 cmake 文件添加 -w -Wno-c++11-narrowing
来禁用无用的 C++ 警告。
演示
$ adb logcat |grep -i EXPLOIT
11-28 16:04:12.500 7989 7989 E EXPLOIT : [+] Target device: 'google/husky/husky:14/UD1A.231105.004/11010374:user/release-keys' 0xa9027bfdd10203ff 0xa90467faa9036ffc
11-28 16:04:15.563 7989 7989 E EXPLOIT : [+] Got the kcpu_id (0) kernel address = 0xffffff8901390000 from context (0x0)
11-28 16:04:18.441 7989 7989 E EXPLOIT : [+] Got the kcpu_id (255) kernel address = 0xffffff89b0bf8000 from context (0xff)
11-28 16:04:18.442 7989 7989 E EXPLOIT : [+] Found corrupted pipe with size 0xfff
11-28 16:04:18.442 7989 7989 E EXPLOIT : [+] SUCCESS! we have a fake pipe_buffer (0)! [...]
11-28 16:04:18.445 7989 7989 E EXPLOIT : [+] Freeing kcpu_id = 0 (0xffffff8901390000)
11-28 16:04:18.446 7989 7989 E EXPLOIT : [+] Allocating 61 pipes with 256 slots
11-28 16:04:18.462 7989 7989 E EXPLOIT : [+] Successfully overlapped the kcpuqueue object with a pipe buffer [...]
11-28 16:04:18.463 7989 7989 E EXPLOIT : [+] pipe_buffer {.page = 0xfffffffe26baab40, .offset = 0x0, .len = 0x30, ops = 0xffffffdaf18d3770}
11-28 16:04:18.463 7989 7989 E EXPLOIT : [+] kernel base = 0xffffffdaf0010000, kthreadd_task = 0xffffff8002da3780 selinux_state = 0xffffffdaf28a3168
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Found our own task struct 0xffffff88416c5c80
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Successfully got root: getuid() = 0 getgid() = 0
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Successfully disabled SELinux
11-28 16:04:20.102 7989 7989 E EXPLOIT : [+] Cleanup ... OK
原文始发于微信公众号(3072):Android Mail GPU 内核 LPE 分析(译)