Android中apk加固完善篇之内存加载dex方案实现原理(不落地方式加载)

Android技术篇 尼古拉斯.赵四 42026℃ 0评论

一、前言

时隔半年,困扰的问题始终是需要解决的,之前也算是没时间弄,今天因为有人在此提起这个问题,那么就不能不解决了,这里写一篇文章记录一下吧。那么是什么问题呢?

就是关于之前的一个话题:Android中apk加固技术实现

关于这个问题,之前的一篇文章已经说过了,没有了解的同学可以点击这里:Android中apk加固技术实现

请务必仔细的看完这篇文章,不然今天说的内容会感觉很蛋疼的,因为今天的文章就是为了解决当初的加固技术遗留的问题,这里先大致来说一下加固apk的原理吧,先来看一张图:

看到这张图其实,还是很好理解的,就是我们把需要加固的apk,外部包装一层壳,而这个壳的作用是为了解密源apk的,比如现在360加固都是采用这种思想,我们可以看一个简单的360加固之后的程序的AndroidManifest.xml文件:

看到了吧,这里StubApplication就是360加固给需要加壳的apk添加的一层Application。这样启动加壳之后的apk,其实是先启动这个Application,然后这个Application就开始解密apk操作,然后动态加载apk运行源程序,所以这里我们还看到有一个加密apk的过程,可以看这张图:

这个就是把源程序的apk塞到壳apk的dex文件中,这样壳Application就可以读取dex中的数据,进行解密即可。

从上面的加固思想来看,还是有一些风险的,那就是对于加固的apk,他启动的时候实际上是先启动壳程序,所以这样就会把我们的一些数据暴露给了这些加固程序,所以在加固apk的时候还是要考虑慎重。

 

二、加固遗留的问题

好了,上面就简单说了一下如何加固apk的大体流程,那么在这个实现过程中当初有一个问题,就是我们解密之后的apk程序是放在/data/data/xxx/cache目录下的,然后在用DexClassLoader进行加载apk,然后运行程序,那么这里就存在两个问题了?

1、解密之后的apk源程序放在指定目录的话,还是存在被破解的风险,因为这种落地方式解密,是很容易获取解密之后的apk的

2、在解密得到源程序apk,然后再用DexClassLoader进行加载,这里相当于两次把apk加载到内存中,第一次是解密的时候,第二次是加载apk的时候,那么这效率就会大大降低了

好了看到了有这两个问题,那么其实我们的解决思路很简单,就是如何做到不落地的解密apk程序,在解密完之后得到apk数据,立马进行内存数据的字节码加载,不需要在保存到本地的apk作为中转站了。

 

三、解决问题思路

我们先来猜想一下,系统既然能够加载dex文件,那么他会不会有一个能够直接加载文件字节码的方法呢?因为不管怎么样,加载一个文件到最后还是需要解析dex文件,然后map到内存中的,那么我们可以通过源码来看看有没有这样的方法?

那么我们既然最后都是要加载,肯定是用DexClassLoader类,那么我们看看这个类的源码:

源码位置:Android源码目录\libcore\dalvik\src\main\java\dalvik\system\DexClassLoader.java

擦,我们看到,他只有一个构造方法,就是需要传入加载文件的路径,没有能够直接出入字节数据的方法,那怎么破呢?不急,我们继续看他的父类BaseDexClassLoader源码:

源码位置:Android源码目录\libcore\dalvik\src\main\java\dalvik\system\BaseDexClassLoader.java

其实这个类,就是PathClassLoader和DexClassLoader的共同父类,关于这两个加载器的区别,不了解的同学可以看这里:

Android中的类加载器详解 这里就介绍了这两个类加载的区别和联系。

看到,在BaseDexClassLoader的构造方法中,有一个重要的类DexPathList,他就是解析加载文件的类,

源码位置:Android源码目录\libcore\dalvik\src\main\java\dalvik\system\DexPathList.java

看到了,这里知道了Android中能够加载的四种文件格式:dex/jar/zip/apk

查看他的构造方法:

有一个makeDexElements方法,进入查看:

在这里,用loadDexFile方法来加载文件,返回一个DexFile对象,那么我们再去查看这个类

源码位置:Android源码目录\libcore\dalvik\src\main\java\dalvik\system\DexFile.java

调用loadDex方法,返回DexFile对象:

在进入看构造方法:

这里有一个核心的地方,调用了openDexFile方法,然后返回一个int值:

擦,原来openDexFile是一个native方法,读取dex文件放在native层做的,而且,我们看到返回值代表什么意思呢?我们可以简单的理解为,VM中会维护一个Map结构,保存的内容就是dexFile文件和他对应的cookie值,每次在寻找这个dex中的类功能的时候,都是需要这个cookie进行操作的。

同时我们这里无意中看到了一个非常重要的方法:openDexFile的重载形式,参数就是一个字节数组,那么我们是不是就可以使用这个方法直接来进行操作呢?

好了,到这里我们分析完了dex加载的Java层的流程了,我们获取到的信息有:

1、Android中能够动态加载的文件格式只有四种:dex/jar/zip/apk

2、在DexFile中有两个openDexFile方法,一个是传递文件名称,一个是传递文件字节码,同时这两个方法是native层的。

我们继续来看看默认的DexClassLoader类加载一个类的流程是什么?

首先看的是loadClass方法:

我们在DexClassLoader和BaseDexClassLoader中都没有找到这个方法,但是BaseDexClassLoader继承了ClassLoader类:

在loadClass方法中其实是调用了findClass方法返回一个Class对象的,在看这个方法,在BaseDexClassLoader中:

这个方法中又继续调用了DexPathList类的findClass方法:

在这个方法中继续调用了DexFile的loadClassBinaryName方法:

好吧,这里最后调用了defineClass方法,又是一个native的方法,注意这个方法的最后一个参数是我们上面说到的那个dex对应的cookie值。这个值是openDexFile方法返回的。

上面分析完了dex的加载流程,下面总结一下就是:

ClassLoader的loadClass方法=》BaseDexClassLoader的findClass方法=》DexPathList的findClass方法=》DexFile的loadClassBinaryName方法=》DexFile的defineClass方法

 

四、实践操作

我们知道了这些信息之后,下面我们就来进行操作吧!

我们知道DexClassLoader提供的只有一个构造方法,接受的是加载文件路径,所以我们如果想让其接受加载字节码的话,只能重写我们自己的ClassLoader了。但是在重写一个ClassLoader的时候,我们需要注意三个重要的方法:findClass/defineClass/loadClass

关于这三个方法的特点是干什么的,具体参见这篇文章:Java中如何自定义类加载器

他们三者有一个执行顺序:

在需要使用到一个类的时候,首先调用findClass去寻找到这个类文件,然后定义这个类,解析class文件格式,最后是加载这个类,当然在这个过程中可能涉及到Java中类加载器的双重委派机制,这里就不做太多的解释了。不过从这三个过程中我们可以看到:

一般是findClass方法中会抛出ClassNotFoundException的异常,defineClass会抛出NoClassDefFoundError的错误,我们看到findClass是在外部存储器中查找class文件的,defineClass是在内存中定义class的时候

所以总结:

加载时从外存储器找不到需要的class就出现ClassNotFoundException
连接时从内存找不到需要的class就出现NoClassDefFoundError

那么我们的流程很清楚了:

肯定要重写findClass方法,在这个方法中需要做一些事情,就是需要进行class的名称转化,我们知道在代码中类的名称是用点号进行连接的,但是在磁盘中的文件是靠路径符/来进行连接的,所以这里需要做一个转化。同时需要把dex文件中的其他类进行define,所以这里还有一个问题,就是如何获取dex中所有的类,还好这个方法在DexFile中,叫做getClassNameList:

也是一个native方法

在磁盘中找到了这个类的话,那么这时候就需要调用defineClass方法,进行定义,之后得到了Class对象。

具体实现步骤如下:

1、需要使用反射机制调用DexFile类的openDexFile方法,载入字节码,这里调用的是参数为字节码的方法。然后得到dex对应的cookie值,保存。

2、重写findClass方法,在这个方法中还是需要使用反射机制调用DexFile类的getClassNameList方法获取dex中的所有类,然后再次调用defineClass方法,这里依然是用反射机制调用DexFile的defineClass方法,而且这里需要传递上面的cookie值。

3、最后在重写loadClass方法,加载指定类

注意需要反射的几个方法的结构如下:

1》native private static int openDexFile(byte[] fileContents);

2》native private static String[] getClassNameList(int cookie);

3》private native static Class defineClass(String name, ClassLoader loader, int cookie);

所以我们下面在用反射调用的时候,注意传递的参数。

从上面的流程看到,我们用到很多反射,所以这里定义一个反射功能类RefInvoke。下面就开始正式coding了,首先看看我们自己定义的DexClassLoader类的构造方法:

构造方法接受的是字节数组参数:

反射调用openDexFile方法,返回cookie值

在来看一下findClass方法:

这里首先使用反射调用getClassNameList方法获取dex中的所有类,然后在用反射调用defineClass方法,同时记得转化路径符,得到class之后返回即可。这里的两个方法都是反射调用的:

最后再来看一下laodClass方法吧:

这里直接调用了父类的loadClass方法返回一个Class对象即可。

好了,上面我们的自己的类加载器就定义好了,下面就来测试一下吧,测试这里很简单,就是用一个demo的classes.dex文件进行测试即可,这里没有涉及到什么的加密和解密了,因为不是本文的重点。

这里很简单,得到dex的字节码,然后在调用injectDexClassLoader方法:

这里我们构造一个自定义的类加载器:DynamicDexClassLoader,然后使用findClass进行直接获取Class类对象,当然这里使用loadClass方法也是可以的。最后还要记得设置系统的ClassLoader,为了classes.dex中的Activity正常加载进来,这个知识点可以参考这篇文章:Android中运行免安装app 为什么要这么做,这里就不多解释了。

好了,下面我们来运行程序:

擦,openDexFile方法没找到,怎么会没找到呢?这时候我们为了排查问题,就在把DexFile类中所有的方法和方法的参数打印一下:

再次运行看看结果:

我擦,怎么只有一个openDexFile方法了,但是我们上面分析源码的时候,有一个openDexFile(byte[] …)的方法的呀!

好吧,在一顿蛋疼之后,想到了可能是系统版本问题,我们上面的源码分析是Android4.2的,但是我运行设备是5.0的,是不是google在新版本中去除这个方法了?我们速度查看了Android5.0的DexFile源码:

麻蛋,果然如此,找不到openDexFile(byte[]…)的方法了,而且也没有类似于这类的方法了,只有传递String参数的方法了。好吧,到这里感觉好绝望,为何在新版本中夭折了这个方法呢?

不过上天自古有好生之德,我们在冷静想一想,是否还记得不管openDexFile(byte[]…)这个方法是否存在了,这里的方法都是native层的,而且,及时夭折了,本质还是没有改变,那就是底层还是会有一个方法去解析dex文件得到字节码,然后进行加载到内存中的,所以我们可以坚信google夭折的肯定是Java层的代码,所以native层的代码肯定没有改变,所以坚信这点,我们查看了DexFile对应的native源码:

源码目录:Android源码目录\\dalvik\vm\native\dalvik_system_DexFile.cpp

这里的源码还是Android4.2的,因为我们为了分析问题,Android5.0肯定没有了,因为他把这个方法给夭折了,5.0对应的native源码目录为:Android源码art\runtime\native\dalvik_system_DexFile.cpp

看到了没有这个方法了,所以看4.2的源码,来查找被夭折的方法openDexFile(byte[]…)对应的native方法是啥?我们看到,openDexFile对应的native方法是:Dalvik_dalvik_system_DexFile_openDexFile_bytearray

再来看看这个方法的具体实现:

这里的参数会有点看不懂,其实很简单

第一个参数代表我们需要传递的参数对应的指针的数组,这么简单的理解吧,比如现在有两个参数字节数组,和字节大小,那么这个参数就是args[0]=字节数组对象的指针,args[1]=字节大小指针,这里可以看到C语言中的指针太无敌了,什么都可以干。

第二参数代表返回值指针,原理实现和上面的参数指针一样

这里使用了dvm系列的方法打开文件的。

好了,到这里,其实我们总结一下,我们现在遇到的问题:

Android5.0把openDexFile(byte[]…)方法给夭折了,但是我们分析了4.2的源码之后,发现openDexFile其实对应的是native层的

Dalvik_dalvik_system_DexFile_openDexFile_bytearray方法,那么5.0会在底层把这个方法也给夭折了吗?其实我们猜想是不会的,因为他不管怎么样,最终还是会调用这个方法来解析dex文件,然后进行加载到内存中,那么这个方法在哪里呢?我们该怎么执行他呢?这里的两个问题其实很简单:

第一个问题:我们知道Android中只要底层涉及到VM的native代码都有一个著名的共享库文件,那就是libdvm.so,如果这个方法没被夭折,那么肯定是在这里

我们可以查看设备中的这个库文件:

我们把它pull到本地,然后用IDA打开进行查看:

这里很多dex开头和dvm开头的底层函数。

第二个问题:我们需要借助于两个系统函数:dlopen和dlsym这两个函数,他们的功能就是打开一个共享库文件,然后可以根据传递的函数名和变量名得到函数指针和变量指针

dlopen函数以指定模式打开指定的动态链接库文件,并返回一个句柄给dlsym()的调用进程
dlsym根据动态链接库操作句柄与符号,返回符号对应的地址。使用这个函数不但可以获取函数地址,也可以获取变量地址。

其实说的简单点,就类似于Java中的反射,我们用ClassLoader加载一个jar文件,然后用反射去访问方法和得到变量等信息。

好了既然上面的两个问题解决了,下面就来写个代码验证一下我们的猜想,看看libdvm.so中是否还存在这个函数

那么这里肯定要设计JNI了,关于AndroidStudio中如何使用NDK,这里不解释了,网上自行搜索即可。

不过这里为了检测方便,我们在java层定义了一个native方法:

public static native int loadDex(byte[] dex,long dexlen);

他的功能其实很简单,就是上面DexFile被夭折的openDexFile(byte[]…)方法,这里多传递了一个dexlen长度参数,是为了native层容易处理,不需要在去计算大小了。再来看看native层:

这里应该在JNI_OnLoad函数中进行dlopen和dlsym操作,因为时机比较早

这里有一行重要的代码:

dvm_dalvik_system_DexFile = (JNINativeMethod*) dlsym(ldvm, “dvm_dalvik_system_DexFile”);

这个是获取libdvm.so中的一个JNINativeMethod结构体变量,Andoird 中使用了一种不同传统Java JNI的方式来定义其native的函数。其中很重要的区别是Andorid使用了一种Java 和C 函数的映射表数组,并在其中描述了函数的参数和返回值。这个数组的类型是JNINativeMethod,定义如下:

typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
第一个变量name是Java中函数的名字。
第二个变量signature,用字符串是描述了函数的参数和返回值
第三个变量fnPtr是函数指针,指向C函数。

那么我们得到这个结构体之后,就可以知道所有其对应的JNI函数列表了,这里定义了lookup函数来做这个事情:

这个函数的作用就是判断传递进来的函数是否为JNINativeMethod数据结构中的native函数。如果是的话,就赋值给JValue指针,这里的JValue指针就是一个函数指针:openDexFile:

所以这里我们看到JNI_OnLoad函数中调用lookup函数的时候传递的函数名是:openDexFile,而不是我们上面猜测的那个函数:

Dalvik_dalvik_system_DexFile_openDexFile_bytearray

就是因为我提前运行测试了,打印log之后发现:

所以这里,我们的猜想是错误的了,就是so中并不存在Dalvik_dalvik_system_DexFile_openDexFile_bytearray 这个函数了,而是openDexFile这个函数,好了,既然猜想错了,但是我们还是找到了这个底层的函数,那么就简单了,执行这个函数即可,因为上面我们已经得到了这个函数的指针了:

这里,我们先把Java层传递进来的字节内容和字节大小构造成一个u4类型的参数指针,然后调用openDexFile函数,得到返回值,返回给Java层即可,不过这里有一个点就是有一个类型是ArrayObject的,这个我们可以去这个源码头文件Object.h中找到copy过来就可以了:

头文件的源码目录:Android源码目录\dalvik\vm\oo\Object.h

native层的代码也看完了,下面我们就来验证一下看看libdvm.so库中的的openDexFile函数好不好使,我们在Java层修改一下自定义的类加载器的代码:

使用我们的native方法:loadDex,传递dex的字节数组和字节大小

那么下面我们来看看运行结果:

看到了,这里是native层的日志,看到openDexFile找到了

我们使用findClass去加载MainActivity类,成功了,我们再看运行结果:

擦擦擦,成功了,哈哈,好兴奋呀。。。我们成功的实现了内存加载dex方案,解决了之前apk加固遗留的两个问题。

资源下载:http://download.csdn.net/detail/jiangwei0910410003/9538313

 

五、知识梳理

1、我们在之前apk加固中遗留的两个问题

1》、解密之后的apk源程序放在指定目录的话,还是存在被破解的风险,因为这种落地方式解密,是很容易获取解密之后的apk的

2》、在解密得到源程序apk,然后再用DexClassLoader进行加载,这里相当于两次把apk加载到内存中,第一次是解密的时候,第二次是加载apk的时候,那么这效率就会大大降低了

那么我们带着这两个问题,就思考,结果这两个问题的最好办法就是如何能够动态加载内存数据,而不是有一个中间产物apk,但是我们看到DexClassLoader只有一个构造方法,是接受加载文件的路径的,那么我们就猜想,不管加载上层如何,底层都是需要解析dex文件,然后加载到内存中的,所以肯定在某个地方有加载字节数据的,所以我们去查看DexClassLoader源码

2、我们通过分析DexClassLoader源码了解了Android中动态加载的流程

这里涉及到了几个类:DexClassLoader/ClassLoader/BaseDexClassLoader/DexPathList/DexFile

其中,BaseDexClassLoader是DexClassLoader的父类,BaseDexClassLoader继承了ClassLoader,他们互相调用的流程:

ClassLoader的loadClass方法=》BaseDexClassLoader的findClass方法=》DexPathList的findClass方法=》DexFile的loadClassBinaryName方法=》DexFile的defineClass方法

这里最终都是回到了DexFile中的几个native方法:

Class defineClass(String name, ClassLoader loader, int cookie)

Class loadClassBinaryName(String name, ClassLoader loader)

int openDexFile(String sourceName, String outputName,int flags)

我们在分析的过程中,在DexFile中发现了一个重要的方法:int openDexFile(byte[] fileContents)

这个方法可以加载字节数组,那么我们就开始尝试用反射机制来操作DexFile来实现自定义类加载器

3、实现自己的类加载器的主要功能

1》在类加载器的构造方法中反射调用openDexFile方法得到一个cookie值

2》重写findClass方法,在这里首先通过反射调用getClassNameList方法,需要传递上面的cookie值,得到dex中所有的类,然后在进行类路径的转化把点号转化成斜杠,然后在反射调用defineClass方法,需要传递上面的cookie值,然后返回一个Class对象

这里我们看到一个重要的值,就是cookie,这个其实就是对应加载的dex的值,后续如果要访问这个dex附属的对象都可以使用这个cookie值

4、实践之后发现报错

实现了上面的功能之后,使用一个demo的classes.dex文件进行测试,运行之后发现报错,错误是找不到DexFile中的openDexFile方法,然后我们为了查找问题,就打印了DexFile类的所有方法,结果发现的确没有openDexFile(byte[]…)方法,这时候就蛋疼了,为何看源码中有个方法,但是运行却找不到呢?考虑之后发现应该是系统版本的问题,就去查看了5.0的DexFile源码,发现的确没有这个方法了,所以猜想是google把这个方法给删除了,那么这时候就蛋疼了。

5、从新整理思路继续探索

经过一刻的蛋疼之后,想一想还是开始的思路,不管google删除了这个方法,底层肯定还是会解析dex文件,加载到内存中的,那么肯定还是会有加载字节数据的方法,可能是在底层中,所以又有了灵感,去查看了4.2的源码,看看DexFile的native层源码,看到了一个和上层openDexFile做映射的函数:

Dalvik_dalvik_system_DexFile_openDexFile_bytearray,然后就想这个函数是否还存在,如果在是在哪里?我们该怎么访问他呢?所以就需要解决这两个问题:

1》我们知道Android关于VM的底层功能都在libdvm.so这个共享库中,所以可能会存在这里

2》如果存在共享库中,我们可以使用dlopen和dlsym两个系统函数获取so库中的函数指针

好了,有了这个思路,我们就去实践

6、猜想还是有一个加载字节数组的函数

在实践中,我们在java层做了一个类似于openDexFile的native方法:loadDex(byte[]…int…),然后在底层去操作,可惜的是,我们在实践中发现没有这个函数,我们的猜想错了,这时候又开始蛋疼了,怎么搞了呢?但是我们还是坚信我们的思路,肯定有一个方法存在的,这时候我们干了一件事就是可以使用dlsym函数获取一个变量指针,得到JNINativeMethod结构体指针,他是DexFile对应的所有native函数,我们打印这个结构体,结果发现了两个方法和签名,其中有一个openDexFile函数就是我们想要找的函数。

7、最终实践,成功

找到了这个函数就好办了,把这个函数和Java层的loadDex做映射,再次实践,测试程序,成功的加载了,运行也成功了。

 

六、技术概要

1、了解到了dlopen和dlsym函数的作用,使用IDA分析so中的函数,然后在使用这两个函数进行so中指定函数的调用即可

2、如何获取一个JNINativeMethod结构体中所有的native函数

3、了解了Android中的自定义类加载器的流程和步骤

 

七、总结

在之前的加固策略弄完之后,遗留的这个问题一直存在的,只是没时间弄,也都快忘了,只是最近工作中又接触到这块了,所以就开始回顾起来,必须解决了,有了内存加载dex的方案之后,之前的apk加固策略就变得比较完美了,从效率和安全性上来说更加高了。而且在开始的也说过了,现在市场中有很多加固平台,但是加固本身还是存在一定的隐私风险的,所以现在加固一般都会很慎重的。不过内存加载方案解决了,还是很爽的!!

《Android应用安全防护和逆向分析》

点击立即购买:京东  天猫

更多内容:点击这里

关注微信公众号,最新Android技术实时推送

转载请注明:尼古拉斯.赵四 » Android中apk加固完善篇之内存加载dex方案实现原理(不落地方式加载)

喜欢 (18)or分享 (0)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
(8)个小伙伴在吐槽
  1. 博主你好,小米5,运行奔溃,提示 Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 12717 (filedynamicload),目测问题应该在jni里面,因为注释掉System.loadLibrary("nativetool");程序不会出现这个问题导致奔溃.
    飞跃o2016-06-03 18:29 回复
  2. 补充一下,小米5的系统是Android6.0
    飞跃o2016-06-03 18:30 回复
  3. 求博主再写一篇文章,来分析小米5为什么会奔溃,自带小板凳坐等 :roll:
    飞跃o2016-06-03 18:31 回复
  4. 吐槽,吐槽,真的好长呀,忍不住先下来顶一下,应该花了很多时间精力。
    HelloWorld2016-06-15 22:43 回复
  5. 楼主你好,我最近在研究你写的这个动态加载,可是执行到JNI里的JNI_OnLoad函数里,调用void *ldvm = (void*) dlopen("libdvm.so", RTLD_LAZY); ldvm是NULL的,所以 dvm_dalvik_system_DexFile 也是NULL的,后面就没法继续了,请问下是什么原因呢?
    zhangYY2016-06-28 16:09 回复
  6. 5.0上运行不了,首先没有了libdvm.so,而是libart.so,其次没有openDexFile函数而是openDexFileNative而且参数不是byte数组
    STYPY2016-07-19 10:15 回复
    • 是的 ,我测试了oppo 5.0, 没有 libdvm.so
      niming2016-10-11 11:29 回复
  7. 资源文件有办法解决了么
    rnc2016-08-10 16:38 回复