平时接触的安全大多数都是web端上的安全,由于web的基本架构是采用的B/S模式,本身以浏览器作为客户端。这样和移动端就形成了一个较为明显的区别:那就是移动端相比于web端要多了一套自我保护的安全能力,或者说是一种防止别人分析甚至是破解的能力。

在android的发展过程中,人们慢慢发现,自己写的app经过打包发布到各大应用商店后,经常会被别人下载下来,经过一些分析,修改,重打包,重签名,重发布。破坏了app的内部逻辑和相应的资源,于是,大家开始了app的加壳,是众多app加固方法中非常有效的方法。

从最早的app动态加载,再到后来的不落地加载,指令抽取,或者是现如今的VMP、混淆等相关加固技术的发展,移动端加固的对象越来越小,破解的门槛也越来越高,加固的难度也越来越趋向于虚拟机、深度混淆。但同样也带来了更大的性能损失、崩溃率的指数增长。致使相当一部分公司不愿意接受这种高损耗性壳。

看了前人很多脱壳分析文章,亦或是一些优秀的开源的脱壳工具。你会发现,其实真正能写出这类工具的人往往都是对dex文件、so文件、虚拟机加载机制等非常熟悉的,以至于他们随意拆解操作系统,编译出他们自定义的镜像文件。或许,从源码里更能发现出那些前人为什么这么"脱"。

早期的大部分加壳都是保护真正的dex文件,虽然dex文件要经过优化,优化成odex,但我们依然说dex文件是dalvik虚拟机的可执行文件。每一个apk文件都对应着内存里的LoadedApk对象,这个对象里保存了类加载器。每一个LoadedApk对象通过ApplicationLoaders中的getClassLoader(下图第30行)来获取属于自己的类加载器。

在getClassLoader中,就是用PathClassLoader(上图第66行)来对dex文件进行最开始的加载的。其中DexClassLoader同样也可以对apk,dex,jar文件,甚至从SD卡上进行加载,而PathClassLoader只能对系统安装的apk文件,就是/data/app目录下的apk文件进行加载。这也就间接解释了为什么动态加载原理的壳基本都是用DexClassLoader来实现的。

PathClassLoader中只有2个构造函数(下图第37行和63行),没有什么实际的操作,都是直接执行父类BaseDexClassLoader中的构造函数。

在BaseDexClassLoader中,发现构造函数中有了实际操作,那就是构造出一个pathList变量(下图第48行),这是DexPathList一个实例。整个的构造函数的重要逻辑就落在了DexPathList中。

在DexPathList中,有一个私有属性dexElements(下图第62行),这是一个Element数组。经常会遇到有一些apk解压之后是多个dex文件,所以源码这里是使用的数组。当dex文件里的方法数量过多时,无法承载的情况下,就会存在这种多dex情况。

这是一个内部类Element,详细的定义如下。其中dexFile数据结构就是dex文件在内存中的映射,从某种角度上来说,dexFile在内存中对应着dex文件。这是在脱壳中非常重要的数据结构。另外的file是需要加载的file文件,isDirectory代表是否为目录。

回到之前的DexPathList中,发现其构造函数中使用的是本类的makeDexElements函数生成的dexElements(下图第112行)。

其中makeDexElements函数的第一个参数是调用了splitDexPath函数形成的一个file列表。用来在makeDexElements用来遍历加载使用。实际的split操作和判断是否为目录的工作是在splitAndAdd函数中完成的。其中splitDexPath函数是依次调用了splitPaths()-->splitAndAdd()。

看了第一个参数之后我们继续回到makeDexElements函数中。这里对刚才的第一个files列表进行遍历,这里的判断相对都比较简单,都是对文件名后缀做一系列的判断(下图第218行开始),但是最后都是进入到了loadDexFile函数中进行加载(下图第221行或230行)。

但是在loadDexFile函数中,只是对优化dex目录做了一个是否为空的判断(下图第262行),便进入到了DexFile.loadDex中。可见,dex文件的优化工作是在DexFile这个类中实现的。

在DexFile中,先对outputname判断是否为空,然后对文件的所有者id也进行了判断。然后通过openDexFile函数(下图第111行)生成了cookie。

实际上openDexFile函数还是调用了一个Native方法(下图第301行)。

openDexFileNative函数中sourceNameobj就是java层传入的dex文件指针,outputNameobj同理,是输出文件指针。(下图第194行)是在检测我们加载的dex文件是否为系统boot的dex文件。(下图第208行)是在判断扩展名为dex的情况。如果是dex,会调用dvmRawDexFileOpen(),并生成DexorJar实例,并对相应的属性进行赋值,比如isDex。(下图第216行)是扩展名为jar的情况。具体内容和dex文件情况一样。(下图第223行)是其他情况,既不是dex,也不是jar。直接打印日志抛异常就可以了。(下图第228行)是针对dex文件或者jar文件的情况,将所生成的DexorJar实例添加到gDvm中userDexFile结构体的hash表中,这样dalvik虚拟机会在hash表中直接查找。

在dvmRawDexFileOpen函数中操作相对比较复杂,流程稍微多一些,同样涉及了很多dex文件相关结构。(下图第128行)首先是打开文件句柄。(下图第134行)先对dex文件的文件头中的校验码进行校验,并把值赋值给adler32。如果失败直接跳到bail。(下图第139行)获取dex文件的修改时间,同样进行赋值操作。(下图第150行)判断odex文件名是否为空,如果空就生成一个。(下图第161行)dvmOpenCacheDexFile函数内部传入了cachename,主要是用来验证缓存文件正确性。如果验证没问题会在函数内部会将newFile赋值为true。

(下图第177行)开始为odex文件优化做准备,真正执行优化逻辑的是dvmOptimizeDexFile函数(下图第192行),其中传入的optfd是dex的文件句柄。(下图第212行)dvmDexFileOPenFromFd函数内部使用mmap函数对dex实现内存加载。

走到这里我们一起屡一下思绪,或者喝一杯咖啡,刚才的最后2行我提到的2个词分别是优化和加载。分别是对应到了上面的dvmOptimizeDexFile函数和dvmDexFileOPenFromFd函数。优化的意思实际上就是为了加载文件之前做的所有准备性的工作,都是为了给加载做铺垫性的工作。那么后面,主要是分别从优化和加载里面看看对文件进行了哪些具体的操作。

那我们按照执行顺序,首先看看优化的过程。其实这里面跟踪函数,我想特别说一下,有时候光看代码容易很难抽血的想象出代码的逻辑。其实android源码中的注释很多时候写的非常详细。dvmOptimizeDexFile()主要是生成一个优化过的odex版本。这里是fork一个进程,通过去执行dexopt来完成此次优化。(下图第373行)从这里开始去fork一个进程,然后初始化了一些变量,比如kDexOptBin为/bin/dexopt。dexopt是用来做优化的。还有androidRoot变量,如果为空,就默认设置成/system。并且修改pgid。(下图第399行)从这里开始申请一块内存,用来拼接execFile,拼接成 /system/bin/dexopt 。(下图第403行)从注释里可以看出,这里应该是设置程序的参数了。

这里面最重要传递了--dex参数和其他一些比如dexoffset,dexLength等。后面对gDvm这个全局变量进行了一些判断和修改。(下图第470行)调用了dexopt。

那我们继续跟踪一下dexopt的源码。看看到底是如何做的优化。就像他注释里说的,这里只是决定去哪儿。我们上段代码里传的是--dex,继续分析fromDex()

在fromDex函数的尾部,终于找到了做优化的地方。(do the optimization)

由于dvmContinueOptimization()函数非常长,这里我准备先插一下,介绍一下odex文件的大致结构。odex文件实际上是在dex文件基础上,添加了一些header,依赖库和辅助数据。可能很好奇的是既然odex在dex基础上添加了那么多,怎么能叫优化呢?因为odex文件都会生成在系统的/data/dalvik-cache目录下,这样dalvik就不用每次都从apk的文件包中解压去加载dex。这里关于dex文件的结构我们就当成黑箱了,实际上里面有很多节区,将字符串,类,函数指令,属性等分别存储在自己的节区中。

(下图第539行)在dvmContinueOptimization源码中,首先对传进来的dexLength和dexOffset进行了校验。

(下图第566行)利用mmap函数映射出一块内存,dexOffset实际上就是一会存放dex文件的地址,dexLength就是所要存放dex文件的大小。

随后调用了rewriteDex函数,注释上有说明,主要是完成对dex文件的字节序列重新排列,结构对其等。

(上图第609行)随后便调用了dvmDexFileOpenPartial(),这个函数也就是广为流传的早期脱壳点,因为这个函数第一个参数为dex文件地址,第二个是dex文件长度。经过了rewriteDex的优化重写,这里的dex文件一般都很准确。再往下就是对odex文件其他部分进行填充。(下图第676行)是对odex文件的依赖库填充。(下图第696行)把优化之后的结构体数据填充到odex文件中。(下图第705行)对文件填充之后,要重新计算校验码。(下图第715行)其他所有数据填充完毕后,开始计算头部的一些偏移地址,长度以及其他数据。

至此我们的优化过程就告一段落,接下来跟踪一下odex的解析过程。在之前的分析说到dvmDexFileOPenFromFd函数。这里的注释已经说明了一切,对优化过的odex进行映射到内存,并且解析。解析的工作是在(下图第114行)dexFileParse中完成的。

其实整个优化也好,加载解析也好,其实都是为了得到这个DexFile对象。一个DexFile对象对应着硬盘上的一个dex文件。从一个文本dex文件,到最终生成DexFile,就基本完成了整个优化和解析这个流程。但是对于Dalvik来讲,DexFile尚未结束。对于虚拟机来说,它得到了应该是一个ClassObject结构体。将DexFile结构体和ClassObject结构体连接起来是靠DvmDex结构体实现的。下面我们分析一下dexFileParse函数,看看它是怎么生成DexFile的。

(下图第296行)首先是做一个简易的长度判断,随后申请一块内存,用来生成DexFile。

(下图第309行)先对odex的头部进行拆分,整个生成的过程实际上就是把odex文件里的东西按照一定的规则赋给DexFile。这里只完成对头部的操作。先比对了版本号。

(下图第321行)这里我个人认为主要是为了建立DexFile的pClassLookup。

(下图第325行)忽略掉dex文件头,并且将忽略掉的部分长度也随之减掉。

(下图第336行)调用了dexFileSetupBasicPointer()用来还原dex的每一个节区。(dexFileSetupBasicPointer的源码在后面)

(下图第344行和374行)分别是对文件校验码和签名进行了重新的校验。其中签名使用的是SHA-1算法对除去magic,checksum,signature外余下的所有文件区域进行计算。用于唯一标识文件。

如上面所说的dexFileSetupBasicPointer的源码,完成了dex文件数据和DexFile成员的关系映射。

经过千辛万苦也算是终于拿到了DexFile了,但是仔细观察DexFile的结构体发现也没有实际的指令啊。那虚拟机到底是怎么解释这些指令呢。实际上上文提到的ClassObject类才是虚拟机最终想见到的人。DexFile和ClassObject之间是靠DvmDex这个类建立起来的。而ClassObject类中保存着Method结构体的地址。而Method结构体中就有我们想要的实际指令insns。dalvik虚拟机正是解析的这些insns。实际上画一个图就是下面这个思路。

在这里我们就可以联想到指令抽取壳的原理是在运行前将dex里的insns抽取为空,将其复写成全0,等到我们虚拟机加载器指定method的时候再将Method的insns给还原成正确的insns。这样就防止了之前通过dump或者hook脱壳就能拿到完整的dex文件。这样即使dump出来,指令也是全空的。后来针对这种指令抽取,有一个开源工具叫dexhunter(https://github.com/zyq8709/DexHunter),大致的原理是同样修改了代码,编译出新的镜像,然后分段dump,针对空指令的处理是当加载dex文件之后,主动对class_def_item进行遍历,并使用dvmDefineClass等函数主动的去加载,初始化,完成指令的还原,最后对完成的指令再做一些修复,包括指令对其,指令出界等。

写这篇文章参考了很多前人的经验,很感谢前人的无私奉献,加上我自己的一些个人理解,也算是初步的把这个dex文件加载的流程简单的跟踪了下来。但是关于很多源码的细节,尚未深究。比如他为什么这么写而没有那样写,亦或是他这么写的目的又是什么等等。小豹依然很喜欢整理思绪,在这里小豹只想说,关于android安全,未完待续,尽请期待!

声明:本文来自小豹讲安全,版权归作者所有。文章内容仅代表作者独立观点,不代表安全内参立场,转载目的在于传递更多信息。如有侵权,请联系 anquanneican@163.com。