ALICTF2014 EvilAPK4脱壳分析

相关文件可以在下面链接中下载:

http://pan.baidu.com/s/1sjpvFy9

1 简述

该apk使用libmobisec.so函数实现对dex的解密还原。真正的dex为assets目录下的cls.jar和jute.data文件。

本分析文档主要用于讨论脱壳方面的技术,并非以获取该APK的登录密码为目的。所以虽然可以使用很多动态的Android 分析工具进行API跟踪,然后快速得到登录密码,但是我还是选择进行静态脱壳,毕竟没参加比赛,不用担心时间限制啊~

分析文档只涉及核心逻辑,具体细节需要结合libmobisec.idb文档阅读,里面注释还是很详细的。

2 分析

2.1 初探

关键函数在sub_24c84(我改名为了keyFunction),该函数进行一些准备工作后,就会在偏移值0x24eca处调用parse_dex函数(偏移值为0x26398)。此函数就是整个dex解密的核心函数!下面对它进行详细分析。

2.1.1 openWithHeader函数解析

首先在0x269c8调用openWithHeader函数。该函数的详细实现在0x285f0处,它的功能如下:

①获取真正的cr4解密key;

②打开并mmap  cls.jar,使用真正的key对cls.jar进行cr4解密,然后解压,解压算法为lzma,处理后的数据重新mmap;

③munmap掉第一次mmap的内存,将解密、解压后的cls.jar(就是一个dex文件)的首地址存放到struct1.cls_jar_mmap_addr中,将它的大小存放到struct1.umcompress_size中;

Struc1为一个辅助结构它的内容如下:

typedef struct struct1

{

      int mmap_size  ; //文件mmap大小,需要注意的是,在cls.jar操作中它的大小刚好比unpacksize的大小多0x10,这是由mmap时的参数造成的!详情参见idb

      void* file_mmap_addr;  //这个文件mmap在内存中的首地址,对于cls.jar其向后偏移0x10才是dex.035的开始地址!

      void* file_path ; //file的绝对路径

};

④返回struct1.cls_jar_mmap_addr值,并将上层参数r2赋值为;struct1.umcompress_size, 及r1赋值为cls_jar_mmap_addr;

 

如何获取真正的cr4解密key?

在ali::decryptRc4函数中,先获取原apk的classes.dex的crc32校验码(32bit),然后将一个硬编码的0x18字节的字符串按4字节为单位依次同crc32结果进行异或运算。运算得到的0x18字节的字符串就是真正的rc4解密密钥。

为了以后叙诉的方便,将解密、解压后的cls.jar称作cls.dex。

2.1.2 反射调用openDexFile函数加载cls.dex

鉴于篇幅有限,这里就不详细说明了,大家可以直接移步到jack_jia大牛的博客:

http://blog.csdn.net/androidsecurity/article/details/9674251

需要提及一点,就是openDexFile加载、解析完cls.dex之后,会在内存重新生成一个经过优化后的cls.dex。之后的操作,都是针对这个优化后的cls.dex的。我们可以在libmobisec的0x27b14下断,r0的值为openDexFile加载、解析后的cls.dex的开始地址,dump出来即可。

大家可以查看openDexFile函数的具体实现,源码在:

dalvik2\vm\native\dalvik_system_DexFile.cpp

   关键函数在dvmRawDexFileOpenArray(pBytes, length, &pRawDexFile),他完成加载和解析工作。需要注意的是其中的dvmPrepareDexInMemory函数会调用rewrite函数,这又是需要重点关心的函数。这个函数完成dex的优化,认证,以及所有类的加载(loadAllClasses)和部分inline方法的加载。说个题外话,可以从loadAllClasses函数看出,对于dalvik虚拟机而言,它在解析dex文件的时候会且仅会把所有的DexclassDef结构加载到内存,而只有在使用到某个类的方法的时候才会具体地加载这个方法!这貌似就是之前有人提及过的基于方法粒度的dex加密的前提吧。

按照正常情况,分析到此,就已经可以在openDexFile函数下断点,dump出解密后的dex了。但是,现实很残酷,我们将dump出来的dex,使用backsmali反编译为smali文件,得到的却是如下结果:

 

所有类的所有方法都被替换成了上面这种形式!显然,得到的cls.dex并非一个完整的dex文件,它真正的方法都被替换掉了!

看来脱壳过程不是想象中的那么简单啊。没办法,继续看libmobisec的代码。

 

2.1.3 ali::dex_juicer.patch函数分析

在加载完cls.dex之后,会接着执行ali::dex_juicer_patch函数。从名字就可以看出它是在对dex进行修补工作。继续分析,总结此函数的功能如下:

①使用同样的方式解密解压juice.data文件,得到文件decrypted_juice;

②通过一系列算法将decrypted_juice同cls.dex结合在一起,组合成为完整的dex.

可以在libmobisec的0x27b60下断,此时的r5为juicer.data解密后的首地址,r3为解密后的长度,dump出来,保存为decrypted_juice即可。

第二个功能总结起来就一句话,但实际情况很复杂,我最初就卡在了0x2a79c的函数上(函数名原来是sub_2a79c,后来直到大牛Flanker_017发了一份某个复旦大牛的解密算法后,才发现它原来就是readUleb128函数!这可能就是一个人进行分析时面临的最大问题——思维固化。听说那位大牛是初次接触android就把它给逆出来了,我......只能给他跪下~)。当然,理解了这个函数,并不意味着,就可以轻松的进行dex还原了。挑战才刚刚开始!

如果仅仅看它的那些解密函数,是很难得到有用的信息的。我分析了一天都没理出个头绪。本着一切问题睡一觉之后都能解决的原则,我一觉睡到了第二天下午~~果然,灵感来了!

 

2.2 换个角度分析问题

回顾2.1.2,我发现所有的方法都被替换成了RuntimeException。现在,我们换个角度,不从逆向的角度来思索问题,转而从加固者的角度来思索:如何才能实现2.1.2的情况。

首先,我们需要认真的分析2.1.2所展示出来的信息,总结一下:

①cls.dex包含了所有的类和方法,只是每个方法的真正代码都被替换了;

②所有的方法都被替换成了RuntimeException,但是,如果细心一点,就可以发现,它们并不是完全相同的!如MainActivity.onCreate方法的registers为3,而MainActivity.<init>方法的registers为2。

如果了解dex文件结构的话,我相信大家此时已经豁然开朗:我们只需要将每个DexMehtod对应的DexCode结构体篡改为2.1.2的模式就可以了!至于修复,将DexMehtod的codeOff偏移值做个重定位,指向真正的DexCode,不就OK了?

事实是我们猜想的那样么?这就需要获取每个method对应的DexCode进行验证了。

2.2.1 获取DexCode

要想获取DexCode结构体,看雪Xbalien的一篇关于dalvik自篡改的文章可以提供思路:

http://bbs.pediy.com/showthread.php?t=176732

总结一下,要找到A类的B方法对应的DexCode结构体(为了简便,这里省去了方法声明的匹配,原理是一样的,可根据需要自行添加)那么步骤如下:

①获取A类名对应的字符串ID­——A_class_stringID和B方法名对应的字符串ID——B_method_stringID;

②获取A_class_stringID对应的A_class_typeID;

③获取A_class_typeID对应的DexClassDef结构体的起始地址A_classDefAddr;

④根据A_class_typeID和B_method_stringID获取B_methodID;

⑤根据A_classDefAddr和B_methodID获取B_codeOff,它指向的就是dexCode结构体。

为了方便大家理解和测试,我在附件中提供了读取codeOff的完整源码,这是用标准c库写的,linux和windows都可编译,只需要将main函数中的classname_string和methodname_string根据自己的需要进行赋值即可。运行效果如下:

 

 

通过查询不同的method的DexCode,得出结论:我们的猜想成立!它是将所有方法的DexCode结构体都改成了如下结构:

MainAcrivity.<init>的dexcode

Offset      0  1  2  3  4  5  6  7   8  9  A  B  C  D  E  F

 

0000F850                                        02 00[w1]  01 00[w2]                   

0000F860   01 00[w3]  00 00 [w4] 00 00 00 00[w5]   06 00 00 00[w6]  22 00 17 01               "  

0000F870   70 10 20 06 00 00 27 00[w7]   02 00 01 00 01 00 00 00   p     '        

0000F880   00 00 00 00 06 00 00 00  22 00 17 01 70 10 20 06           "   p  

0000F890   00 00 27 00 02 00 01 00  01 00 00 00 00 00 00 00     '            

0000F8A0   06 00 00 00 22 00 17 01  70 10 20 06 00 00 27 00       "   p

 

 [w1]registersize = 2

 [w2]inssize = 1 参数个数

 [w3]outsSize = 1,调用其他方法时使用的寄存器个数

 [w4]triesSize

 [w5]debugInfoOff

 [w6]insnsSize = 6 指令集个数,以2字节为单位,

 [w7]紧跟着的6条指令

registersize和inssize会根据具体地函数进行相应的变化,除此之外,都相同。

 

2.2.2 修复dex

在前面说过,将DexMehtod的codeOff偏移值做个重定位,指向真正的DexCode就可以完成修复了。libmobisec中的代码也印证了此想法:

ali::dex_juicer_patch部分代码节选:

注意:由于当时分析的时候,命名规则有点乱,可能会给各位观众大老爷带来困扰,这里的juicerdata就是decrypted_juice;dex就是cls.dex;ali::juiceMem就是decrypted_juice在内存中的首地址

    ........

    juiceMem[0] = *ali::juiceMem;  //0x401

    juiceMem[1] = ali::juiceMem[1]; //0x8a8

    juice_mmap_addr_Plus_0x?_ptr = (int)(ali::juiceMem + 2);

    if ( juiceMem[0] > 0x1FC00000 )

      m_0x1004 = -1;

    else

      m_0x1004 = 4 * juiceMem[0];

    juiceDexCodeOff = juiceMem[1];

    ali::orgOffset = operator new[](m_0x1004);

    while ( v9 != juiceMem[0] )

    {

      dex_offset_sum += readUleb128((int **)&juice_mmap_addr_Plus_0x?_ptr);

      data_offset = readUleb128((int **)&juice_mmap_addr_Plus_0x?_ptr);

      orgOffset_new0x1004 = ali::orgOffset;

      cur_dexaddr = dexStartAddr + dex_offset_sum;

      data_offset_sum += data_offset;

      *(_DWORD *)(orgOffset_new0x1004 + 4 * v9++) = readUleb128((int **)&cur_dexaddr);// 这个new_0x1004的数据有什么作用?我们在这里给他赋了值,但是却不知道在哪个地方需要用到他!

      changeCodeOffInDex(

        dexStartAddr,

        dex_offset_sum,

        (char *)ali::juiceMem + data_offset_sum + juiceDexCodeOff - dexStartAddr);

/* 第三个参数很有意思,需要我们好好分析。首先它是4个变量的运算结果,但是注意,这4个变量中,ali::juiceMem, juiceDexCodeOff以及dexStartAddr均是确定的值,只有dats_offset_sum会不停变化。经过分析,发现第三个参数其实就是一个偏移值,这个偏移值表示的是juicerData中的某个DexCode数据的地址较dexStartAddr的偏移距离。*/

    }

    result = 0;

  }

  return result;

进一步分析chageCodeOffInDex,它的代码翻译过来如下:

ChangeCodeOffInDex(void* dexStartAddr, int dex_offset_sum, int rel_off){

    dexStartAddr [dex_off_sumd] = (rel_off & 0x7F) | 0x80

    dexStartAddr [dex_off_sumd+1] = ((rel_off >>7)&0x7F) | 0x80

    dexStartAddr [dex_off_sumd+2] = ((rel_off >>14)&0x7F) | 0x80

    dexStartAddr [dex_off_sumd+3] = ((rel_off >>21)&0x7F) | 0x80

    dexStartAddr [dex_off_sumd+4] = ((rel_off >>28)&0x7F)

}

很明显就是一个uleb128的数据。

再详细解释下decrypted_juice的文件结构:

patchCount

constOffset

dexOffset_1

dataOffset_1

dexOffset_2

dataOffse_2

......

Real_dexCode_1

......

Real_dexCode_N

 

这里patchCount = 0x401, constOffset = 0x8a8。这两个参数都是固定的,前者表示共有多少个dexCode需要重定位,后者为0x401个使用uleb128编码的dexOffset+ dataOffset所占用的总共字节大小(其实它们真正的大小为0x8a5,不过为了4字节对齐所以填充为0x8a8字节)。第一个dexOffset表示cls.dex中的第一个method的codeOff变量的地址较dexStartAddr的偏移值,之后的dexOffset都是相对于第一个codeOff地址的增量。同理第一个dataOffset表示decrypted_juice中的第一个real_dexCode的地址较decrypted_juice的偏移值,之后的dataOffset都是相对于第一个real_dexCode地址的增量。

其实只要画个内存的草图就很容易理解了。虽然cls.dex与decrypted_juice在内存中并不连续,但由于decrypted_juice中存放的真正有用的信息仅仅是dexCode,所以只要将cls.dex中每个method的codeOff重定向到decrypted_juice中相应的dexCode就可以了。

3 修复

修复的话就很简单了,时间有限,我就不用C重写修复代码了,这里直接贴上那位牛人的python代码(为了方便理解,我做了一点点修改):

import struct

 

# return value, length

def readLEB128(data,pos):

    length = 0

    tp = pos

    val = 0

    for i in xrange(5):

        val |= (data[tp] & 0x7F) << (7*i)

        length += 1

        if data[tp] & 0x80:

            tp += 1

        else:

            break

    return val, length

 

data = bytearray(open('decrypted_juice','rb').read())

dex = bytearray(open('cls.dex','rb').read())

 

patch_count, data_offset = struct.unpack('<II',data[:8])  

dexlen = len(dex)

 

pos = 8

dex_off_sumd = 0

data_off_sumd = 0

for i in xrange(patch_count):

    dex_off, skip = readLEB128(data, pos)

    dex_off_sumd += dex_off

    pos += skip

    data_off, skip = readLEB128(data, pos)

    data_off_sumd += data_off

    pos += skip

 

    reloff = dexlen + data_offset  + data_off_sumd

  

    dex[dex_off_sumd] = (reloff & 0x7F) | 0x80

    dex[dex_off_sumd+1] = ((reloff>>7)&0x7F) | 0x80

    dex[dex_off_sumd+2] = ((reloff>>14)&0x7F) | 0x80

    dex[dex_off_sumd+3] = ((reloff>>21)&0x7F) | 0x80

    dex[dex_off_sumd+4] = ((reloff>>28)&0x7F)

 

with open('fixed.dex','wb') as fp:

    fp.write(dex)

    fp.write(data)

马上使用dex2jar反编译fixed.dex,得到的smali代码如下:

 

解密成功!

不过,分析smali代码相对来说效率还是比较低的。最好能转换成java。果断使用dex2jar,结果悲剧了:

 

从上图可以看出android.support.v4.app.qn的testdex2jarcrash方法造成了dex2jar的崩溃。凭直觉,不可能只有这一个testdex2jarcrash方法,找出smali文件中的所有testdex2jarcrash方法,删掉后(最好改为无用函数),重新生成dex文件(这里推荐使用android逆向助手,集成化逆向分析工具,很方便,需要的话大家可以自行百度)。再次使用dex2jar完美编译,然后使用jd-jui打开就可得到java源码了:

 

鉴于jeb比jd-gui功能更强大,大家可直接将修复后的dex放到jeb进行分析。

 

至此整个脱壳工作告一段落。剩下的获取登录密码什么的,就交给各位去练手啦。

4 总结

说句实话,要完成此APK的脱壳所需要的技术、知识还是很多的:动态调试,静态分析,熟悉dex文件结构,熟悉Android dex动态加载机制,甚至于了解dex2jar等逆向工具的缺陷等等等等。不过收获自不用说,对dex的加固算是正式入门了`(*∩_∩*)′。

其实我这段时间一直研究的是so的加壳,但此APK并没有使用任何的so加固技术,可能是为了降低比赛难度吧,但还是有一种一拳打到空气上赶脚~算了,等以后有机会,再总结下so的加固技术吧。

 

 


 

posted @ 2014-10-10 13:14  WanChouchou  阅读(2077)  评论(2编辑  收藏  举报