一、前言
在前两篇破解的文章中,我们介绍了如何使用动态调试来破解apk,一个是通过调试smali源码,一个是通过调试so代码来进行代码的跟踪破解,那么今天我们就这两篇文章的破解方法,来看看Android中开发应用的过程中如何对我们的应用做一层安全保护,当然现在市场中大部分的应用已经做了一些防护策略,但是没有绝对的安全,破解只是时间上的问题。所以攻破和防护是相生相克,永不停息的战争,没有绝对的安全,也没有万能的破解之道。
下面我们就来看看如何做到我们的应用更安全,我们主要从这五个方面来看看怎么操作:
1、混淆策略
2、应用的签名
3、修改Native函数名
4、反调试异常检测
5、应用的加固策略
当然还有其他防护方法,我们今天就介绍这五种,后续还有的话,继续补充
二、技术原理
第一种方式:混淆策略
混淆策略是每个应用必须增加的一种防护策略,同时他不仅是为了防护,也是为了减小应用安装包的大小,所以他是每个应用发版之前必须要添加的一项功能,现在混淆策略一般有两种:
1、对代码的混淆
我们在反编译apk之后,看到的代码类名,方法名,已经代码格式看起来不像正常的Android项目代码,那么这时候就会增加阅读难度,增加破解难度,像这样的代码混淆:
我们一般现在的破解查看Java层代码就是两种方式:
一种是直接先解压classes.dex文件出来,使用dex2jar工具转化成jar文件,然后再用jd-gui工具进行查看类结构
一种是使用apktool工具直接反编译apk,得到smali源码,阅读smali源码
不过这种代码混淆有时候在一定程度上能够增加混淆策略,但是有时候也不是很安全,因为我们知道我们在破解的过程中一般是找程序的入口,那么这些入口一般都是Application或者是MainActivity之类的,但是这些Android中的组件类是不能进行混淆的,所以我们还是有入口可寻,能够找到入口代码,然后进行跟踪。
2、对工程资源的混淆
我们上面说到了对代码的混淆能够增加一定的代码阅读难度,有时候我们为了防止资源的保护也是可以做混淆的,这个资源混淆原理这里就不多解释了,微信团队已经将这个功能开源,不了解的同学可以转战github查看:
https://github.com/shwenzhang/AndResGuard
当然资源混淆还有一个很大的好处就是减小apk包的大小,当然这个不是本文讨论的知识点,这里我们讨论的是混淆资源增加破解查找资源的难度,先来看一下混淆资源之后的结果:
这里我们可以看到,一个混淆资源的应用,反编译之后查看他的string.xml内容,发现他的name全是简单的混淆字母,那么这个对于我们之前的那种可以通过name的值,来查找对应的字符串内容来获取消息,这个将是很蛋疼的一件事,因为你这时候如果全局搜索一个name值的话,比如这里的name='a',那么得搜出多少个这样的name,查找也是很好时间的,其实在没有混淆之前,一般string中的name都是比较唯一的一种值,查找的话不会有那么多查找结果,而且查找时间也是很短的。
破解之道:
但是对于这种混淆资源也是绝对的防护安全,因为我们知道一般在反编译之后的Java代码中,看到的获取资源值的时候,并不是资源的name值了,而是资源对应的int类型的值,比如这样:
这里获取一个字符串的值,那么,这些int类型的值,我们可以在反编译之后的res/values/pulblic.xml中找到:
比如这里的2131230929变成16进制就是:0x0x7f0800d1,我们在public.xml中查找,找到了name='ey‘的一项,然后再去string.xml中进行name查找:
好吧,还是找到了这个字符串的值,反编译之后的public.xml中记录了所有资源的id和整型值对应值,混淆之后的代码中看到的都是资源id的整型值,那么这么一看混淆并没有什么用途。只能偏偏小白了。
从上面的两处混淆策略看到,混淆对于破解并没有什么太大的阻碍,也是只是一个障眼法,不过混淆的另外一个功能就是减少apk包的大小,这个也是每个应用添加混淆的最主要原因。
第二种方式:应用的签名
我们知道Android中的每个应用都是有一个唯一的签名,如果一个应用没有被签名是不允许安装到设备中的,一般我们在运行debug程序的时候也是有默认的签名文件的,只是IDE帮我们做了签名工作,一般在应用发版的时候会用唯一的签名文件进行签名,那么我们在以往的破解中可以看到,我们有时候需要在反编译应用之后,然后从新签名在打包运行,这个又给了很多二次打包团队谋取利益的一种手段,就是反编译市场中的包,然后添加一些广告代码,最后使用自家的签名在此从新打包发布到市场中,因为签名在反编译之后是获取不到的,所以只能用自己的签名文件去签名,但是在已经安装了应用设备再去安装一个签名不一致的应用也是安装失败的,这样也有一个问题就是有些用户安装了这些二次打包的应用之后,无法再安装正规的应用了,只有卸载重装。那么这时候我们可以利用应用的签名是唯一的特性做一层防护。
我们为了防止应用被二次打包,或者是需要破解我们的apk的操作,在入口处添加签名验证,如果发现应用的签名不正确就立即退出程序,我们可以在应用启动的时候获取应用的签名值,然后和正规的签名值作比对,如果不符合就直接退成程序即可,这里我们做一个简单的案例测试一下:
这里定义一个简单的工具类用于比较应用的签名,这里只是简单处理,正常情况下这里应该比对签名的MD5值,这里为了简单就忽略了,然后我们在程序的入口处做一次比对,如果不正确就退出程序:
那么我们得到上面的apk之后,下面来反编译,然后从新签名安装(关于这里如何反编译和签名,不做解释了,使用apktool和signapk工具即可,签名文件是自己的),然后运行:
发现程序根本运行不起来,一点击就闪退,这里就做到了防止应用被二次签名打包的安全问题策略了。
破解之道:
但是这个也不是最安全的,因为我们知道,既然有签名比对方法的地方,那么我只需要反编译apk之后,修改smali语法,把这个方法调用的地方注释即可:
只需要使用#把这行代码注释,然后回编译从新打包安装即可。所以这种方式也是只能欺骗一下小白,不过这里需要注意的是,如何找到这个检测签名的方法的地方还是最关键的,比如有的程序在native层做的,但是不管在哪里,只要是在代码中,我们就可以找出来的。
第三种方式:修改Naitve函数名
这个方法其实不太常用,因为他的安全措施不是很强大的,但是也是可以起到一定的障眼法策略,在说这个知识点的时候,我们先来了解一下so加载的流程:
在Android中,当程序在java层运行System.loadLibrary("jnitest");这行代码后,程序会去载入libjnitest.so文件,与此同时,产生一个"Load"事件,这个事件触发后,程序默认会在载入的.so文件的函数列表中查找JNI_OnLoad函数并执行,与"Load"事件相对,当载入的.so文件被卸载时,“Unload”事件被触发,此时,程序默认会去在载入的.so文件的函数列表中查找JNI_OnUnload函数并执行,然后卸载.so文件。需要注意的是,JNI_OnLoad与JNI_OnUnload这两个函数在.so组件中并不是强制要求的,用户也可以不去实现,java代码一样可以调用到C组件中的函数,之所以在C组件中去实现这两个函数(特别是JNI_OnLoad函数),往往是做一个初始化工作或“善后”工作。可以这样认为,将JNI_ONLoad看成是.so组件的初始化函数,当其第一次被装载时被执行(window下的dll文件也可类似的机制,在_DLL_Main()函数中,通过一个swith case语句来识别当前是载入还是卸载)。将JNI_OnUnload函数看成是析构函数,当其被卸载时被调用。由此看来,就不难明白为什么很多jni C组件中会实现JNI_OnLoad这个函数了。 一般情况下,在C组件中的JNI_OnLoad函数用来实现给VM注册接口,以方便VM可以快速的找到Java代码需要调用的C函数。(此外,JNI_OnLoad函数还有另外一个功能,那就是告诉VM此C组件使用那一个JNI版本,如果未实现JNI_OnLoad函数,则默认是JNI 1.1版本)。
应用层的Java类别通过VM而调用到native函数。一般是通过VM去寻找*.so里的native函数。如果需要连续呼叫很多次,每次都需要寻找一遍,会多花许多时间。此时,C组件开发者可以将本地函数向VM进行注册,以便能加快后续调用native函数的效率.可以这么想象一下,假设VM内部一个native函数链表,初始时是空的,在未显式注册之前此native函数链表是空的,每次java调用native函数之前会首先在此链表中查找需要查找需要调用的native函数,如果找到就直接使用,如果未找到,得再通过载入的.so文件中的函数列表中去查找,且每次java调用native函数都是进行这样的流程,因此,效率就自然会下降,为了克服这样现象,我们可以通过在.so文件载入初始化时,即JNI_OnLoad函数中,先行将native函数注册到VM的native函数链表中去,这样一来,后续每次java调用native函数时都会在VM中的native函数链表中找到对应的函数,从而加快速度
通过上面的分析之后,我们知道原来我们知道so文件加载和卸载的时机,同时我们可以显示的手动注册我们自己的native方法,那么我们知道一般我们在定义native方法的时候,对应的native层的函数名是:Java_类名_方法名 这种样式:
所以就有两个问题:
第一个问题就是我们在IDA工具查看so文件的时候,去找到对应的native方法非常容易,以为我们知道了Java层的native方法名和类型,那么直接可以定位到这个native函数:
第二问题就是恶意破解人可以得到这个so文件之后,查看这个native方法的参数和返回类型也就是方法签名,然后自己在Java层写一个demo程序,然后构造一个和so文件中对应的native方法,然后就可以执行这个native方法,如果我们有一个校验密码或者是获取密码的方法是个native的,那么这时候就会很容易的被恶意人执行方法后获取结果。
说的简单点就比如上面的这个isEquals例子:
现在有一个人,想执行这个我的应用的isEquals方法,那么他只需要解压我的apk,得到so文件,查看so文件中的函数,或者是查看上层的Java代码,得到这个方法的返回值和签名,然后他就可以编写一个简单的程序,构造一个类:
cn.wjdainkong.encryptdemo.MainActivity
然后在他内部定义一个native方法:
public native boolean isEquals(String str);
然后在使用System.loadLibrary加载我的so文件,然后在适当的地方执行isEquals方法,这样就等于调用了我的so文件中的isEquals方法了。
所以从上面的两个为可以看到,如果我们native层的函数遵从这样的格式,无疑是给破解者简单的一种方式,所以我们可以这么做,就是显示的注册我们的JNI方法,只需要在我们native层的代码中调用这三个函数即可:
第一个函数:(*env)->RegisterNatives(env,clazz, methods, methodsLenght)
这个函数就是手动的注册一个native方法,这个函数是属于JNIEnv*的,参数也比较简单
1》clazz就是,需要注册native方法的那个类,是jclass类型,这个我们可以使用JNIEnv的FindClass方法,传递类的名称即可获取这个对象,类似于这样:
2》methods是一个结构体,定义如下:
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
第一个变量name是Java中函数的名字。
第二个变量signature,用字符串是描述了函数的参数和返回值
第三个变量fnPtr是函数指针,指向C函数。
类似于这样的结构:
第二个函数:jint JNI_OnLoad(JavaVM* vm, void* reserved)
这个函数就是上面说到的,so被加载的时候被调用到,同时我们可以看到这里还可以获取JVM参数的,一般在这个函数中主要就是执行上面的注册函数功能,同时这里还需要获取一个JNIEnv*变量:
这里通过JVM来获取JNIEnv变量,然后调用注册函数:
实现手动的注册函数
第三个函数:void JNI_OnUnload(JavaVM* vm, void* reserved)
这个函数和JNI_OnLoad是相对应的,是在so被卸载的时候调用
通过上面的三个函数我们就可以手动的显示注册我们的native函数方法了,那么我们同时就可以修改native层的函数名,不要按照之前的那种格式了,增加破解者寻找关键的native层函数的难度:
这里我们把isEquals函数名变成了jiangwei:
然后在修改注册方法的结构体:
编译运行,在使用IDA查看:
这时候破解者不能按照常规的套路,找到了native层的函数了,那么上面的两个问题就可以避免了。增加安全性
破解之道:
但是问题来了,现在的破解者,一般打开SO文件的时候,如果找不到对应的native方法之后,就会去找JNI_OnLoad函数,然后在通过分析arm汇编代码,找到register函数,分析注册方法结构体,找到对应的native方法,那么这种方式还是不靠谱,也是只能糊弄一下小白破解者。不过我们通过这个例子也可以得知,在JNI_OnLoad中可以做很多事的,比如上面说到的签名机制校验,我们也可以在JNI_OnLoad中做一次,增加安全性:
看看equal_sign函数功能:
在这个方法中,其实用我们用JNIEnv变量调用了Java层的方法,来获取应用的签名,然后进行比对的
所以我们用这种签名校验方式来做安全性保证也是一个思路至少native层的代码分析比smali代码分析难度大点,而且这种签名校验机制必须用静态方式去破解apk,也就是通过分析代码来破解,因为程序没有运行起来无法通过动态方式破解的。那么应对与静态方式破解的话,我们只能增加代码的阅读难度了。
第四种方式:反调试异常检测
这种方式其实是为了应对现在很多破解者使用IDA进行动态方式调试so文件,从而获取重要的信息,如果还不知道如何使用IDA进行动态调试so文件的同学可以查看这篇文章:Android中使用IDA进行动态调试so文件 ,看完这篇文章之后,我们可以知道IDA进行so动态调试是基于进程的注入技术,然后使用Linux中的ptrace机制,进行调试目标进程的,那么ptrace机制有一个特点,就是如果一个进程被调试了,在他进程的status文件中有一个字段TracerPid会记录调试者的进程id值,比如:
查看文件:/proc/[myPid]/status
在第六行,有一个TracerPid字段,就是记录了调试者的进程id
那么我们就可以这么做来达到反调试的功效了,就是我们可以轮训的遍历自己进程的status文件,然后读取TracerPid字段值,如果发现他大于0,那么就代表着自己的应用在被人调试,所以就立马退出程序。原理知道了,代码实现也很简单,这里用pthread创建一个线程,然后进行轮训操作:
使用pthread_create创建一个线程,线程启动之后执行thread_function函数
看看thread_funcation函数:
开始轮训,读取TracerPid字段的值,发现大于0,就立马退出程序,我们运行结果看看:
看到了,当我们使用IDA工具进行调试的时候,程序立马退出,同时IDA的调试页面也退出了。
所以这里我们看到这种轮训机制来实现反调试策略,可以应对与一般的破解小白了。
破解之道:
但是还是有问题,因为现在破解者们,他们已经免疫了,知道会有这种检测,所以就会用IDA工具给JNI_OnLoad函数下断点,然后进行调试,找到检测轮训代码,使用nop指令,替换检测指令,就相当于把检测代码给注释了,功能的夭折,所以这种反调试方法还是不好使,知道的人多了,也没什么意义了,但是有总比没有的好。
第五种方式:应用的加固策略
关于这种方式,那就是现在很多应用都用的一种方式了,也是安全性最高的一种防护了,他加固主要有三方面:
1、对dex文件进行加密
这样我们用dex2jar工具,或者apktools等工具反编译失败,关于这个dex加密这里,也不做太多的介绍了,之前有一篇文章已经介绍了dex加固的原理了:Android中apk加固原理解析
破解之道:
但是可惜的是,这种方式也沦陷了,因为我们知道,不管你dex怎么加密,最后都是需要用DVM加载dex文件到内存中的,我们知道Android中所有关于DVM的函数都是在libdvm.so文件中的,而且这个文件是存在设备的/system/lib目录中的,加载dex有一个重要的函数:
int dvmDexFileOpenPartial(const void* addr, int len, DvmDex** ppDvmDex)
第一个参数就是dex内存起始地址
第二个参数就是dex大小。所以在这个函数下断点可以直接dump出明文dex
所以我们使用IDA调试程序,找到模块libdvm.so的内存地址,找到其中的函数dvmdexfileopenpartialPKviPP6DvmDex,在这个函数下断点即可dump处dex文件了。
关于这个案例,后续还会写一篇文章来介绍如何破解现在加固dex的应用。
2、对so文件进行加密
现在很多应用把中号的功能都放到了native层中,那么如果我们队so文件进行加密的话,那么IDA工具就无法打开so文件,从而做到安全保护,关于Android中so加密可以参考这篇文章:Android中对so加固原理解析
破解之道:
但是可惜的是,这种方式也被沦陷了,看完这篇文章之后,我们知道加固so的有一个特点就是你必须在so在被调用的时候需要进行解密,不然会影响正常的native层调用,那么这个时机很重要,一般都是在so文件的入口处,也就是用:
__attribute__((constructor)) 这个属性来标注一个解密函数,这样就能保证解密函数执行的时机比任何一个函数时机都早,或者可以理解为是一个类的构造函数的执行时机即可,但是一般有这种属性的函数都在so的.inin_array段中的:
那么现在的问题就变成了,如果我知道了.init_array端的位置,然后在查看这个段中的arm指令代码,就可以得到解密函数了,然后在解读这个解密函数的逻辑即可。那么最后就要看这个加密函数的难度程度怎么样了。
关于这个案例,后续还会写一篇文章来介绍如何破解现在加固so的应用。
3、加固资源文件和AndroidManifest.xml文件
这个加固一般是应对与现在最流行的反编译工具apktool了,他是开源的,比如下面的这个应用:
所以看到了,这种加固就是利用apktool工具的漏洞来进行加固的,不过这个apktool工具也是实时在更新的,也是为了解决现在的apk这种资源文件的加固导致反编译失败的问题。
破解之道:
所以对于这种反编译失败的问题,我们应该自己编译apktool的源码,找到指定的保存位置,然后修改异常即可,不过这个可不是一个简单的工作,是需要耐心和经验的。
项目下载:http://download.csdn.net/detail/jiangwei0910410003/9534543
三、安全工作流程
1、为了应对与低级破解小白,同时也是为了减小apk包的大小,我们会对代码和资源的一个混淆,增加破解难度
2、为了应对与初级破解小白,我们将手动的注册我们的native方法,让破解者找不到对应的native方法,同时解决一些重要的native方法的被调用问题,增加破解难度
3、为了应对与中级破解小白,我们会利用应用的签名,来防止应用的二次签名打包,同时防止动态调试问题,增加破解难度
4、为了应对与高级破解小白,我们会增加应用的反调试功能,来防止应用被动态调试和进程注入问题,增加破解难度
5、为了应对与资深破解小白,我们会采用应用的加固策略,对dex,so,资源文件进行加固,增加反编译工作了和调试难度
四、总结
通过这篇文章我们看到,介绍了几种安全防护应用的方法,但是我们在每个方法后面也都介绍了破解者如何应对与这种方法,所以说这里说的这些安全防护都是可以被破解的,只是时间问题,随着时间的推移,我们看到没有绝对的安全,也没有统一的破解之道,只有一个安全策略出来了,破解之道也就相对应出来的,这样相生相克,彼此进步。但是个人感觉破解还是要大于防护的,因为破解是逆向思维,这种要求会更高点,特别是对于那种变态的加密算法的破解和逆向,尤其蛋疼。最后也希望通过这篇文章能够让你们了解到Android中的破解不是那么容易的,安全也不是那么容易的。两者都在进步,我们也要进步!