一、前言
时隔半年,困扰的问题始终是需要解决的,之前也算是没时间弄,今天因为有人在此提起这个问题,那么就不能不解决了,这里写一篇文章记录一下吧。那么是什么问题呢?
就是关于之前的一个话题: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技术实时推送