安卓逆向:某录屏软件脱壳重打包,附对几种frida脱壳脚本的分析

脱壳重打包

  之前在玩游戏的时候打出了一个精彩操作,想分享给朋友,然而手机还没有自带录屏功能,于是应用商店随便下了一个录屏软件。在录制好回放准备导出发给朋友的时候,弹出了一个令人不太愉快的窗口:

  是的,他居然要会员。就你这个软件敢要我的会员?叔能忍,婶忍不了。直接拖到jadx-gui里反编译一波呗。



  看起来反编译的结果不大对。MT里看一眼:
  确实是有壳的,TX御安全的壳。那么第一步就要脱壳了,这里还是用的Frida DEX Dump脚本,轻量,省事,搞不定我们再试其他办法。文章的最后也会附上一些我对这个脚本原理的理解。

开启frida-server及端口转发以后,直接运行脚本。
脱出来了6个dex。将dex改名为classes*.dex
回到MT管理器中,删除他原来的3个dex
然后把我们脱壳出来的6个dex替换进去。接下来要去掉TX御安全的相关文件。其实不去掉也可以,只是会占用空间。asset目录下的

  垃圾文件,去掉。那个0OO00什么的,8.71M,其实猜也能猜到,这个就是加密的dex。我们现在已经不需要他了。lib目录下,带有shell的so文件,删掉,壳的so代码:

  根目录下:
  一看就不是好东西,去掉。接下来进到AndroidManifest.xml中修改入口,先查看入口Activity是否为原本的Activity。找的方法,还是,通过MAIN和LAUNCHER定位到入口Activity。

  哎这截图让我难受。观察到这个Activity确实是原本的App内的Activity。再向上找Application
  这个name很明显就不是App的了。接下来要去加固的Apk里找真正的入口点。首先要知道加固的一些原理,本质上加固的类代理了Application,运行时由加固的Application去加载真实的入口Application类,在这里就可以拿到真实的Application类名称。怎么找呢,很简单,这个代理类必定会继承自Application,在Hook加壳的App时,也是去找到这个类然后去HookattachBaseContext方法获取classloader。我们搜一下extends Application:
我们要找的入口类就是这个
把他复制一下,替换到application标签下的android:name中:
保存,重新编译、签名。
  可以看到直接变成未加固了,安装试试。然后发现,闪退了。。
  直接闪退,首先怀疑dex损坏,其次是签名校验。我尝试用mt管理器的dex修复功能修复DEX后,依然闪退,那么高度怀疑签名校验。这里我就不试MT的签名校验功能了,完全依赖工具也没什么意思(其实是MT的没搞定hhh)。搜索一下签名三兄弟:
..199个
  97个,最后那个getPackageName就没必要找了,主要是这两个。难搞啊,这么多位置,不可能一个一个去看啊。那么考虑一下常规的签名校验套路,首先在进来的时候应该去做一次签名对比把,如果不一样,就强制关闭App。那么在强制关闭这里有没有什么捷径可走?常用的强制退出方式就那几种,首先就是System.exit(0),然后Process.killProcess。先搜搜第一个:
  就5个,不多,挨个看看。
  看到这个的时候,这个代码的逻辑是不是和之前提到的套路很相似?那先干掉这个地方的校验试试。后面一大串,懒得改,直接干掉退出这一行代码
保存,签名,安装。
  看来改对了。
  之后要搞他的会员了,这个App没有混淆,所以关键位置是很好定位的,我只是想用导出功能,所以并没有去花太长时间研究完全破解会员(想完美破解会员的话需要的时间成本也比较高..),只是改了导出视频的限制,非常简单,这里就不讲了。简单虽简单,要小心逻辑,逻辑改的有问题的话还是会各种闪退,此时可以借助DDMS辅助分析,在闪退的时候DDMS中会提示相关错误信息。
  所以总结脱壳重打包的步骤:工具脱壳->修复->替换Dex->找到入口点并同步修改AndroidManifest.xml->删除垃圾文件(这步可以省略)->apk签名->找到并PASS签名验证(if有)。找入口根据各家的加固方案不同这一步的流程也不同,不过常见的壳基本上都是这个套路,企业壳除外。

脱壳脚本简析

  然后读几个脱壳脚本的代码,白嫖葫芦娃大佬的FridaDexDump这么久了,一直也没有仔细的看过大佬的代码是怎么写的。突然心血来潮看了一看,发现这个脚本非常有意思,和其他的脱壳方法都不一样,眼前一亮的感觉。

  既然同是frida脱壳,我们拿其他的两个常用的frida脱壳脚本看一下是怎么写的。首先来个frida-unpack,也是基于python调用,主要的部分在js里,打开看一下:

  代码很少,就20多行,短小精悍且有用。既然加壳的App在执行时想要使用原本的dex,那么他就会在运行时将原本的dex解密并加载,此时的一条必经之路就是libart.so的OpenMemory,这个名字不是全称,还会夹杂着一些乱七八糟的名字。。看一下这个函数的参数是什么:
std::unique_ptr<const DexFile> DexFile::OpenMemory(const uint8_t* base,
                                                   size_t size,
                                                   const std::string& location,
                                                   uint32_t location_checksum,
                                                   MemMap* mem_map,
                                                   const OatDexFile* oat_dex_file,
                                                   std::string* error_msg) {
  CHECK_ALIGNED(base, 4);  // various dex file structures must be word aligned
  std::unique_ptr<DexFile> dex_file(
      new DexFile(base, size, location, location_checksum, mem_map, oat_dex_file));
  if (!dex_file->Init(error_msg)) {
    dex_file.reset();
  }
  return std::unique_ptr<const DexFile>(dex_file.release());
}

  想要dump出来,知道目标dex的开始位置和size就行了。起始位置是第二个参数,size呢?dex的header都找到了,还愁不知道size嘛,看看Dex文件格式的header:

struct header_item {
    uchar[8] magic <comment="Magic value">;
    uint checksum <format=hex, comment="Alder32 checksum of rest of file">;
    uchar[20] signature <comment="SHA-1 signature of rest of file">;
    uint file_size <comment="File size in bytes">;
    uint header_size <comment="Header size in bytes">;
    uint endian_tag <format=hex, comment="Endianness tag">;
    uint link_size <comment="Size of link section">;
    uint link_off <comment="File offset of link section">;
    uint map_off <comment="File offset of map list">;
    uint string_ids_size <comment="Count of strings in the string ID list">;
    uint string_ids_off <comment="File offset of string ID list">;
    uint type_ids_size <comment="Count of types in the type ID list">;
    uint type_ids_off <comment="File offset of type ID list">;
    uint proto_ids_size <comment="Count of items in the method prototype ID list">;
    uint proto_ids_off <comment="File offset of method prototype ID list">;
    uint field_ids_size <comment="Count of items in the field ID list">;
    uint field_ids_off <comment="File offset of field ID list">;
    uint method_ids_size <comment="Count of items in the method ID list">;
    uint method_ids_off <comment="File offset of method ID list">;
    uint class_defs_size <comment="Count of items in the class definitions list">;
    uint class_defs_off <comment="File offset of class definitions list">;
    uint data_size <comment="Size of data section in bytes">;
    uint data_off <comment="File offset of data section">;
};

  其他的先不看,我们直接看这个file_size,这不就是整个dex文件的大小吗,拿到开始的位置,加上一个0x20的偏移就可以得到整个dex文件的size了。有兴趣的可以了解下dex文件格式,非虫大佬的书里讲的比较清楚了。
  frida-unpack就直接Hook了libart.so的OpenMemory方法,先拿到start的地址,然后计算得到size,直接从内存中读二进制数据dump下来保存~不过这个脚本也有不足之处,有时候会dump不下来或者dump了一些错误的dex。

  接下来看另外一个常用的脱壳脚本,fuckdex,这个就比较长了,200多行。

  fdex里面有很多函数,我们先挑重点的看看他都做了什么。getFunctionName里先是做个判断,如果安卓版本大于4,依旧hook libart.so的OpenMemory方法,否则尝试hook libdvm.so。因为ART虚拟机是在Android4.4之后引入的,所以如果版本小于4就不能依靠libart.so了,因为根本没有啊~~
  下面这个方法看的我有点发懵,hook了一堆加载so的方法。。一度看不懂在做什么,不过看函数名,获取进程名,好吧先这样理解吧。

  这两个方法,检查dex头中的魔数,也就是文件头最开始的magic。dex一般会有dex和odex,常规在apk中的是dex文件。而安卓在安装app时,会进行检查并优化dex,将优化后的dex整合并提取出来的就是odex,odex内可以包含多个dex,此后会将这个odex复制到系统目录,之后在运行的实际上是这个odex文件内的代码。而文件头中的魔数,dex对应的是dex.035,odex的是dey.036,通过这一点就可以判断这里的是dex还是odex了。
  我们把两个函数中的magicFlagHex数组转换成字符串看一下:
  结果就是dex035和dey036了,那么这里就是通过魔数判断是否是dex/odex文件。接下来的就是把上面这些连起来:
  好吧和之前的那个也差不多,只是多了对odex的支持。顺便说一句,odex是可以转换回dex的,具体,需要的时候百度吧。。挺麻烦的,还要dump出系统的frame文件夹我记得。
  还有一些是通过Hook Classloader的getDex等方法实现脱壳,具体是哪个脚本记不住了。这些都是去通过Hook实现,拦截dex加载的步骤。为啥说葫芦娃的这个脚本有趣,看一下怎么实现的。

  一块一块看,先遍历每一块内存,查看是否有"64 65 78 0a 30 ?? ?? 00"特征,Memory.scanSync用于搜索内存数据,第三个参数为pattern,找到的话会返回符合条件的数据。

  对每一个符合条件的结果,先判断path中是否有"/data/dalvik-cache/"和"/system/",因为这里要脱的是apk的壳,所以以这两个path开头的就直接跳过。然后进入到verify方法里判断,如果符合就用当前地址+0x20拿到dex文件大小,接下来把开始地址和大小保存在result数组中,之后就能猜到了,dump出result数组中的每一个元素。
verify做了什么,跟过去看一下。
  首先判断这段内存块的size是否大于0x70,如果小于直接返回false,这里的话,是因为dex文件头的大小就是0x70..你这块内存比dex header还小,那肯定没什么用。
  0x20,还记得吗,上面提过,0x20的偏移位置是dex文件的大小。这个判断是假设我这里就是dex文件了,通过0x20取到一个大小,加起来得到一个结束位置,和传过来的内存块结束位置对比,如果我这个dex结束位置比内存块结束位置还大,那么肯定也是不符合条件的。
  然后0x34,对照着上面的表看一下,可以知道0x34是map段大小,同样是用起始位置+map段大小与内存结束位置对比。如果我这起始位置+map段大小,比你内存块还大,那肯定也是不对的。接下来就是对每一段map进行对比校验。之后进入到verify_by_maps中:
  先取一个mapoff段的整体大小maps_offset。然后对map中的每一个item的大小累加,得到各个item的总大小maps_size,如果这两个值不相等,那么也是不合法的。比如你包里有3个苹果,你和我说你有4个,我拿过包来数一下你这里只有3个,这不是不对劲吗。正是通过这样一个校验判断mapoff是否合法。这些都是判断dex是否完整的特征。
  最下面还有一个+0x3C是否等于0x70的判断,这里就是判断基址加上0x3C所在块,也就是string_ids块结束的偏移,是否等于一个标准dex头的大小(0x70),因为string_ids块是dex头的最后一块内容,string_ids结束的位置应当就是dex头结束的位置,如果是一个合法的dex文件,那么string_ids结束的位置偏移应该是0x70。
  至此所有特征判断结束,是的,DexDump是通过判断一系列dex文件的特征,从内存中活活把dex给扒出来了。。确实是和其他的脱壳点不一样,相比于其他脱壳脚本也更加可靠。


  不过脱壳这东西,都不是万能的,我们在用的时候也需要掌握一些原理,观察使用的每一个工具的脱壳点,以便应对各种复杂且奇葩的加固壳。所以,要重视基础和方法啊(敲黑板)。。。

推荐阅读更多精彩内容