ProGuard 简介
ProGuard是 2002 年由比利时程序员 Eric Lafortune 发布的一款优秀的开源代码优化、混淆工具,适用于 Java 和 Android 应用,目标是让程序更小,运行更快,在 Java 界处于垄断地位。
主要分为三个模块:Shrinker(压缩器)、Optimizer(优化器)、Obfuscator(混淆器)、Retrace(堆栈反混淆)。
Shrinker 通过引用标记算法,将没用到的代码移除掉。
Optimizer 通过复杂的算法(Partial Evaluation &Peephole optimization,这部分算法我们不再展开介绍)对字节码进行优化,代码优化会使部分代码块的结构出现变动。
举几个例子:
某个非静态方法内部没有使用
this
没有继承关系,这个方法就可以改为静态方法。某个方法(代码不是很长)只被调用一次,这个方法就可以被内联。
方法中的参数没有使用到,这个参数可以被移除掉。
局部变量重分配,比如在 if 外面初始化了一个变量,但是这个变量只在 if 内部用到,这样就可以将变量移动的 if 内部去。
Obfuscator 通过一个混淆名称发生器产生 a、b、c 的毫无意义名称来替换原来正常的名称,增加逆向的难度。
Retrace 经过 ProGuard 处理后的字节码运行的堆栈已经跟没有处理之前的不一样了,除了出现名称上的变化还伴随着逻辑上的变化,程序崩溃后,开发者需要借助 Retrace 将错误堆栈恢复为没有经过 ProGuard 处理的样子。
背景
在我们实施插件化、热补丁修复时,为了让插件、补丁和原来的宿主兼容,必须依赖 ProGuard 的 applymapping 功能的进行增量混淆,但在使用 ProGuard 的 applymapping 时会遇到部分方法混淆错乱的问题,同时在 ProGuard 的日志里有这些警告信息Warning: ... is not being kept as ..., but remapped to ...
,针对这个问题我们进行了深入的研究,并找到了解决的方案,本文会对这个问题产生的缘由以及修复方案一一介绍。
现象
下面是在使用-applymapping
之后 ProGuard 输出的警告信息,同时我们发现在使用-applymapping
得到的混淆结果中这些方法的名称都和原来宿主混淆结果的名称不一致的现象,导致使用-applymapping
后的结果和宿主不兼容。
applymaping 前后的映射关系变化
stop 方法作为一个公用方法存在的宿主中,而子模块依赖于宿主中的 stop 方法。子模块升级之后依然依赖宿主的接口、公共方法,这要确保 stop 方法在子模块升级前后是一致的。当使用-applymapping
进行增量编译时 stop 由 b 映射为 c_。升子模块依赖的 stop 方法不兼容,造成子模块无法升级。
了解一下 mapping
mapping.txt 是代码混淆阶段输出产物。
mapping 的用途
retrace 使用 mapping 文件和 stacktrace 进行 ProGuard 前的堆栈还原。
使用
-applymapping
配合 mapping 文件进行增量混淆。
mapping 的组成
以->
为分界线,表示原始名称->新名称
。
类映射,特征:映射以
:
结束。字段映射,特征:映射中没有
()
。方法映射,特征:映射中有
()
,并且左侧的拥有两个数字,代表方法体的行号范围。内联,特征:与方法映射相比,多了两个行号范围,右侧的行号表示原始代码行,左侧表示新的行号。
闭包,特征:只有三个行号,它与内联成对出现。
注释,特征:以
#
开头,通常不会出现在 mapping 中。
一段与-applymapping
出错有关的 mapping
GifFrameLoader 映射为 g。在代码里面,每个类、类成员只有一个新的映射名称,其中 stop 出现了两次不同的映射。为什么会出现两次不同的映射?这两次不同的映射对增量混淆有影响吗?
ProGuard文档对于这个问题没有给出具体的原因和可靠的解决方案,在-applymapping
一节提到如果代码发生结构性变化可能会输出上面的警告,建议使用-useuniqueclassmembernames
参数来降低冲突的风险,这个参数并不能解决这个问题。
为了解决这个问题,我们决定探究一下 ProGuard 源码来看下为什么会出现这个问题,如何修复这个问题?
从源码中寻找答案
先看一下 ProGuard 怎么表示一个方法:
Class Detail
ProGuard 对 Class 输入分为两类,一类是 ProgramClass,另一类是 LibraryClass。前者包含我们编写代码、第三方的 SDK,而后者通常是系统库,不需要编译到程序中,比如引用的 android.jar、rt.jar。
ProgramMember 是一个抽象类,拥有 ProgramField 和 ProgramMethod 两个子类,分别表示字段和方法,抽象类内部拥有一个 Object visitorInfo 的成员,这个字段存放的是混淆后的名称。
代码混淆
代码混淆可以认为是一个为类、方法、字段重命名的过程,可以使用-applymapping
参数进行增量混淆。使用-applymapping
参数时的过程可简略的分为 mapping 复用、名称混淆、混淆后名称冲突处理三部分。
流程简化后如下图(左右两个大虚线框代表了对单个类的两次处理,分别是名称混淆和冲突处理):
混淆简略流程
只有使用-applymapping
参数时 MappingKeeper 才会执行,否则跳过该步骤。
1. MappingKeeper
它的作用就是复用上次的 mapping 映射,让 ProgramMember 的 visitorInfo 恢复到上次混淆的状态。
如果是新加方法,visitorInfo 为 null。
如果一个方法存在多份映射,新出现的映射会覆盖旧的映射并输出警告
Warning: ... is not being kept as ..., but remapped to
。
2. 混淆处理
混淆以类为单位,可以分为两部分,第一部分是收集映射关系,第二部分是名称混淆。判断是否存在映射关系,如果不存在的话分配一个新名称。
第一部分:映射名称收集
MemberNameCollector 收集 ProgramMember 的 visitorInfo,并把相同描述符的方法或字段放入同一个 map<混淆后名称,原始名称>
。
如果 visitorInfo 出现相同名称,map 中的键值对会被后出现的方法(以在 Class 中的顺序为准)覆盖,可能会导致错误映射覆盖正确映射。
第二部分:名称混淆
如果 visitorInfo 为 null 的话为 member 分配新名称,第一部分收集的 map 来确保 NameFactory 产生的新名称不会跟现有的冲突,nextName()
这个里面有个计数器,每次产生新名称都自加,这就是出现 a、b、c 的原因。这一步只会保证 map 里面出现映射与新产生的映射不会出现冲突。
3. 混淆名称冲突的处理
混淆冲突处理的第一步同混淆的第一步,先收集 ProgramMember 的 visitorInfo,此时 map 跟混淆处理过程的状态一样。
冲突的判断代码:
取出当前 ProgramMethod 中的 visitorInfo,用这个 visitorInfo 作为 key 到 map 里面取 value,如果 value 跟当前的 ProgramMethod 不相同话,说明 value 覆盖了 ProgramMethod 映射,认为当前 ProgramMethod 映射与 map 中的映射冲突,当前的映射关系失效,把 visitorInfo 设为 null,然后再次调用 MemberObfuscator 为 ProgramMethod 产生一个新名称,NameFactory 会为新名称加入一个_
作为后缀,这样会出现某一些方法混淆出现下划线。
4. 最终的代码输出
代码优化之后不再对字节码进行修改,上面的主要是为类、类成员的名称进行映射关系分配以及映射冲突的处理,
当冲突解决完之后才会输出 mapping.txt、修改字节码、引用修复、生成 output.jar。
5. 关于 mapping 的生成
在 mapping 生成过程中,除了生成类、方法、字段的映射关系,还记录了方法的内联的信息。
第一行表示:从右边的代码范围偏移到左侧的范围(方法 c 中的 2077-2087 行来自 stop 方法的),第二行表示偏移来的代码最终的位置(81 行的方法调用修改为 2077-2078 行代码)。这两行并不是普通的映射。
代码优化
刚才我们讲了,mapping 里面有一段内联信息,现在看为什么 mapping 里面出现一段看起来跟混淆无关的内联。
上文讲到,mapping 里面存在一段内联信息,之所以 mapping 里面出现一段看起来跟混淆无关的内联,这是因为 javac 在代码编译过程中并没有做太多的代码优化,只做了一些很简单的优化,比如字符串链接 str1+str2+str3 会优化为 StringBuilder,减少了对象分配。
当引入的大量代码、库以及某些废弃的代码依然停留在仓库时,这些冗余的代码占用大量的磁盘、网络、内存。ProGuard 代码优化可以解决这些问题,移除没有使用到的代码、优化指令、逻辑,以及方法内部的局部变量分配和内联,让程序运行的更快、占用磁盘、内存更低。
内联:在编译期间的调用内联的方法进行展开,减少方法调次数,消耗更少的 CPU。但是 Java 中没有inline
这个关键字,ProGuard 又是怎么对方法做的内联呢?
内联
在代码优化过程中,对某一些方法进行内联(将被内联的方法体内容 Copy 到调用方调用被内联方法处,是一个代码展开的过程),修改了调用方的代码结构,所以被内联的方法 Copy 到调用方时需要考虑带来的副作用。当 Copy 来的代码发生崩溃时,Java stacktrace 无法体现真实的崩溃堆栈和方法调用关系,它受调用方自身代码和内联 Copy 的代码相互影响。
内联主要分为两类:unique method 和 short method,前者被调用并且只被调用一次,而后者被调用多次可能,但是这个方法code_length小于 8(并非代码行数)。满足这两种的方法才可能被内联。
以 clear 调用 stop 为例,如下图:
内联
在 clear 的 81 行调用 stop,发生内联,stop 的方法内容复制到 81 行处,很明显不可以使用之前的 77-78 行,在 81 行后的新代码从原来的 77-78 偏移为 2077-2078。内联信息对 retrace 有用:
当内联处发生崩溃,根据 2077-2078 确定是 stop 方法发生崩溃,而 stop 实际 clear 的 81 行调用,根据 2077-2078 的偏移还原原始的堆栈应该是:clear 方法 81 行调用 stop 方法(77-78 行)发生崩溃。
行号的规则简化后如下:
(被内联方法的代码行数+1000 后/1000)x1000x 内联发生的次数+offset,offset 为被内联的起始行号。
Copy 的代码最低行号为 1000+起始行号,如果行数大于 1k 的话取整之后+起始行号。
对于被内联的方法还存在吗?
这个是不一定,可能不存在,也可能存在,如果存在的话 mapping 就会出现对此方法映射。如果被内联之后不会有其他方法调用这个方法不存在,但是该方法如果是因为继承关系(子类继承父类),这种方法通常存在。
整个流程是这样的
这几个模块并不是没关联的,接下来把整个流程串起来。
ProGuard 流程图
1. 初始化
ProGuard 初始化会读取我们配置的 proguard-rule.txt 和各种输入类以及依赖的类库,输入的类被 ClassPool 统一管理,我们的 rule.txt 配置了 keep 类的条件,ProGuard 会根据 keep 规则和输入 Classes 确定最终需要被 keep 的类信息列表,这一份列表就是所谓的 seeds.txt(种子),以后所有的操作(混淆、压缩、优化)都已 seeds 为基准,没有被 seeds 引用的代码都可以移除掉。
2. shrink
这部通过引用标记算法,如果没有被用到的类、类成员支持从 ClassPool 移除掉,只有第一次调用 shrink 才会产生 usage.txt 记录了移除掉的类、方法、字段。
3. optimize
代码优化做的事情比较复杂,这一部分对类进行优化,包括优化逻辑、变量分配、死代码移除,移除方法中没用的参数、优化指令、以及方法的内联,我们知道内联发生了代码 Copy,被 Copy 的代码不会被当前方法调用。代码优化完之后会重新执行一次 shrink,对于被内联的方法可能真的没有引用,这样就会被移除,但是如果被内联的方法继承关系,这种就要保留。
4. obfuscate
混淆以类为单位,为类、类成员分配名称,处理冲突名称,输出 mapping 文件,之后会输出一份经过优化、混淆后的 jar。如果使用`-applymapping 参数进行增量编译会从 mapping 里面获取映射关系,找不到映射关系才会为方法、字段分配新名称。mapping 文件记录了两类信息:第一类是普通的映射关系,第二类就是内联关系(这部分源于 optimize,跟混淆并没有直接关系),对于 retrace 这两类信息都需要,但是对于增量混淆只需要映射关系。
再次回到 mapping 文件
MappingKeeper 读取 mapping 发生了什么错误?
在执行混淆时,MappingKeeper 会把 mapping 中存在的映射关系为 ProgramMethod 的 visitorInfo 赋值,但是没有区分普通映射还是内联,虽然 stop 方法最初被正确的赋值为 b,但是因为内联接下来被错误的赋值为 c,此时 clear 的 visitorInfo 也是 c。
map 状态
当进入 MemberNameCollector 收集映射关系。stop 和 clear 方法对应的 visitorInfo 都是 c。因为 stop 方法排序位于 clear 之后。虽然 stop 方法的映射被搜集了,但收集到 clear 之后会把 stop 的映射覆盖掉,此时 map 里面已经没有了 stop 的映射,如左上图。如果 stop 方法 visitorInfo 并没有被覆盖此时状态如右上图。
进入解决冲突环节
stop 的 visitorInfo 为 c,根据 map 里面的 c 取到为 clear,认为 stop 跟 map 里面的映射存在冲突,把 stop 的 visitorInfo 设为 null,然后重新为 stop 分为一个带有下划线的名称。
假设 clear 的描述符不是 void 类型并且被混淆为 f 那么 map 的状态如下图:
map 状态
因为内联stop()->f
的干扰,map 中 stop 的 visitorInfo 由 b 变为 f,但是名称为 f 的这个方法并不与其他返回值为 void 类型、参数为空的方法的 visitorInfo 存在冲突。这个情况就跟文章开头例子里提到的另一个方法 transform 一样虽然错乱了,但是并不会出现下划线。
Sample
这个 Bug 有些项目上很难复现,或者能复现该 Bug 的项目过于复杂,我们写了一个可以触发这个 Bug 的Sample。
下载项目后首先./gradlew assembleDebug
产生一个 mapping 文件,然后把 mapping 复制到 app 目录下,到 Proguard rule 打开-applymapping
选项再次编译就会出现Warning: ... is not being kept as ..., but remapped to ...
。
关于 ProGuard 一些常见问题
除了本文提到的增量混淆方法映射混乱,开发者也会遇到下面这些情况:
反射,例如
Class clazz=Class.forName("xxxx");clazz.getMethod("method_name").invoke(...)
与xxxx.class.getMethod("method_name").invoke(...)
这两种写法效果一不一样的,后者混淆的时候能正确处理,而前者 method_name 可能找不到,需要在 rule 中 keep 反射的方法。规则混写会导致配置错误如
-optimizations !code/** method/**
,只允许使用肯定或者或者否定规则,!号为否定规则。在 6.0 之前的版本大量单线程操作,整个处理过程比较耗时,如果时间可以将
-optimizationpasses
参数改为 1,这样只进行一次代码优化,后面的代码优化带来的提升很少。
总结
本文主要介绍了 Java 优化 &混淆工具 ProGuard 的基本原理、ProGuard 的几个模块之间的相互关系与影响、以及增量混淆使用-applymapping
遇到部分方法映射错乱的 Bug,Bug 出现的原因以及修复方案。代码优化涉及的编译器理论比较抽象,实现也比较复杂,鉴于篇幅限制我们只介绍了代码优化对整个过程带来的影响,对于代码优化有兴趣的读者可以查阅编译器相关的书籍。
作者简介
李挺,美团点评技术专家,2014 年加入美团。先后负责过多个业务项目和技术项目,致力于推动 AOP 和字节码技术在美团的应用。曾独立负责美团 App 预装项目并推动预装实现自动化。主导了美团插件化框架的设计和开发工作,目前工作重心是美团插件化框架的布道和推广。
夏伟,美团点评资深工程师,2017 年加入美团。目前从事美团插件化开发,美团平台的一些底层工具优化,如 AAPT、ProGuard 等,专注于 Hook 技术、逆向研究,习惯从源码中寻找解决方案。
评论