ydc0 2017-07-24
这是一篇关于 Android 代码保护的文章,旨在介绍代码混淆、防止逆向工程的各种高级技巧。大家都很忙,我也赶着回去继续开发我的新应用,因此话不多说,越干(gan, 一声)越好。
开始之前,值得一说的是,本文超过五千字,完全由我开发的「纯纯写作」书写而成,纯纯写作主打安全、写作体验和永不丢失内容,于是本着珍爱生命,我用纯纯写作来写这篇文章。
本文有两部分内容,一部分讲混淆,一部分介绍一些混淆之下的安全手段。基准原则都是:在保证不麻烦到自身 以及 能够正常阅读异常日志的前提下,尽可能提高混淆强度和保护代码安全。
本文原文地址:http://drakeet.me/android-advanced-proguard-and-security/
混淆
Android 官方集成了 Proguard 以供我们进行代码混淆工作,关于 Proguard 你可以搜索到各种它的 rules 解释,这些文章千篇一律,因此我不再赘述,只说一些特别的有用的技巧:
一般情况下,Android 的 gradle 中都会默认写着:
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
这一行代码很多人不了解。它的意思是,指定了两个 Proguard rules 文件,一个是通过getDefaultProguardFile() 方法获得官方自带的混淆规则文件路径,另一个是与当前 gradle 相同目录下的 proguard-rules.pro 文件路径。
后者就在我们项目中,由我们书写的,没什么好说的,我们要关注的是前者这个默认 Proguard 文件,它的内容是什么你有曾探究过吗?没有的话,你可以在你的系统文件里搜索proguard-android.txt 就应该能把它找出来,具体自己去看,我就说一些关键的,这个默认文件中帮我们声明了许多混淆规则内容,包括:keep 所有继承自 View 的类,keep 所有继承自 Activity 的类,keep 所有 JavascriptInterface、native 方法声明,以及 keep 一些注解了@Keep 的内容。
所以你知道为什么默认情况下,即使你自己一条规则都没有加入,你的自定义 View 和 Activity 都被保留下来了吧,至少类名都没有被混淆。
那么为什么官方默认会帮我们写下这些?为什么 View 和 Activity 默认情况下应该被保留呢?
简单来说,因为 Proguard 原本是为 Java 打造的,它无法搜索到我们 AndroidManifest、布局等文件中引用了哪些 Java 类,因此如果 Java 代码变了而 XML 文件中的引用没变,就会造成反射失败。所以这些被 XML 使用到的类需要 keep 住。
对于这个问题,饿了么 的团队提供了一个鲜为人知的 gradle 插件 用来无伤混淆 Activity 和 View,这个项目叫 Mess:https://github.com/eleme/Mess ,具体内容各位可以稍后自行去阅读其文档和教程,链接最后都还会附于末尾。简单来说,Mess 弥补了 Proguard 不能检索 XML 文件的缺点,帮 Proguard 完成了 Activity 和 View 的改名及 mapping。
话说回来,前面我建议各位都去逐行了解下默认混淆配置文件,因为只有这样,你才知道整个混淆工具帮你做了什么,了解清楚之后,我建议的一个做法是,把这个默认文件拷贝到你的项目目录之下,删掉 getDefaultProguardFile('proguard-android.txt'),再引入现存于你目录之下的原默认文件。这么做的好处是,方便你修改这个默认文件,因为它有些内容是不必要或者可以更改的。不过基本上我们可以保留其原样。复制过来的另一个好处是,避免其被外方更新导致你引用过来后产生变数。总之,proguardFiles 这个配置项(其实是一个 gradle 方法)可以接受无限个 rules 文件路径,它的参数是一个可变字符串参数,不过为了避免代码横向发展,我更愿意使用另一个方法,叫 proguardFile,注意,少了一个 s 有没有,它接受单个参数,相当于 add 一个 rules。对此,提供我的配置以供参考:
release { debuggable false minifyEnabled true zipAlignEnabled true shrinkResources true signingConfig signingConfigs.release proguardFile 'proguard-common.pro' proguardFile 'proguard-rules.pro' proguardFile 'proguard-rules-google-ads.pro'}
其中 proguard-common.pro 这个文件就是上述我说的复制过来的官方默认配置文件,它被我放在当前 module 目录之下和 proguard-rules.pro 并列。这么写很清楚而且便于复用。
讲完基本内容之后,我决定再介绍两条特别实用的 Proguard rules:
-repackageclasses
-repackageclasses 这条规则配置特别强大,它可以把你的代码以及所使用到的各种第三方库代码统统移动到同一个包下,可能有人知道这条配置,但仅仅知道它还不能发挥它最大的作用,默认情况下,你只要在 rules 文件中写上 -repackageclasses 这一行代码就可以了,它会把上述的代码文件都移动到根包目录下,即在 / 包之下,这样当有人反编译了你的 APK,将会在根包之下看到 成千上万 的类文件并列着,除此之外,由于我们有时不得不 keep 一些类文件,于是你应用的包名层次仍然会存在,有一些没被完全混淆的类将继续存留在你的包名之下,这些类文件就相对得不到很好的保护。于是我要介绍一个小技巧,就是 -repackageclasses 后跟上一个你应用的包名,如:
-repackageclasses com.drakeet.purewriter.debug
这么做以后,最终 Proguard 会将包括第三方库的所有类文件都移动到你的包名之下,所谓藏叶于林,这时候那些你未能完全混淆的类也可以藏身在这类文件大海之中,而且这些类文件名都会被混淆成 abcd 字母组合的名字。
需要注意的是,-repackageclasses + 你的包名 这种做法存在混淆 bug,而默认 -repackageclasses 不加包名不会出现 bug,所以初次使用此法需要进行测试,否则请退而求其次,关于这个 bug 的具体内容不多说,很赘述。
第二个实用 rules 配置项:-obfuscationdictionary
-obfuscationdictionary 后面加一个纯文本文件路径,它的作用是指定一个字典文件作为混淆字典。默认情况下我们的代码命名会被混淆成 abcdefg... 字母组合的内容,需要修改可以使用这个配置项将字典修改成乱码或中文内容。乱码命名可以令反编译者怀疑人生。中文命名则能够破坏一些反编译软件的正常工作,而且有的中文命名还能起到乱花渐欲迷人眼的效果,比如 GitHub 上较为流行的某长者的话语作为字典,在此不便贴出(可能会有人身危险),各位可以自行搜索,找不到别怪我。这些话语作为代码命名,可以令反编译者沉浸其中,无心分析代码 :P。
最后,关于混淆的内容,我们还有一块软肋,就是资源文件,Proguard 完全不会管我们的资源文件,因此如果资源文件名没有做保护的话,很容易被顺藤摸瓜找到关联的 Java 代码,对此,微信团队提供了一个好用的资源混淆工具,它不仅能帮你全面混淆资源文件,还能帮你缩减资源文件的整体体积,这个工具叫 AndResGuard,开源地址:https://github.com/shwenzhang/AndResGuard
好了,终于简单讲完了一些关于混淆的要点,关于混淆其实还有许多小内容,比如可以使用consumerProguardFiles 为一个 library 或 SDK 项目配置混淆文件,这样当某个 app 引用了你这个库,无需再配置相关混淆内容,该 app 就会自动从 consumerProguardFiles 配置的文件中读取需要进行的 keep 动作,这对于库开发者是很有用的一个功能。更多就不细说了,文章末尾我会附上我的混淆配置文件片段。
安全
有了代码混淆还不够,我们需要更多技巧来保护我们的代码,特别是对于需要做混淆但又需要暴露许多 API 的 SDK 开发者来说。混淆是基础,代码安全是意识。
首先我们要知道我们混淆代码是如何被攻破的,其实对于反编译者来说,最简单的入手点就是字符串搜索,我们硬编码留在代码里的字符串值都会在反编译过程中被原样恢复,因此这是我们首要关注对象。避免被通过字符串攻破,我们应该做到以下几点:
一,不要硬编码写入字符串值,即使你不得不这么做,也至少应该另起一个类,比如叫做HardStrings,用于静态存放这些硬编码的字符串。这样反编译者只能搜索到你这个常量类,而较难以搜索到这些字符串常量被哪里引用。
二,在 release 混淆过程中删除 Log 代码,使用 -assumenosideeffects 这个配置项可以帮我们在编译成 APK 之前把日志代码全部删掉,这么做不仅有助于提升性能,而且日志代码往往会保留很多我们的意图和许多可被反编译的字符串:
-assumenosideeffects class android.util.Log { public static boolean isLoggable(java.lang.String, int); public static int d(...); public static int w(...); public static int v(...); public static int i(...); }
三,对于你不得不留下的一些硬编码和日志内容,可以采用编码形式替换,如 你可以规定 "4001" 代表某种错误,而不是在你的代码里写入这个错误的具体描述字符串。这么做的话,你需要有个地方记下这些编码映射的内容,关于此有个技巧:你可以再创建一个常量类,其内容是一堆静态字符串对象,针对上面那个例子,你可以把真正的错误信息作为一个字符串变量的名字,而把它的值写成一个编码,如下:
public static final String SHOULD_REGISTER_FIRST_ERROR = "ssrrffe";
这样当你在看没混淆的代码引用这个静态变量,你能够一目了然它的意思。而反编译者看到的则是:
public static final String abc = "ssrrffe";
命名看不懂,值也看不懂。
四,把 AppKey 之类特别敏感的字符串内容藏在 native so 文件中。
关于字符串技巧的内容差不多就这样了,能做到这些就不错了,还有一些极端做法不多说,为了阻碍黑客阅读,自己也变得非常麻烦,双刃剑,这不是我们想要的结果。
然后我们讲另一个混淆后代码的软肋,就是一些我们不得不 keep 的内容,如果是闭源 SDK 开发者,需要 keep 的内容将会更多,几乎只要是 public 的类、变量,方法,全部要 keep,那么针对这个问题,我们该怎么办?介绍一个方法:
给这些需要 keep 的内容设置委托者,然后将委托者投入大海之中。
很玄乎吧?哈哈,这么讲有助于记忆。其实和我们在混淆章节说的藏叶于林的思想是一样的。如果一个类不得不 keep,那就把它所做的全部内容都转交给一个 private 或 internal 的类对象去完成,这个委托类对象代码可以完全混淆,然后你再把这个委托类通过混淆工具藏在大量的代码之中,这样就足够给反编译者带来了很大的麻烦,相比直接获取逻辑代码,这么做以后要找到实体的逻辑代码将费劲得多。
因此,如果你知道有这么一个方式,其实你完全可以不使用饿了么提供的那个 Activity 和 View 混淆工具,也能很好地保护你的 Activity 和 View。
不过一般情况我们无需所有内容都保护,只要把关键、核心内容委托出去就可以了。
最后的最后,我们还需要做的就是防止反编译者重新打包,全方位绝人之路呀,能做的就是在代码中加入签名验证,并做双向依赖。关于此我写过一个类似阿里黑匣子的东西,能够在 native 检查签名和加解密内容,后续也有计划整理开源,这里暂且就不多说了。