Pixel 9 零点击漏洞利用链 第二部分:用大浪破解沙箱

访问原始链接 Google 翻译

随着潜在的杜比统一解码器RCE漏洞利用的出现,谨慎起见,有必要探究从由此产生的用户态上下文(即mediacodec上下文)中可以访问哪些Linux内核驱动程序。根据AOSP文档,mediacodec SELinux上下文旨在成为一个受约束的(即沙盒化的)上下文,用于运行非安全的软件解码器。然而,使用我的DriverCartographer工具,我发现了一个有趣的设备驱动程序/dev/bigwave,可以从mediacodec SELinux上下文访问。BigWave是Pixel SOC上存在的硬件,用于加速AV1解码任务,这解释了为什么可以从mediacodec上下文访问它。正如先前的研究已经充分证实的那样,用于硬件设备的Android驱动程序是寻找强大的本地权限提升漏洞的主要场所。BigWave驱动程序也不例外——在审计代码的几个小时里,我发现了三个独立的漏洞,其中一个功能强大到足以逃逸mediacodec沙盒,并在Pixel 9上实现内核任意读写。

(非常简短的)漏洞搜寻

我发现的第一个漏洞是一个重复漏洞,最初于2024年2月报告,但在2025年6月重新发现时仍未修复,尽管修复只是交换两行代码。一年多后,该漏洞仍未修复。第二个漏洞呈现了一个非常有趣的漏洞类别,类似于双重释放kmalloc利用原语——但使用的是完全不同的链表。然而,第三个漏洞创造了最理想的利用原语。针对这三个漏洞的修复已于2026年1月5日发布。

最理想的漏洞

每次打开/dev/bigwave设备时,驱动程序都会分配一个新的内核结构体,称为inst,它存储在fd的private_data字段中。在inst内部有一个名为job的子结构体,用于跟踪与BigWave硬件执行单个任务调用相关的寄存器值和状态。为了向bigo硬件提交一些工作,进程使用ioctl BIGO_IOCX_PROCESS,该ioctl从AP用户态的ioctl调用者处获取Bigwave寄存器值,并将job放入一个队列中,该队列由一个单独的线程(bigo工作线程)拾取和使用。这意味着,一个生命周期本质上绑定到文件描述符的对象,会在一个单独的内核线程上被临时访问,而该线程并未明确与该文件描述符的存在同步。在BIGO_IOCX_PROCESS ioctl处理期间,提交一个job以在bigo_worker_thread上执行后,ioctl调用进入wait_for_completion_timeout,并设置16秒的超时等待bigo_worker_thread完成任务。在这16秒之后,如果bigo_worker_thread没有发出任务完成信号,超时期结束,ioctl将job从优先级队列中出列。然而,如果先前有足够数量的任务堆积在bigo_worker_thread上,bigo_worker_thread可能会被严重延迟,以至于它刚刚出列并正在并发处理ioctl认为已超时并试图出列的同一个job。在这种情况下,系统调用上下文直接返回到用户态,如果此时用户态关闭与BigWave实例关联的fd,则inst(以及随之的job)会被销毁,而bigo_worker_thread继续引用job。

高亮部分表示对UAF对象的任何访问:

static int bigo_worker_thread(void *data)
{
	...

	while(1) {
		rc = wait_event_timeout(core->worker,
			dequeue_prioq(core, &job, &should_stop),
			msecs_to_jiffies(BIGO_IDLE_TIMEOUT_MS)); //The job is fetched from the queue
		...

		inst = container_of(job, struct bigo_inst, job); //The job is an inline struct inside of the inst which gets UAF'd

		...

		rc = bigo_run_job(core, job);

		...
		job->status = rc;
		complete(&inst->job_comp);
	}
	return 0;
}

...

static int bigo_run_job(struct bigo_core *core, struct bigo_job *job)
{
	...

	inst = container_of(job, struct bigo_inst, job);
	bigo_bypass_ssmt_pid(core, inst->is_decoder_usage);
	bigo_push_regs(core, job->regs); //The register values of the bigwave processor are set (defined by userland)
	bigo_core_enable(core);
	ret = wait_for_completion_timeout(&core->frame_done,
			msecs_to_jiffies(core->debugfs.timeout)); //pause for 1 second
	...
        //At this point inst/job have been freed
	bigo_pull_regs(core, job->regs); //A pointer is taken directly from the freed object
	*(u32 *)(job->regs + BIGO_REG_STAT) = status;
	if (rc || ret)
		rc = -ETIMEDOUT;
	return rc;
}
void bigo_pull_regs(struct bigo_core *core, void *regs)
{
	memcpy_fromio(regs, core->base, core->regs_size); //And the current register values of the bigwave processor are written to that location
}

通过喷洒攻击者控制的kmalloc分配(例如通过Unix域套接字消息),我们可以控制底层的UAF指针job->regs,从而控制写入的目标地址。此外,由于我们在执行开始时设置寄存器,通过以某种方式设置寄存器使BigWave处理器根本不执行,我们可以确保结束时的寄存器状态几乎与原始寄存器状态相同——因此我们也可以控制写入的内容。就这样,我们获得了一个相当不错的2144字节任意写入!而且完全不需要泄露KASLR偏移量!

绕过KASLR(什么都不做)

在启用KASLR的情况下利用此问题通常涉及在bigo inst上重新分配其他对象,并在inst->job.regs的位置放置一个指针,从而导致该重叠指针指向的对象内存损坏。这需要找到在该位置具有指针的某个可分配对象,并且还需要找到一种方法来利用能够覆盖子对象的能力。找到这样的对象很困难但并非不可能,特别是如果考虑跨缓存攻击。然而,这相当繁琐,并不是我理想中的有趣时光。幸运的是,我找到了一个简单得多的策略,基本上可以完全绕过Pixel上的KASLR,其细节你可以在我之前的博客文章中阅读。那次支线任务的最终结果是发现,与其需要泄露KASLR基址,不如直接使用0xffffff8000010000,尤其是在覆盖内核.data时。这极大地简化了利用过程,并显著提高了利用的潜在可靠性。

创建任意读写

此时,我拥有了一个在内核.data中几乎任意位置的写入原语——我可以为任何我想要的内核全局变量创建别名位置并进行修改。然而,bigo_worker_thread任务执行循环末尾的complete调用使利用稍微复杂了一些。complete调用swake_up_locked,后者在bigo inst内部的list_head节点上执行一系列列表操作:

static inline int list_empty(const struct list_head *head)
{
return READ_ONCE(head->next) == head;
}

void swake_up_locked(struct swait_queue_head *q) //The q is located at &inst->job_comp.wait (so attacker controlled)
{
	struct swait_queue *curr;

	if (list_empty(&q->task_list))
		return;

	curr = list_first_entry(&q->task_list, typeof(*curr), task_list);
	wake_up_process(curr->task);
	list_del_init(&curr->task_list);
}

虽然第一个list_empty调用最容易伪造,但它也需要知道内核内存中inst的位置,因为q是inst内部的内联结构体。不幸的是,我们的KASLR绕过方法无法提供这个信息,而且获取它也不特别容易,因为inst在内核堆中,而不是内核.data中。这意味着我们需要伪造一个有效的列表条目供q指向,并且还需要知道传递给wake_up_process()的任务的位置。最后,我们实际上需要伪造足够多的列表结构,以在q->task_list中的条目上执行list_del_init时存活下来,这涉及到列表节点以及指向第一个列表节点的第二个列表节点。考虑到我们之前提到的关于KASLR绕过的限制,这听起来可能很难伪造,但实际上并没有那么糟糕,因为到这个时候我们的任意写入已经发生了——所以我们知道在内核.data的某个地方我们控制的内存位置。这意味着我们可以在.data的那个空间中伪造任意的列表节点,并且我们可以将指向这些未来伪造的列表节点的指针放在我们用来替换inst的原始堆喷洒中。我们还知道内核虚拟地址空间中单个任务结构体的位置——init任务!init的任务结构体在内核.data中,因此我们可以通过线性映射引用它。在init_task上执行虚假的wake_up_process将完全无关紧要,同时避免崩溃。你可以在漏洞利用的setup_linked_list中看到设置这些链表节点的代码。

解决了这个障碍后,是时候确定用我们的任意写入目标.data中的什么了。我们的目标是将不可靠的2144字节任意写入转变为可靠的任意读写,并且对周围内存造成的附带损害要小得多。我决定尝试重新实现几年前从一个在野(ITW)漏洞利用中逆向出来的策略。该技术涉及通过将ashmem_misc数据结构中的一些VFS/fops处理程序替换为其他文件类型的VFS处理程序来创建类型混淆。实际上,由于CFI,你不能用指向内核.text中任意位置的指针替换处理函数指针。你必须用其他VFS处理程序替换VFS处理程序。然而,非常方便的是,我可以像ITW漏洞利用一样,在我的利用中使用configfs VFS处理程序。struct file的fops表和private_data的最终布局如下所示:

绿色的fops处理程序将把private_data结构体作为struct ashmem_area或asma来访问,而黄色的fops处理程序将把同一个private_data结构体作为configfs缓冲区来访问。对于configfs fops处理程序,将访问page指向的内存——那就是我们希望任意读写进行读取或写入的地方。我们将使用ASHMEM_SET_NAME ioctl来设置目标。

然而,还有一个额外的复杂情况:内核.text的线性映射是不可执行的,因此在伪造我的ashmem_misc数据结构时,我不能使用指向VFS处理程序的.text区域线性映射地址。实际上,泄露实际的KASLR偏移量并不特别困难。在目标ashmem_misc之前,我首先使用我的任意写入来目标内核.data中的sel_fs_type对象。该结构体有一个字符串name,在读取/proc/self/mounts时会被打印出来。通过使用我的任意写入替换该字符串指针,然后读取/proc/self/mounts,我可以将不可靠的任意写入转变为任意读取!使用这个任意读取,我可以读取ashmem_fops结构体(同样通过线性映射),它提供了相对于内核基址偏移的指针,从而允许我计算KASLR偏移量。

然后,我再次执行任意写入,用指向我同时构建的新伪造ashmem_fops表的指针覆盖ashmem_misc结构体——这就是覆盖比我所需多得多的数据带来的好处。

然而,你们中的敏锐者可能已经意识到,这个巨大的2144字节任意写入也有一个主要缺点,因为如此大的写入会破坏我实际写入目标周围的所有数据——这可能导致各种额外的崩溃和内核恐慌。实际上,可能会发生虚假崩溃,但手机出奇地稳定。我的经验是,在切换Wi-Fi开关时似乎会崩溃——但除此之外,手机似乎大部分工作正常。

一旦伪造的ashmem_misc结构体被插入,我们现在就拥有了一个完全可靠的任意读写,尽管手机有时会额外崩溃。获得任意读写后,我将SELinux设置为宽容模式(只需翻转selinux_state内核对象中的标志),分叉一个新进程,然后使用我的任意读写将新进程的任务凭证指向init_cred。此时,我拥有了一个具有root凭证且SELinux被禁用的进程。

集成到杜比漏洞利用中

将两个漏洞利用组合成一个链需要两个漏洞利用都付出相当多的工程努力。杜比漏洞利用将把Bigwave漏洞利用作为shellcode有效载荷传递(使用/proc/self/mem修补到进程中),所以我需要将我的漏洞利用转换为二进制blob。它还需要远远小于我的静态编译环境所支持的大小。最容易实现的目标是移除静态libc要求,并让漏洞利用包含它所需的所有系统调用和libc函数的包装器。当我着手完成这项相当繁琐的任务时,我意识到这可能是LLM非常擅长的事情。因此,我没有自己实现系统调用包装器,而是简单地将我的源代码复制粘贴到Gemini中,并要求它为我创建所需的系统调用包装器头文件。自然,AI生成的头文件导致了许多编译错误(如果我尝试自己做,肯定也会如此)。我拿这些编译错误,把它们反馈给同一个Gemini窗口,并要求它修改头文件以解决这些错误。修改后的头文件导致gcc产生了全新且令人兴奋的编译失败——但错误看起来与之前不同,所以我简单地重复了这个过程。经过4到5次尝试,Gemini能够生成一个不仅能够编译——而且完美工作的头文件。这提供了一些关于攻击者如何能够使用(或更可能已经在使用)LLM来使其漏洞利用过程更高效的见解。

这项努力产生了一个比以前小得多的ELF(7 KB而不是500 KB),但仅仅一个ELF还不够——我需要生成的blob在杜比漏洞利用从shellcode顶部开始执行时就能工作。然而,好消息是我的漏洞利用可以完全在没有链接器的情况下运行——只需要在ELF前面加上一个跳转,将PC设置为入口点。我还在gcc参数中包含了“-mcmodel=tiny -fPIC -pie”,以便生成的代码无论shellcode在内存中的位置或对齐方式如何都能工作。

最终确定漏洞利用

作为安全研究人员,内核任意读写足以展示漏洞的影响,但似乎有必要创建一个更易于访问的演示,以便更广泛地展示影响。我添加了代码,使漏洞利用执行一个包含的shell脚本,然后编写了一个shell脚本,该脚本拍照并将该图片发送回任意IP地址。

在本博客系列的最后一部分,我们将讨论从这项研究中学到的经验教训。