Pixel 9 零点击漏洞利用链 第一部分:解码 Dolby
过去几年,手机中新增了多项AI功能,让用户能更好地搜索和理解他们的消息。这一变化的一个影响是增加了0点击攻击面,因为高效分析通常需要在用户打开消息之前就对消息媒体进行解码。音频转录就是其中一项功能。Google Messages接收到的传入SMS和RCS音频附件现在无需用户交互即可自动解码。因此,音频解码器现在已处于大多数Android手机的0点击攻击面之中。
我花了相当多的时间研究这些解码器,首先在三星设备的Monkey's Audio编解码器中报告了CVE-2025-49415。基于这项研究,团队审查了Dolby Unified Decoder,我和Ivan Fratric报告了CVE-2025-54957。这个漏洞很可能存在于当今使用的大多数Android设备的0点击攻击面中。与此同时,Seth Jenkins调查了Pixel 9上解码器运行的沙箱中可访问的一个驱动程序,并报告了CVE-2025-36934。
在我分享这项研究时,供应商以及安全社区的成员质疑此类漏洞是否可利用,以及在现代Android安全环境下,除了资源最丰富的攻击者之外,0点击漏洞利用是否可能。他们还询问,在媒体解码器上下文中执行代码对攻击者来说是否实际有用,以及平台如何降低这种能力给用户带来的风险。
为了回答这些问题,Project Zero编写了一个针对Pixel 9的0点击漏洞利用链。我们希望这项研究能帮助防御者更好地理解这些攻击在现实世界中如何运作,Android安全功能在防止此类攻击方面的优势和不足,以及修复移动设备上媒体和驱动程序漏洞的重要性。
该漏洞利用将分三篇博客文章详细说明。
本系列的第一部分将描述我们如何利用CVE-2025-54957在Google Pixel 9的mediacodec上下文中获得任意代码执行。
本系列的第二部分将描述我们如何利用CVE-2025-36934在此设备上从mediacodec权限提升到内核。
第三部分将讨论经验教训以及防止移动设备上类似漏洞利用的建议。
这些文章中讨论的漏洞已于2026年1月5日修复。
Dolby Unified Decoder
Dolby Unified Decoder组件(UDC)是一个提供对Dolby Digital(DD)和Dolby Digital Plus(DD+)音频格式支持的库。这些格式也分别称为AC-3和EAC-3。这些格式有公开规范。UDC被集成到各种硬件和平台中,包括Android、iOS、Windows和媒体流设备。它通常以符号有限的二进制"blob"形式分发给大多数OEM,然后静态链接到共享库中。在Pixel 9上,UDC被集成到/vendor/lib64/libcodec2_soft_ddpdec.so中。
漏洞详情
DD+音频从比特流处理,比特流由独立可解码的同步帧组成,每个同步帧代表一系列音频样本。在正常操作期间,UDC连续地从比特流解码每个同步帧。
同步帧的一个元素是音频块,根据规范,它可以包含以下字段。一个同步帧最多可包含6个音频块。
语法
比特数
skiple
1
if(skiple)
skipl
9
skipfld
9 * 8
}
这意味着解码器最多可以将0x1FF(skipl)字节从比特流复制到我们称之为"跳过缓冲区"的缓冲区中。
跳过缓冲区包含一种称为可扩展元数据传递格式(EMDF)的数据。这种格式是同步的,意味着UDC在跳过缓冲区中寻找特定的字节序列,然后将之后的数据作为EMDF处理。单个同步帧中的EMDF称为"EMDF容器"。这在规范中表示为:
语法
比特数
emdf_sync(){
syncword
16
emdf_container_length
16
}
EMDF同步字是'X8'。
EMDF容器定义如下:
语法
比特数
emdf_container() {
emdf_version
2
if (emdf_version == 3) {
emdf_version += variable_bits(2)
}
key_id
3
if (key_id == 7) {
key_id += variable_bits(3)
}
while (emdf_payload_id != 0x0) {
5
if (emdf_payload_id == 0x1F) {
emdf_payload_id += variable_bits(5)
}
}
emdf_payload_config()
emdf_payload_size
variable_bits(8)
for (i = 0; i < payload_size; i++) {
emdf_payload_byte
8
}
emdf_protection()
}
variable_bits定义为:
语法
比特数
variable_bits (n_bits) {
value = 0;
do {
value += read
n_bits
read_more
1
if (read_more) {
value <<= n_bits;
value += (1<<n_bits);
}
}
while (read_more);
return value
}
如果你花时间寻找这类规范中的漏洞,问题可能已经很明显了。emdf_payload_size的大小没有规定限制,同时variable_bits的输出可能非常大,基本上是任何数值。
确实,这就是Ivan Fratric在分析Android UDC二进制文件时发现的问题根源。在伪代码中,它将EMDF有效载荷读入自定义的'evo'堆,如下所示:
result = read_variable_bits(this, 8, &payload_length);
if ( !result )
{
if ( evo_heap )
{
buffer = ddp_udc_int_evo_malloc(evo_heap, payload_length, param.extra_len);
outstruct.buf = buffer;
if ( !buffer )
return 2;
if ( payload_length )
{
index = 0;
while ( !ddp_udc_int_evo_brw_read(this, 8, &byte_read) )
{
outstruct.buf[index++] = byte_read;
if ( index >= payload_length )
goto ERROR;
}
return 10;
}
}
因此,先分配内存,然后将有效载荷的字节复制到分配的内存中。这个分配是如何工作的?
void ddp_udc_int_evo_malloc(heap *h, size_t alloc_size, size_t extra)
{
size_t total_size;
unsigned __int8 *mem;
total_size = alloc_size + extra;
if ( alloc_size + extra < alloc_size )
return 0;
if ( total_size % 8 )
total_size += (8 - total_size) % total_size;
if ( total_size > heap->remaining )
return 0;
mem = heap->curr_mem;
heap->remaining -= total_size;
heap->curr_mem += total_size;
return mem;
}
evo堆是一个单一的slab,有一个单一的跟踪指针,在分配内存时递增。无法释放evo堆上的内存。它仅用于处理单个同步帧的EMDF有效载荷(规范没有限制一个同步帧可以包含的有效载荷数量,除了跳过缓冲区大小的限制),一旦该帧处理完毕,整个evo堆被清除并重新用于下一帧,同步帧之间没有持久性。
虽然evo_malloc对分配执行了相当多的长度检查,但这个检查存在缺陷,因为它缺少整数溢出检查:
if ( total_size % 8 )
total_size += (8 - total_size) % total_size;
如果在64位平台上总分配大小介于0xFFFFFFFFFFFFFFF9和0xFFFFFFFFFFFFFFFF之间,total_size的值将回绕,导致分配很小,同时,写入缓冲区的循环使用原始的payload_length作为其边界。
整数溢出漏洞通常难以利用,因为它们执行非常大的写入,但这段代码有一个特性使其并非如此。写入的每个字节都使用ddp_udc_int_evo_brw_read从跳过缓冲区读取,该函数基于emdf_container_length检查读取边界,该值也从跳过缓冲区读取。如果读取边界检查失败,循环退出,不再有数据写入由evo_malloc分配的缓冲区。这意味着溢出的长度是可控的,写入越界的字节值也是可控的,受限于skipl的大小(0x1FF * 6个音频块)。
这是一个强大的原语,我将其称为此漏洞的"缓冲区溢出能力"。但如果你仔细观察,这个漏洞还包含一个泄露。
EMDF内容以长度skipl写入跳过缓冲区,但EMDF容器也有一个大小emdf_container_length。当emdf_container_length大于skipl时会发生什么?
if ( skipflde && ... )
{
int skip_copy_len = 0;
for ( int block_num = 0; block_num < total_blocks; ++block_num )
{
if ( skiple )
{
...
for ( skip_copy_len; skip_copy_len < skipl; skip_copy_len++ )
{
b = read_byte_from_syncframe();
skip_buffer[skip_copy_len] = b;
}
}
}
int i = 0;
for (i = 0; i < skip_copy_len; i+=2 )
{
int16_t word = skip_buffer[i] | skip_buffer[i+1]);
if ( word == "X8" )
{
has_syncword = 1;
break;
}
}
if ( has_syncword )
{
…
emdf_container_length = skip_buffer[i + 1] | ( skip_buffer[i] << 8);
bit_reader.size = emdf_container_length;
bit_reader.data = skip_buffer[i + 2];
}
}
因此,虽然跳过缓冲区数据基于skipl写入,但用于处理EMDF容器的比特读取器的长度设置为emdf_container_length。这意味着可以在初始化的跳过缓冲区之外读取EMDF数据。我将在下文中将此称为此漏洞的"泄露能力"。
我们没有将泄露能力作为与CVE-2025-54957分开的漏洞报告,因为它本身没有独立的安全影响。解码器启动时,跳过缓冲区初始化为全零,之后只有同步帧数据(即正在处理的媒体内容)被写入其中。因此,在正常情况下,攻击者无法使用泄露能力来泄露他们不知道的任何内容。只有当与漏洞的缓冲区溢出能力结合时,泄露能力才变得有用。
解码器内存布局
利用此漏洞的下一步是理解它可以覆盖内存中的哪些结构。这需要理解UDC的内存布局。UDC在解码DD+音频时总共执行四次系统堆分配,所有这些都发生在解码器创建时,在任何同步帧处理之前。这些分配在处理每个媒体文件之间释放和重新分配。这对于媒体解码器来说相当典型,因为系统堆分配具有非确定性时序,在播放媒体时可能导致延迟。
分配的一个缓冲区是"静态缓冲区"。这个缓冲区包含一个大型结构体,支持解码器的所有功能。evo堆是这个缓冲区的一部分。在Android上,静态缓冲区的大小是692855。分配的另一个缓冲区是"动态缓冲区"。这个缓冲区用作各种计算的"暂存空间",也是跳过缓冲区的位置。它长85827字节。另外两个分配用于输入参数和输出数据,与此漏洞利用无关。
术语"静态缓冲区"和"动态缓冲区"有些令人困惑,因为解码器还使用其他静态和动态缓冲区,并且这两个缓冲区都是动态分配的。然而,这些是Android在集成UDC时使用的名称。在本文中,术语"静态缓冲区"始终指UDC初始化时分配的692855字节缓冲区,术语"动态缓冲区"始终指UDC初始化时分配的85827字节缓冲区,而不是其他任何静态或动态缓冲区。
下图显示了跳过缓冲区和evo堆相对于这些缓冲区的位置:
evo堆位于静态缓冲区的偏移量0x61d28处,紧接着是处理EMDF时用于写入跳过缓冲区的指针,我称之为"跳过指针"。它指向跳过缓冲区下方0x1000处,每次处理同步帧时,将其值加上0x1000来计算写入跳过数据(skipfld)的地址。
这意味着该漏洞有可能覆盖一个稍后会被攻击者控制的内容(下一个同步帧的跳过数据)写入的指针。不幸的是,这并不像使用缓冲区溢出能力覆盖指针那么简单,因为evo堆长0x1f08字节,而skipl的最大值是3066(0xbfa = 0x1ff * 6个音频块),这意味着跳过指针将被覆盖的值不能简单地通过解码包含该漏洞的EMDF有效载荷来立即控制。
这种行为由CVE-2025-54957附带的原始概念验证所证明。该文件导致缓冲区溢出发生,但由于跳过指针距离被覆盖的evo堆分配超过3066字节,数据从跳过缓冲区之外复制。由于此内存始终为零,跳过指针被覆盖为0,当下一个同步帧的跳过数据写入时会发生空指针崩溃。
为了解决这个问题,需要在堆部分填充时在evo堆分配上触发缓冲区溢出。幸运的是,一个EMDF容器可以包含多个EMDF有效载荷,解析每个有效载荷都会在evo堆上分配内存。分析执行此解析和分配的函数ddp_udc_int_evo_parse_bitstream,最小的有效载荷从跳过缓冲区消耗19比特。同时,每个处理的EMDF有效载荷导致在evo堆上分配96字节。这意味着大约需要99个有效载荷来填满evo堆,这相当于235字节的跳过数据。这完全在可用的跳过数据空间内。使用这种技术,可以用可控的绝对值覆盖跳过指针,然后将任意数据写入其中。
写什么?写到哪里?
虽然这是一个有用的原语,但其效用受ASLR限制,因为攻击者需要知道要写入的指针的绝对值,这在0点击上下文中不太可能。另一种可能性是部分覆盖跳过指针,例如,0x7AAAAA00A0可以被覆盖为0x7AAAAA1234。由于跳过指针最初指向动态缓冲区,这允许覆盖大部分动态缓冲区。不幸的是,动态缓冲区仅用于存储临时数值数据,不包含任何对利用有帮助的指针或其他结构,但这个原语有一个有用的方面。通常,只有3066字节的跳过数据可以写入跳过缓冲区,但它可以允许攻击者写入更多数据。
例如,想象以下一系列同步帧:
- 将跳过指针设置为0x7XXXXX4000
- 将3066字节的跳过数据写入跳过指针
- 将跳过指针设置为0x7XXXXX3800
- 将0x800+字节的跳过数据写入跳过指针
现在跳过缓冲区中可用数据的长度为3066 + 0x800,并且可以用更多同步帧链接,以将最多0xFFFF字节写入动态缓冲区。这本身并不是利用的路径,但它是一个稍后会有用的原语。我将在后续章节中将其称为WRITE DYNAMIC。
有一个重要的细微差别需要注意。为什么同步帧3只将跳过指针向后移动0x800(2048)字节,而它可以向后移动3066字节?这是因为设置跳过指针会覆盖跳过缓冲区中的数据。所以同步帧2写入3066字节,但同步帧3覆盖其中的一部分,例如200字节,然后同步帧4需要写入0x800+200字节来"修复"被覆盖的数据。因此,为了准确地将长缓冲区写入动态缓冲区,每个同步帧覆盖的内存需要重叠。但别担心,只要有足够的同步帧,几乎可以用攻击者控制的数据填充整个动态缓冲区。也可以通过将跳过指针设置为要处理的数据的起始位置来处理已写入的数据而不修改它,在一个同步帧中设置跳过指针,然后在第二个同步帧中使用skipl为2,这将只写入同步字('X8')。然后跳过数据将基于已写入的emdf_container_length进行处理。
无论如何,WRITE DYNAMIC原语显然不足以进行利用,所以我决定退一步,找出我可以覆盖哪些内存以获得代码执行,即使我没有立即的策略来覆盖它。分析静态缓冲区后,我了解到我的选择相当有限。整个静态缓冲区中只有两个函数指针,由函数DLB_CLqmf_analysisL非常频繁地调用,位于偏移量0x8a410和0x8a438。这似乎是UDC使用的唯一包含任何函数指针的动态分配内存。
请注意,0x8a410和0x8a438是绝对巨大的偏移量。它们距离evo堆末尾的地址0x63c30超过0x20000字节。典型的利用方法可能是直接溢出堆来覆盖其中一个指针,但这个偏移量太大了。即使使用上述原语用EMDF容器数据填充整个动态缓冲区(可写长度0xFFFF),仍然不足以覆盖这些指针。
扩展evo堆
需要一种不同的方法,所以我重新审视了静态缓冲区,寻找我可以在evo_heap末尾附近溢出的其他字段。有一个看起来很有趣:
heap_len用于在每个同步帧处理期间设置evo堆的分配限制。如果可以覆盖它,evo堆将能够在原始边界之外分配内存。这是一个非常有希望的可能性,因为它有可能启用一个允许在静态缓冲区内进行相对写入的原语。例如,如果我用一个非常大的值覆盖堆长度,然后分配0x286e8字节,由于evo堆从偏移量0x61d28开始,并且我能够分配和写入evo堆内存,那么我是否能够写入偏移量0x61d28 + 0x286e8 = 0x8a410?
当然,这仍然受限于可用的跳过数据大小,由于WRITE DYNAMIC原语,现在为0xFFFF。但由于有效载荷以19比特到90字节的比例使用跳过缓冲区内存,理论上可以使用0x286e8 / 90 * 19 / 8 = ~ 0xa000字节的跳过数据覆盖函数指针,这小于可用的0xFFFF字节。
然而,覆盖heap_len带来了挑战,因为到达它的写入也会覆盖跳过指针,如果跳过指针无效,它会在heap_len的新值被处理之前导致崩溃。解决这个问题的一种方法是知道可写指针的绝对值并将其包含在覆盖内存的数据中,但如果没有信息泄露,这在Pixel上不切实际。另一种方法是,如果动态缓冲区中存在有效指针,使用泄露能力,可以将其嵌入到帧的跳过数据中并用于覆盖,但动态缓冲区只包含数值数据。
然后我意识到动态缓冲区确实包含指针。不在分配的部分,而是在Android的scudo分配器包含在分配中的连续元数据中。在调试器中检查动态缓冲区,指针始终具有地址格式0x000000XXXXXXX0A0。0xa0的偏移量为堆头留出空间。
动态缓冲区的堆头如下:
偏移量0x00到0x50之间的内存未被scudo堆使用,因为这是一个次要(大)分配,但不幸的是,头之前有一个保护页,0x50字节不足以容纳覆盖跳过指针和堆长度所需的EMDF容器,所以我研究了增加保护页和分配头之间未使用内存的方法。我发现:
- 如果释放一个次要分配,然后分配一个最多小0x2000字节的块,则释放的块将被重新分配以满足请求。更重要的是,堆头将向上移动。例如,如果在0x7f00000000分配了一个大小为0x17000的堆块然后释放,然后进行大小为0x15000的分配,则该块将被重用,但堆头现在将在0x7f00002000。
- 当释放次要块时,scudo完全基于上面显示的"curr chunk len"字段确定大小
同样重要的是要注意,动态和静态缓冲区是如此大的分配,具有如此不寻常的大小,以至于scudo总是在特定进程中的同一位置分配它们,在解码器初始化时分配内存,在取消初始化时释放它,因为一旦堆创建了这些块,它们就是满足该大小分配请求的唯一合适的现有块。(请注意,UDC在Android上运行在与其它编解码器分开的进程中。)
将所有这些放在一起,可以将跳过指针指向动态缓冲区头的"curr chunk len"字段,然后覆盖它,使块的长度变为0x17000而不是0x15000。然后,当解码器重置时(即播放新文件时),缓冲区将被重新分配,在堆头之前有额外的0x2000字节可写空间。这意味着漏洞利用将需要解码多个文件,但在通过转录利用此漏洞时这不是问题,因为单个消息的多个音频附件是按顺序解码的。
这一步有一个小的ASLR问题。如上所述,动态缓冲区以格式0x000000XXXXXXY0a0的指针分配,X和Y是由ASLR随机化的比特。要写入的期望值是0x000000XXXXXXY065。但请记住,跳过缓冲区实际上在跳过指针引用的地址偏移0x1000处。因此,为了执行写入,跳过指针需要设置为0x000000XXXXXXZ065,其中Z比Y小1。这意味着漏洞利用需要覆盖半字节Y,因此需要知道Y的值,而Y是由ASLR随机化的。
我在Pixel上做了一个实验,看看这个值是如何随机化的,它似乎相当均匀。
所以这里唯一的选择是猜测这个值,这意味着漏洞利用每16次中只有1次成功。但这并不构成障碍,因为攻击者可以重复发送漏洞利用直到成功,如果堆半字节值错误,解码过程大约三秒后崩溃并重新启动,这意味着漏洞利用平均在24秒内成功。
我的漏洞利用假设半字节值为3。有了这个,以及上述scudo堆头的移动,可以在堆头之前插入一个EMDF容器,并使用漏洞的泄露能力将其复制到跳过指针上,然后继续复制以设置堆长度。堆长度最终被动态缓冲区早期的音频数据(具体来说是比特分配指针)覆盖,对于我使用的同步帧,该值为0x77007700770077。
控制PC
现在一切准备就绪:我们可以将包含大约2070个EMDF有效载荷的EMDF容器写入动态缓冲区,当它被处理时,evo堆的约0x28000字节被分配,然后最终的有效载荷覆盖偏移量0x8a410处的函数指针。不幸的是,这没有成功。
事实证明,静态缓冲区中堆长度之后还有一些其他字段。
要理解这些是什么以及它们为什么导致问题,我们需要更仔细地查看处理EMDF有效载荷时如何分配evo内存。高度简化的伪代码如下所示。
int num_payloads = 0;
while(true){
int error = evo_parse_payload_id(&reader, &payload_id);
if(payload_id == 0 || error)
break;
num_payloads++;
error = evo_parse_payload(reader, payload_id, 0, 0, &payload, 0); //allocates no memory
if(error)
break;
}
void** payload_array = evo_malloc(evo_heap, 8 * num_payloads, 8 * array_extra);
for (int i = 0; i < num_payload; i++){
payload_array[i] = evo_alloc(88, 0);
}
reader.seek(0);
for (int i = 0; i < num_payload; i++){
int error = evo_parse_payload_id(&reader, &payload_id);
if(payload_id == 0 || error)
break;
error = evo_parse_payload(reader, payload_id, evo_heap, 0, payload_array[i], 0);
if(error)
break;
}
在第二次调用evo_parse_payload时,执行单个分配(与漏洞发生时可能溢出的分配相同),如下所示:
void* payload_mem = evo_alloc(payload_size, payload_extra);
在高层次上,这段代码计算EMDF有效载荷的数量,然后分配一个该大小的数组来保存指向每个有效载荷结构体的指针,然后分配一个结构体来表示每个有效载荷,并将数组中的相应指针设置为结构体分配,然后将每个EMDF对象读入其有效载荷结构体,如果包含有效载荷字节,则可选地分配有效载荷内存。
上面代码中静态缓冲区的两个字段以粗体标记。array_extra和payload_extra都是集成器可配置的参数,导致对evo_alloc的特定调用分配额外内存。
那么为什么这会导致我覆盖静态缓冲区中函数指针的尝试失败?当解码器处理具有大量有效载荷的EMDF容器时,它开始在evo堆之外分配内存,因为堆长度被覆盖为一个非常大的大小。第一个分配的evo堆内存是payload_array,一个指针数组,稍后设置为88字节的evo堆分配,每个有效载荷一个。对于2070个EMDF容器,这个数组非常大,为0x40B0字节。它与payload_extra以及静态缓冲区中的许多其他字段重叠,将它们设置为指针值。对于被解释为整数的字段,如payload_extra,最终结果是它们现在包含非常大的数值。
在payload_extra被覆盖后不久,调用evo_parse_payload,它尝试分配:
void* payload_mem = evo_alloc(payload_size, payload_extra);
分配大小通过添加payload_size + payload_extra(带有整数溢出检查)计算,然后才进行导致漏洞的错误对齐填充添加。由于指针在Android上被标记,最终结果类似于:
total_size = payload_size + 0xB400007XXXXXXXXX;
同时,堆长度被覆盖为0x77007700770077,这总是小于total_size,因此此分配失败。更糟糕的是,被覆盖的payload_extra在同步帧之间持续存在,这意味着没有payload_mem分配会再次成功。这阻止了漏洞再次触发,因为它需要成功的分配,因此无法纠正静态缓冲区中的这些值。
但也许没有必要再次触发漏洞,因为跳过指针是被巨大的payload_array分配覆盖的众多字段之一,导致它指向evo堆上方的静态缓冲区。我将跳过这里的一些细节,因为我最终在最终漏洞利用中没有使用此策略,但通过将数据写入更改后的跳过指针,可以覆盖函数指针,这证明此漏洞可以设置程序计数器!
非连续覆盖
控制PC表明此漏洞具有极佳的可利用性,但上述策略有一个严重的缺点:它阻止了漏洞再次触发,因此我只能执行一次覆盖,这将使实现shellcode执行具有挑战性。所以我的下一步是找到一种方法对静态缓冲区执行多次非连续写入。
当设置PC时,payload_extra不可避免的损坏阻止了未来的覆盖,但我最终意识到我可以利用设置此字段的能力来获得优势。
evo堆上的分配布局如下:
如果一个EMDF容器包含两个EMDF有效载荷,第二个有效载荷的数据将分配在num_payloads × 96 + payload_1_size + payload_extra。这允许在静态缓冲区中分配payload_extra字节,但不会被有效载荷覆盖。由于有效载荷数据的长度和内容可由攻击者控制,如果我能找到某种方法用受控数据覆盖payload_extra,就可以在静态缓冲区中的任何相对位置写入基本上任何数据。payload_1_size也从同步帧数据设置这一事实使这更加方便。由于此漏洞利用所需的所有写入在内存中彼此相当接近,payload_extra只需要写入一次,因此heap_base + num_payloads × 96 + payload_1_size + payload_extra等于DLB_CLqmf_analysisL的X0参数(稍后解释为什么这是一个好选择)。然后,通过修改payload_1_size的大小,单个写入的地址可以偏移那么多字节。例如,如果payload_1_size是14 × 8,上面讨论的静态缓冲区中的函数指针将被覆盖。
覆盖payload_extra
不幸的是,用于覆盖堆长度的方法不足以同时覆盖payload_extra,并且在获得PC控制期间发生的损坏没有提供足够的控制来覆盖payload_extra以执行上述步骤。请记住,堆长度被动态缓冲区中恰好写入静态缓冲区的scudo堆头之后地址的音频数据覆盖,而payload_extra被指针覆盖。对于仅扩展堆长度,将值设置为"随机垃圾"就足够了,但对于通过payload_extra进行多次覆盖,需要特定值。
一个简单的解决方案是使用WRITE DYNAMIC将堆头之后的数据写入所需值,但这不可能,因为此地址由解码器在解码称为比特分配指针(baps)的音频块部分时写入,时间在攻击者控制的数据写入之后和下一个同步帧处理之前。因此,即使使用WRITE DYNAMIC写入所需值,它们也会在被用于设置payload_extra和附近字段之前被覆盖。我尝试通过在同步帧中包含错误数据来阻止写入发生,以防止写入baps,但这也会阻止EMDF数据处理。我还尝试更改音频块以在此位置写入受控数据,但baps的可能值相当有限,只有低16位整数。
我最终想知道是否可能让scudo堆写入一个"非活动"头,即包含指针值但当前未使用的头。我实验了scudo,发现如果一个次要块是进程首次分配的该大小的块(就像动态缓冲区那样),它的前一个指针将指向自身,如果前一个指针被部分覆盖(例如,使最后两个字节为0x5000而不是0x3000),下次分配该块时,分配器返回的地址将在0x5000地址,但0x3000处的scudo头不会被清除。这之所以有效,只是因为动态缓冲区是该进程分配的唯一接近其大小的缓冲区,否则,存在此缓冲区再次分配的风险,导致内存损坏,可能在漏洞利用完成运行之前导致崩溃。
由于需要重置解码器以导致动态缓冲区重新分配,实现这需要向漏洞利用添加第三个媒体文件,但在完全远程漏洞利用中这不是大成本,因为三个附件可以轻松添加到同一SMS或RCS消息中。现在漏洞利用有三个文件:
- first.mp4 -- 使用WRITE DYNAMIC,将dynamic_base + 0x3061写入0x48,导致动态缓冲区在加载second.mp4时在dynamic_base + 0x4800重新分配
- second.mp4 -- 使用WRITE DYNAMIC,将dynamic_base + 0x4861写入0x50,导致动态缓冲区在加载third.mp4时在dynamic_base + 0x5000重新分配
- third.mp4 -- 包含漏洞利用的其余部分
请注意,dynamic_base是动态缓冲区的位置,低两个字节被清除,即dynamic_buffer && 0xFFFFFFFFFFFF0000。当漏洞利用工作所需的ASLR状态正确时,动态缓冲区位于dynamic_base + 0x3000。
现在,在dynamic_base + 0x4800有一个scudo堆头,它未主动使用,并且不会被可用于创建将覆盖payload_extra的EMDF容器的baps覆盖。但有一个问题。我之前解释过,当使用DYNAMIC WRITE填充缓冲区时,漏洞利用需要执行向下的重叠写入,因为下一个EMDF容器(需要移动跳过指针进行下一步)会覆盖写入开头的一些数据。这在写入长页数据时无关紧要,因为下一次写入可以修复前一次,但在此情况下很重要。堆头的布局如下:
我需要将特定数据写入精确偏移量0xc8,但不能损坏"prev chunk ptr",因为在复制期间需要它来覆盖跳过指针。这两者之间有0x60字节,不足以容纳移动跳过指针的有效载荷。
所以我需要一个新的原语。幸运的是,解码器处理EMDF同步字的方式提供了这个。基本上,一旦跳过数据复制到跳过缓冲区,就会在缓冲区中搜索同步字('X8'),EMDF容器解析在同步字之后开始。因此,可以在同步字之前放置一些数据,这些数据被写入跳过指针,然后将移动跳过指针的容器放在其后。这允许将数据写入跳过指针,然后在单个同步帧中移动跳过指针,因此数据不会被未来的跳过指针写入损坏。我将此原语称为WRITE DYNAMIC FAST。与WRITE DYNAMIC相比,此原语有两个缺点。一是由于移动跳过指针的EMDF容器和写入的数据在同一同步帧中,可以写入的数据量较小。二是更难调试。在WRITE DYNAMIC同步帧中,写入的地址始终在同一偏移量,因此很容易目视检查许多同步帧并确定它们写入的位置,但WRITE DYNAMIC FAST并非如此。因此,我的漏洞利用尽可能使用WRITE DYNAMIC,仅对无法用WRITE DYNAMIC完成的写入使用WRITE DYNAMIC FAST。
有了这个原语,我可以创建一个同步帧,用指向动态缓冲区的有效指针覆盖跳过指针,然后覆盖堆长度和payload_extra。这创建了一个新的原语,我将其称为WRITE STATIC。这允许写入静态缓冲区中相对于静态缓冲区基址大于0x63c30的任何偏移量!
调用可控函数
现在我有能力对静态缓冲区执行多次写入,是时候找出实现shellcode执行的路径了。这需要分析静态缓冲区中的函数指针如何被调用。它发生在以下函数中:
void* DLB_CLqmf_analysisL(void **static_buffer, __int64 *output_index, __int64 in_param)
{
//static_buffer is static buffer at offset 0x8a3c8
…
int loop_times = *(int*)static_buffer + 5);
int index = *(_DWORD *)static_buffer;
do
{
index_val = *output_index++;
param_X0 = static_buffer[12];
param_val = param_X0 + 8 * index;
(static_buffer[14])(
param_X0,
static_buffer[5],
static_buffer[1],
static_buffer[7],
in_param);
result = dlb_forwardModulationComplex(
param_X0,
index_val,
param_val,
*static_buffer,
static_buffer[13],
static_buffer[8],
static_buffer[9]);
index = *(unsigned int *)static_buffer;
--loop_times;
…
}
while ( loop_times );
return result;
}
函数dlb_forwardModulationComplex包含以下条件:
if ( a7 )
{
result = (__int64 (__fastcall *)(__int64, __int64, _QWORD))(*a7)(a3, a1, a4);
}
此函数的行为在利用方面非常有希望。它从可以用WRITE STATIC写入的内存中读取函数指针和参数,然后用这些参数调用函数指针。如果恰好有指向函数指针的指针可用而不是函数指针本身,还可以选择使用dlb_forwardModulationComplex进行间接函数调用。最后,基于从静态缓冲区读取的可控值,调用重复特定次数。将DLB_CLqmf_analysisL与WRITE STATIC结合,我可以部分覆盖函数指针以运行具有可控参数的ROP。
计划是什么,(Seth和)Jann?
在我开发此漏洞利用时,Jann Horn多次询问我计划如何从ROP转到mediacodec上下文中的代码执行,因为Android有几项安全功能旨在使此步骤变得困难。我将此推迟为"未来问题",但现在需要解决。
通常,我的策略是将共享库写入文件系统,然后对其调用dlopen。或者将shellcode写入缓冲区并使用ROP调用mprotect使其可执行。SELinux阻止了这两种方法。事实证明,mediacodec SELinux上下文没有任何允许规则允许它打开和写入同一文件,因此dlopen不可行。此外,mediacodec没有execmem权限,因此使内存可执行也不可行。更糟糕的是,libcodec2_soft_ddpdec.so对libc的调用有限。因此,可用于ROP目的的函数不多。例如,该库导入fopen和fread,但不导入fwrite或fseek。
最终,我与Jann Horn和Seth Jenkins一起制定了一个从ROP到任意指令执行的策略。Jann的想法是写入/proc/self/mem。这个ProcFS文件允许覆盖进程中的任何内存以用于调试目的(即支持软件断点),并可能用于覆盖函数然后执行它。
在调查了mediacodec上下文的权限后,我们提出了以下策略:
- 使用WRITE DYNAMIC将shellcode映射到内存中
- 在/proc/self/mem上多次调用fopen,以便可以轻松猜测与/proc/self/mem关联的文件描述符编号
- 调用pwrite将shellcode写入稍后可以执行的函数。(请注意,pwrite未被libcodec2_soft_ddpdec.so导入,但也没有其他可以写入文件句柄的函数)。
将此序列转换为由WRITE STATIC进行的ROP调用比预期更困难。一个问题是部分覆盖DLB_CLqmf_analysisL中的函数指针提供的功能比我想象的要少。如果你还记得,DLB_CLqmf_analysisL进行两次可以覆盖的函数调用。第一次是对0x26BDEC处analysisPolyphaseFiltering_P4的直接调用(请注意,这在Android版本的库中没有符号)。第二次是通过偏移量0x2A7B60处的指针对DLB_r8_fft_64的间接调用。
这些函数加载位置的第二个字节的上半字节在Android上由ASLR随机化。我测试了这一点,并看到了以下行为,这相当均匀。
因此,我唯一的选择是使用仅涉及覆盖函数指针第一个字节的ROP gadget,或者为漏洞利用增加额外的不可靠性。可用的gadget并不理想,所以我决定在我的漏洞利用中猜测此偏移量,这增加了另一个1/16的概率,意味着漏洞利用总共每256次成功一次。考虑到解码器进程需要三秒重新启动,这意味着漏洞利用平均需要大约六分钟才能成功,这并不构成障碍。
猜测这个半字节将可用的ROP gadget扩展到0xFFFF字节的范围,并且可以根据漏洞利用猜测此半字节的值在一定程度上移动此范围。尽管如此,这只是libcodec2_soft_ddpdec.so中1.3 MB代码的约5%。对于间接调用,0xFFFF几乎覆盖整个导出表以及全局偏移表(GOT),因此那里有一些选项,但该库仅从libc导出约40个函数。
但这并非毫无希望。首先,可以在这些限制下调用memcpy,如果参数未修改,dst是动态缓冲区中的一个位置,src是静态缓冲区中的一个位置。此外,在可访问范围内有一个有希望的ROP gadget:
0x000000000026ae38 :
ldr w8, [x1]
add w8, w8, #0x157
str w8, [x1]
ret
我将此称为"递增gadget"。
有了这个,我有了一个计划:
- 将间接调用更改为GOT中的fopen指针,并在/proc/self/mem上多次调用它
- 将间接调用更改为memcpy,并将fopen GOT条目复制到动态缓冲区
- 将memcpy的dst参数设置为动态缓冲区中GOT指针的位置并再次调用它,导致指向libc中fopen函数的指针被复制到动态缓冲区
- 使用DYNAMIC WRITE覆盖函数指针的最后一个字节,使指针与pwrite之间的距离是0x157的倍数
- 反复调用递增gadget,将动态缓冲区中的函数指针递增0x157,直到其值为pwrite
- 调用pwrite
- 成功?
这个计划显然忽略了很多细节,其中大部分将在下一节解释,但这是我当时写下的计划。
一个直接的问题是"数学计算是否可行"?似乎可行。在我查看的库版本中,fopen在0x92E90,pwrite在0xDD6C0。一个字节的覆盖可以将fopen指针更改为0x92E4A,然后:
0x157 × 890 + 0x92E4A = 0xDD6C0
另一个问题是,即使在libc编译偏移量不同的设备上,这个数学计算是否普遍有效。我相信是的。在每个libc版本中,至少有四个调用位置最终会调用pwrite:pwrite、pwrite的PLT、pwrite64和pwrite64的PLT。如果这些不行,还有seek和write或fseek和fwrite的组合。最坏的情况是,漏洞利用可以更改读取的GOT条目,使数学计算从不同于fopen的函数指针开始。有非常多的可能性,并且很可能不止一个在每个libc编译中有效。
漏洞利用
现在,是时候编写漏洞利用的第三个文件了。结果证明这相当复杂,存在