一步一步调通stagefright exploit

古语云:“蒙惠者虽知其然,而未必知其所以然也”。在漏洞利用的学习方面,我们不能只知其然,而必须知其所以然,才能真正掌握利用的精髓,以便学以致用。

前言

安卓Media Server的漏洞近一年多以来一直是挖掘的重点。而其中libstagefright无疑是最热点。自从今年八月份jduck公开披露以来,这个模块一直是谷歌撒钱的大户。近期我又从里面挖掘到一个critical的漏洞,有望在明年一月份bulletin里公布。种种迹象表明,这个模块漏洞还没有挖掘干净,应该还要继续臭名昭著一段时间。

目前关于libstagefright开源的exploit有两个版本,一个是jduck公布的exploit1,另外一个是谷歌公布的exploit2。这其中谷歌的版本做法比较精致,精确控制了内存布局,具体可以参见project zero的博客。Jduck的版本,网上没有过多的分析文章,乌云上有一篇只分析了一半,很关键的前期内存布局控制及触发点分析没有覆盖到,且作者似乎只是从静态角度分析了exploit chain的调用原理,这对于初学者想要彻底弄清楚整个利用的原理,还显不够。

本文从纯动态调试角度彻底阐述清楚该exploit的原理以及其所包含的问题。

源码分析

在着手调试之前,我们先要分析一下Exploit源码。只有弄懂了代码,调试才能事半功倍。
Exploit利用的漏洞是CVE-2015-1538,具体对应的安卓部分代码如下:

status_t SampleTable::setSampleToChunkParams(off64_t data_offset, size_t data_size) {
if (mSampleToChunkOffset >= 0) {
    return ERROR_MALFORMED;
}
mSampleToChunkOffset = data_offset;
if (data_size < 8) {
    return ERROR_MALFORMED;
}
uint8_t header[8];
if (mDataSource->readAt(
            data_offset, header, sizeof(header)) < (ssize_t)sizeof(header)) {
    return ERROR_IO;
}
if (U32_AT(header) != 0) {
    // Expected version = 0, flags = 0.
    return ERROR_MALFORMED;
}
mNumSampleToChunkOffsets = U32_AT(&header[4]);
if (data_size < 8 + mNumSampleToChunkOffsets * 12) {
    return ERROR_MALFORMED;
}
mSampleToChunkEntries =
    new SampleToChunkEntry[mNumSampleToChunkOffsets]; ----> heap overflow
for (uint32_t i = 0; i < mNumSampleToChunkOffsets; ++i) {
    uint8_t buffer[12];
    if (mDataSource->readAt(
                mSampleToChunkOffset + 8 + i * 12, buffer, sizeof(buffer))
            != (ssize_t)sizeof(buffer)) {
        return ERROR_IO;
    }
    CHECK(U32_AT(buffer) >= 1);  // chunk index is 1 based in the spec.
    // We want the chunk index to be 0-based.
    mSampleToChunkEntries[i].startChunk = U32_AT(buffer) - 1;
    mSampleToChunkEntries[i].samplesPerChunk = U32_AT(&buffer[4]);
    mSampleToChunkEntries[i].chunkDesc = U32_AT(&buffer[8]);
}
return OK;
}

其中在分配内存的时候会造成堆溢出,具体原理不再赘述。
在Exploit源码中,首先定义了两个地址:

# The address of a fake StrongPointer object (sprayed)
sp_addr = 0x41d00010 # takju @ imm76i – 2MB (via hangouts)

# The address to of our ROP pivot
newpc_val = 0xb0002850 # point sp at __dl_restore_core_regs

其中sp_addr是heap spray后单个buffer的起始地址,newpc_val是god gadget restore_core_regs (libc.so中)的起始地址,具体用作stack pivot. 因为Exploit默认在ASLR disable的情况下工作,因此作者就hardcode了这两个地址。需要说明的是如果你在不同的device测试,这两个地址可能不一样。

定义好了这两个地址后,就开始创建mp4文件。其中首先会把用来做Heap spray的buffer通过tx3g这个Box写进去:

page = page[:off] + rop + page[off+len(rop):] 
spray = page * (((2*1024*1024) / len(page))20) 
moov_data += make_chunk(‘tx3g’, spray)

一共2M左右。单个buffer中包含这几部分数据:

  • decStrong指令需要的相关寄存器数据(用来跳转到ROP)
  • restore_core_regs需要的相关寄存器数据(ROP起始,栈翻转)
  • mprotect需要的相关寄存器数据(堆可执行)
  • Shellcode
  • 一些其他gadget和补齐数据

需要注意的是,Exploit中tx3g生成的这个堆是放在第一个trak里面的,这会有问题。如果你在调试的过程中发现走到decStrong中必定发生crash,就是由于这个原因导致的,具体后面分析。

Spray的内容准备好了之后,Exploit中生成了一堆各种各样的data box,这里的作用其实是试图生成堆空隙,以便后面分配的堆能够最终落到前面,为覆盖提供可能。因为从代码看,造成溢出的堆其实是在最后的stsc 这个box解析时分配的,为了避免溢出后没东西可覆盖,需要在前面分配的各种堆中产生空隙,这样才有可能造成最后分配的堆反而跑到前面。安卓代码在解析这些box的时候都会生成临时堆,解析完后即free掉,另外,对同样的box,在第二次解析时会free掉第一次分配的内存(参考MetaData::setData),这两个原因导致这一堆data box解析结束后,在相关位置会留下堆空隙供后续溢出堆使用。 说到这里,解释一下为什么前面说谷歌的版本比较精致,就在于对这块堆布局的处理上,做的比较妥当,基本能保证溢出堆后面必有一个可以被用来利用的对象。而Jduck这个版本,并不能做这种保证,只是提供了可能。在调试的过程中证明了这点,溢出堆跑到前面的概率大概50%左右。

最后,生成第二个trak,并塞入恶意的stsc box,触发堆溢出:

# Build the nasty sample table to trigger the vulnerability here. 
stbl1 = make_stsc(3, (0x1200 / 0xc) – 1, sp_addr, True) # TRIGGER  

这里最终会导致溢出堆之后的0x1200个字节被填入sp_addr。这里的0x1200已经超过了一个内存页,这个值可能是根据调试的device所设定的。不过一股脑溢出超过一个内存页的大小却不希望导致crash这个做法其实挺坑爹的。我在调试的过程中发现,设这么大的值,很容易导致sample table对象被覆盖,于是溢出之后的循环还没跑完就crash了,根本跑不到析构函数里面去。

到这里总结一下,作者是希望通过对象析构时调用decStrong来触发exploit chain。这就需要保证整个过程不能crash。另外,为了保证进入decStrong后 r0(this)指向sp_addr, 就需要能够覆盖对象指针,这个指针我反复调试之后,确定为是sample table指针,位于trak对象中。当然不同的机型也可能不一样,需要测试。

问题

在调试的过程中,过于信任Exploit源码,没有去质疑而被误导,走了不少弯路。这里把Jduck的源码中的问题罗列一下:

  • ROP起始地址问题
    调试的过程中发现指令走到mprotect函数时老是发生crash,分析了一下堆,发现spray 的堆的内容发生错位。ROP的起始位置不对。看了一下源码,ROP起始地址设的为0x34:

    off = 0x34 
    page = page[:off] + rop + page[off+len(rop):] 
    ...
    # Add some identifiable stuff, just in case something goes awry… 
    rop_start_off = 0x34 
    x = rop_start_off + len(rop)
    

    根据指令的调试,这个值应该是0x4c (同乌云的那篇文章)。 不知道jduc那边如何考虑,我们需要修改一下。

  • tx3g box的位置问题
    前面分析了exploit chain是通过析构触发的。MPEG4Extractor这个类的析构顺序如下:
    ~MPEG4Extractor->first Trak->first sp(SampleTable)->second Trak->second sp(SampleTable)
    通过调试发现溢出的堆有50%的概率分配在second Trak之前,极少的概率分配在first Trak或MPEG4Extractor之前,即我们只能覆盖second Trak。但spray buffer是通过first Trak的tx3g 生成的,在析构的时候,first Trak先析构,这样会导致在析构sencond Trak的时候spray buffer已经被free了。由于是2M的大堆,free之后进程已经无法访问。所以会发现decStrong中 的指令LDR R4, [R0,#4]一经调用,必定会crash。 解决这个问题的办法就是把tx3g box放到第二个trak中,这样在sample table析构之前, spray buffer仍然是活的。下面两张图直观呈现了这个改动:
    改动前 改动后
    至于原作者为何将tx3g至于第一个trak,目前我还没找到可以解释的合理理由。

  • 溢出字节的大小问题
    在内存中几个关键的对象布局大概是这样,从低地址到高地址:
    MPEG4Extractor对象-Trak1对象-Trak2对象-sample table2对象
    而我们所关心的溢出堆大概有50%概率会落在Trak1对象与Trak2对象之间。这样就可以通过覆盖Trak2对象中的sample table2对象指针,达到在析构时控制r0的目的。要记住的是不能覆盖sample table2对象,因为在循环里面会用到,一旦覆盖,循环里面就crash了。因此设计一个合理的溢出值,就比较关键。在我的机子上经过若干次调试,我设的这个值为0xA50,大半个内存页的大小,可以使得成功率40-50%左右。

  • 几个地址问题
    即使关闭ASLR,不同的版本几个地址也不一样。需要注意的是代码中用到的pop {pc}和pop {r0, r1, r2, r3, r4, pc}是16位的thumb指令,需要将ida中的地址+1。 另外heap spray的预测地址也要根据调试预先定义好。

调试

在解决了以上四个关键的问题后,就可以开始调试了。
我的机器是华为 T9510E, android 4.0.4.
需要先关闭ASLR:

echo 0 > /proc/sys/kernel/randomize_va_space

ida attach media server,无需gdb server,直接用自带的android_server, nonpie版本。
通过下断点,可以看到第一个分配的Trak1的地址为:0x231d0
Trak1对象地址

第一个分配的stsc1里面的SampleToChunkEntry对象地址为:0x23458
stsc1的SampleToChunkEntry对象地址

Trak2的地址为:0x23e70
Trak2对象地址

第二个分配的stsc2里面的SampleToChunkEntry对象地址为:0x236d0 (溢出堆),同时看到sample table对象地址为r4=0x24888
stsc2的SampleToChunkEntry对象地址

到这里我们可以计算一下,溢出堆到Trak2的距离为 0x23e70 - 0x236d0 = 0x7a0 < 0xa50, 到sample table 对象的距离为 0x24888 - 0x236d0 = 0x11b8 > 0xa50。 即我们的溢出会完全覆盖Track2对象同时不会覆盖到sample table对象,说明这次触发有可能成功。 继续调试:

发现Trak2对象确实已经被完全覆盖,写入我们的预测地址:
Trak2被覆盖

循环结束,没有crash,进入MPEG4Extractor的析构,此时发现我们预测的堆已经被spray到,并且刚好是起始位置:
heap spray成功

Trak1先析构:
Trak1先析构

Trak2开始析构:
Trak2开始析构

sample table开始析构,a1 + 12为 sp(SampleTable)指针,已经被覆盖为sp_addr,所以调用decStrong后 r0即为sp_addr:
sample table开始析构

进入decStrong, r0指向spray buffer头:
decStrong

进入god gadget restore_core_regs, r0已经更新:
restore_core_regs

进入mprotect,r0已经被更新为页首,其他参数已经设入:
mprotect

第一个pop, 16位的thumb指令:
第一个pop

第二个pop, 16位的thumb指令:
第二个pop

shellcode, 即乌云文中所翻译的那段指令:
shellcode

最终:
reverse shell

总结

因为Media server crash后会不断重启,所以通过反复触发总能成功。我的机器概率大概是5-8次重启,能保证有一次成功。本文主要以动态调试方法详细阐述了该exploit的原理及相关问题,实践出真知,这些问题,或许因为某些原因在原作者的设备上并不存在(我对此表示怀疑),但通过这个调试过程,我们可以对漏洞利用的一般性方法有一个大概的了解。对文中指出的几个问题,有不同见解的朋友可以留言讨论。

修改的代码链接

CVE-2015-1538-update.py