Android 里比较常见的混淆方案就是 ProGuard 和 DexGuard,都是 GuardSquare 的产品,前者免费,后者收费;同时,GuardSquare 还推出了 iOS 的混淆方案 iXGuard。
我们用的方案是 ProGuard,因此这里的讨论只针对 ProGuard,源码见这里 https://sourceforge.net/projects/proguard/ 。
功能
ProGuard 为我们做了这么几项工作:
- shink(压缩);
- optimize(优化);
- obfuscate(混淆);
- preverify(预校验);
也就是说我们常说的混淆,并不仅仅指的是混淆,而是 ProGuard,而混淆只是其多个功能里的一个。在 Android 里,现在 ProGuard 的实现方式实际上有两种:
一种是传统的方式,在 proguard.pro 文件里配置规则,这种方式,更便于集中管理,这里有一点需要注意,proguardFile 是可以配置多个的,我们的插件化里,有一个公共的 proguard-rules-base.pro,是归所有模块共用的,还有一个 proguard-rules.pro,这是每个模块独有的;
另一种是引入 support-annotation 包,使用 @keep 注解,这种方式,与日常的开发结合更紧密。
shink
的目的是为了移除在 App 运行过程中,用不到的类和方法,这样可以缩减方法数,进而缩小 Apk 体积,是避免达到 65535 方法数的一个很好的措施。但是由于我们的插件化方案,对于这一步,我们就要慎之又慎,因为每个模块都是独立的 Apk,虽然不存在了 65535 的问题,但是一些会被其他模块调用但是在自身模块却不会调用的类或方法,经过 ProGuard 之后,就会被 shink 掉,此时就会有 ClassNotFountException 或 NoSuchMethodError,这种一般也比较好解,根据报错,将对应的类或方法 keep 掉即可。详细参数介绍看这里shrinkingoptions 。
optimize
可以在字节码级别对代码进行优化,默认启用,可以设置不启用。详细参数介绍可以看这里optimizationoptions 。经过优化,可能会删除一些 log 信息,或者删除被调用没有返回值的方法,或者一些常亮引用直接就使用字符串数字本身了,但是需要注意的是对于 library 里是无效的,同样它可以合并接口,通过减少类来优化。对于我们插件化来说,就也可能出现跟 shink 一样的情况,也需要注意。
obfuscate
就很好理解了,也就是我们经常说的混淆,通过设置一些字符,将我们代码里的类名、方法名、变量名替换为这些设置的字符,这些字符组合起来就是一个字典,默认的字典就是我们常常看到的 abcd 之类的,我们可以自己指定一些自定义的字典,增加反编译难度,主要是这样三个参数:-obfuscationdictionary filename
-classobfuscationdictionary filename
-packageobfuscationdictionary filename
,在 proguard.pro 里配置一下就可以了。
最终的混淆结果就像下面这样:
详细参数介绍看这里obfuscationoptions 。混淆是保护 App 很好的手段,正常情况下,我们根据编译过程中的 warning 警告就可以确定哪些类不应该被混淆,但是由于我们的插件化方案,造成了编译期的警告全都不可用了,只能通过运行期的报错信息来处理混淆。
preverify
预校验从 Java6 开始被支持,Java6 上是可选的,Java7 以上是必须的,但是 Android 上是可选的,在我们的工程里,关闭了这个选项,这也是官方推荐的,因此暂时也就没有了解。
这样之后,我们会看到,我们的主要工作主要集中在 shink、optimize 和 obfuscate 这三项上。
compile VS runtime
事实上,ProGuard 是会在编译过程中处理所有的类,因此,正常情况下,在编译过程中的 warning 信息是非常有用的,如果我们能处理掉在编译过程中所有的 warning 信息,那将会对整个 ProGuard 的工作有非常大的帮助。而且编译期的 warning 信息是非常容易解决的,根据提示将对应的类或类成员 keep 掉即可。
但是,既然这是正常情况下,就肯定有一些非正常情况,在插件化里,由于类的调用方式已经改变了,可能有大量的编译器依赖并不会被打进最终的 Apk 里,因此编译过程中的 warning 信息就几乎完全不可用了。所以,还是要从运行期入手,通过运行期的报错信息,来一点点地处理,这极其低效,然而在我的有限理解里却是最有效。
产物
经过 ProGuard 处理之后,会生成四个文件:
- dump.txt 描述apk文件中所有类文件的内部结构
- mapping.txt 列出了类、方法、成员的原貌与混淆后的映射表。在收到错误报告时,可以用它翻译混淆后的堆栈信息。参数为 -printmapping。
- seeds.txt 列出未混淆的类与成员。参数为 -printseeds。
- usage.txt 列出从apk中清除的无用代码。参数为 -printusage。
根据上面的解释,我们可以发现 mapping.txt、seeds.txt、usage.txt 对于我们的 ProGuard 工作是非常重要的。通过分析它们,我们可以扩大混淆范围,尽可能精准地确定混淆信息。
ProGuard 提供了 ReTrace 功能,也就是反混淆功能。混淆之后,堆栈信息会变得很难看懂,这时候就需要 ReTrace 出场了,通过 mapping.txt,可以还原被混淆后的堆栈信息。用起来也很简单,如下:
1 | java -jar retrace.jar [options...] mapping_file [stacktrace_file] |
工具
一些反编译工具能够帮助我们更好地完成 ProGuard 工作,通过一些措施,使反编译工具失效,也能更好地达到保护 App 的目的。
Jadx
最常用的就是 Jadx 了,可以直接反编译 apk,apk 里的二进制 xml、dex 都可以被反编译出来,如下截图:
但是 jadx 并不能反编译 jar 包和 dex 文件。
其他的诸如 dex2jar,Apktool,smali/baksmali,BytecodeViewer 等,也都不错,不过都不如 jadx 用起来简单。前三者也没有 GUI 工具,功能也不够丰富。
BytecodeViewer
值得一提的是 BytecodeViewer
:
- 它像 jadx 一样,也提供了 GUI 工具;
- 对于 Java 的反编译提供了更多种方案,理论上应该是几种工具里反编译最准确,可定制性也最强的;
- 更新也很快;
- 比 jadx 更强大的一点是,它还可以反编译 jar 和 dex;
- 而且更加难能可贵的是,它不仅提供了 class 的反编译,还可以可视化看到 smali 代码(如下图)。
但是实际上它对 apk 的支持并不好,经常会出问题。而且对于插件化方案来讲,它有一个致命的缺陷,就是不支持一个 apk 里存在多个packageId
,一旦 apk 里有多个 packageId
,工具就会 crash。
除了这些专门针对 Android 的反编译工具之外,还有一些针对 Java 的反编译工具,也可以参考一下,如 CFR、Procyon/luyten,JD-GUI 等,前两者比 JD-GUI 更准确,也更强大稳定。
源码解析
ProGuard 的源码主要分为几个部分:
- 如何读取输入的文件(readInput)?
- shink、optimize、obfuscate、preverify 如何处理文件(Android 新的编译工具 Jack 执行任务的过程已经包含了 ProGuard,说明 ProGuard 不仅能处理 class?存疑。)?
- 如何将输入的文件与 ProGuard 参数组合(Configuration)?
这里只是大致分析一下如何读取,obfuscate 如何工作,以及 ProGuard 的参数配置部分。optimize 代码量太大了,以后再慢慢看,preverify 项目里没用到,shink 要简单不少,因此这三部分就不作分析了。
readInput
先找到 ProGuard#main
方法,在这里经过一系列 check,调到 readInput()
方法,然后实例化 InputReader
,并调用 readInput(String, ClassPath, DataEntryReader)
,通过循环遍历读取 class path entry
1 | /** |
紧接着递归调用 readFiles
方法,读取每一个文件
1 | /** |
由这里,我们可以看到,关键点在 DataEntryReader
,这是个接口,有不同的实现类,这会根据传入的 file 的不同,选择不同的实现类去实例化。
以 ClassReader 为例,继续下面的分析
1 | // Implementations for DataEntryReader. |
值得一提的是,在 ProGuard 源码里用了大量的装饰器模式和访问者模式,代码阅读起来还是有一些困难。
在上面的代码里,Clazz
作为一个 Element(抽象元素)
,其实现类 LibraryClass
和 ProgramClass
是 ConcreteElement(具体元素)
,ClassVisitor
作为一个 Visitor(抽象访问者)
,其实现类 ProgramClassReader
和 LibraryClassReader
都是 ConcreteVisitor(具体访问者)
,元素调用 accept
方法(需要传入参数访问者),而在元素的 accept 方法里(元素里还有诸多其他的业务方法),又调用了访问者的 visitProgramClass(ProgramClass programClass)
或 visitLibraryClass(LibraryClass libraryClass)
方法,从而实现『双重分派』,这里实际上少了一个 ObjectStructure(对象结构)
的角色和增加删除元素的方法,对象结构是元素的集合。下面就是简单的图示:
如果是完整的访问者模式,这里需要修改一下,增加对象结构,然后编译集合,这样就可以使每一个元素都调用到访问者的 accept 方法,再加上增加元素和删除元素的方法。如果新增加元素,只需要调用增加删除元素的方法即可,而无需修改其他代码。
obfuscate
对访问者模式有一个大致了解之后,obfuscate 的代码也就不那么难读懂了。在这里 ClassPool 是元素,ClassPoolVistor 是访问者。调用 Obfuscator 的 execute 方法,在这个方法里就按照下面的步骤执行:
先去 check 一下 keep 规则、applyMapping、printMapping 是否同时为空,同时为空就抛出异常,否则继续;
清空 visitor 信息;
根据类成员的名字是否要全局对应,如果是的话,就 link 所有的类,否则就 link 所有类里的非 private 方法,具体做法是:
1
2
3
4
5
6
7
8
9
10
11ClassVisitor memberInfoLinker = configuration.useUniqueClassMemberNames ? (ClassVisitor)new AllMemberVisitor(new MethodLinker()) : (ClassVisitor)new BottomClassFilter(new MethodLinker());
programClassPool.classesAccept(memberInfoLinker);
libraryClassPool.classesAccept(memberInfoLinker);
public void classesAccept(ClassVisitor classVisitor) { //classesAccept 是 ClassPool 这个元素里的一个业务方法,而在这个方法里,又将 memberInfoLinker 作为一个访问者,通过循环遍历,处理每一个类。
Iterator iterator = classes.values().iterator();
while (iterator.hasNext()) {
Clazz clazz = (Clazz) iterator.next();
clazz.accept(classVisitor);
}
}创建标记为 seed(参照上文的 seeds.txt) 的 visitor,并标记 seed;
1
2
3
4
5
6
7
8
9
10
11NameMarker nameMarker = new NameMarker();
ClassPoolVisitor classPoolvisitor =
ClassSpecificationVisitorFactory.createClassPoolVisitor(configuration.keep,
nameMarker,
nameMarker,
false,
false,
true);
// 又是访问者模式
programClassPool.accept(classPoolvisitor);
libraryClassPool.accept(classPoolvisitor);keep library 里的 class 和 library 里的 class member;
keep 返回动态方法调用的类成员;
标记需要被 keep 的 attributes;
keep 方法的参数和参数类型;
移除被标记为丢弃的 attitutes;
applyMapping;
为所有的类指定新的名字(应用混淆字典);
为所有的类成员指定新的名字;
解决在替换名字过程中可能引起的冲突;
如果有 warning 新的话,就打印 warning 信息;
移除没有用到的常量。
这就是整个混淆的过程。以上步骤除了第一步的 check 之外,无一例外全都是通过访问者模式实现的。
常见问题(runtime)
需要重点提及的一个问题就是因为间接依赖,造成不该被混淆优化的类和类成员被混淆优化了。比如 Activity,Fragment 这种,类被打进了宿主里,其他模块引用的又是 BaseActivity 这种中间类,同时又没有通过 provided 去依赖,这样就会造成 Activity 被混淆了。实践过程中,有相当一部分问题都是这种原因造成的。我们现在的解决方案是,让所有的模块都 provided 了一份其他所有模块的 api,这样保证不会因为间接依赖而出问题,不过因为打包的时候会扫描所有的类,会造成编译速度会慢几秒钟。
java.lang.ClassNotFoundException
类找不到,keep 掉对应的类即可。
java.lang.NoSuchMethodError
方法找不到,keep 掉对应的方法即可。
Java.lang.UnsupportedOperationException: Required method xxx was not overridden
方法没被重写,keep 掉对应的方法即可。
java.lang.AbstractMethodError
接口里方法和 new 出来的接口对象里实现的方法名不一致,需要保证它们是一致的。
other
其他问题,可以先把对应的类给 keep 掉,然后再去断点调试定位哪里出了问题。
这就是我做 ProGuard 相关的工作时的一些经验。ProGuard 的工作其实是很不容易做的,这最需要耐心,很多问题有时候经验本身会显得更重要。