3

Android APK加固完善篇

 2 years ago
source link: http://www.androidchina.net/8415.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Android APK加固完善篇 – Android开发中文站

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

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

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

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

Center

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

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

Center

这个就是把源程序的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

Center

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

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

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

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

Center

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

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

Center

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

查看他的构造方法:

Center

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

Center

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

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

Center

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

Center

在进入看构造方法:

Center

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

Center

擦,原来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类:

Center

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

Center

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

Center

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

Center

好吧,这里最后调用了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:

Center

也是一个native方法

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

具体实现步骤如下:

Center

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

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

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

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

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

Center

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

Center

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

Center

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

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

Center

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

Center

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

在来看一下findClass方法:

Center

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

Center

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

Center

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

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

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

Center

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

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

Center

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

Center

再次运行看看结果:

Center

我擦,怎么只有一个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

Center

这里的源码还是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

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

Center

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

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

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

Center

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

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

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

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

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

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

Center

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

Center

这里很多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);

Center

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

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

Center

这里有一行重要的代码:

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函数来做这个事情:

Center

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

Center

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

Dalvik_dalvik_system_DexFile_openDexFile_bytearray

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

Center

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

Center

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

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

Center

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

Center

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

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

Center

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

Center

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

Center

擦擦擦,成功了,哈哈,好兴奋呀。。。我们成功的实现了内存加载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 APK加固完善篇


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK