Android插件化、热补丁中绕不开的Proguard的坑

avatar
美团小编 @美团

文章主体部分已经发表于《程序员》杂志2018年2月期,内容略有改动。

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后的结果和宿主不兼容。

Printing mapping to [.../mapping.txt]...
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' is not being kept as 'b', but remapped to 'c'
Warning: there were 6 kept classes and class members that were remapped anyway.
         You should adapt your configuration or edit the mapping file.
         (http://proguard.sourceforge.net/manual/troubleshooting.html#mappingconflict1)
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' can't be mapped to 'c' because it would conflict with method 'clear', which is already being mapped to 'c'
Warning: there were 2 conflicting class member name mappings.

applymaping前后的映射关系变化
@@ -1491,7 +1491,7 @@ BitmapRequestBuilder -> com..glide.a:
-    264:265:BitmapRequestBuilder transform(cBitmapTransformation[]) -> a
+    264:265:BitmapRequestBuilder transform(BitmapTransformation[]) -> b

@@ -3532,7 +3532,7 @@ GifFrameLoader -> com.bumptech.glide.load.r
-    77:78:void stop() -> b
+    77:78:void stop() -> c_

初次混淆 增量混淆
transform->a transform->b
stop->b stop->c_

stop方法作为一个公用方法存在的宿主中,而子模块依赖于宿主中的stop方法。子模块升级之后依然依赖宿主的接口、公共方法,这要确保stop方法在子模块升级前后是一致的。当使用-applymapping进行增量编译时stop由b映射为c_。升子模块依赖的stop方法不兼容,造成子模块无法升级。

了解一下mapping

mapping.txt是代码混淆阶段输出产物。

mapping的用途
  1. retrace使用mapping文件和stacktrace进行ProGuard前的堆栈还原。
  2. 使用-applymapping配合mapping文件进行增量混淆。
mapping的组成

->为分界线,表示原始名称->新名称

  1. 类映射,特征:映射以:结束。
  2. 字段映射,特征:映射中没有()
  3. 方法映射,特征:映射中有(),并且左侧的拥有两个数字,代表方法体的行号范围。
  4. 内联,特征:与方法映射相比,多了两个行号范围,右侧的行号表示原始代码行,左侧表示新的行号。
  5. 闭包,特征:只有三个行号,它与内联成对出现。
  6. 注释,特征:以#开头,通常不会出现在mapping中。
一段与-applymapping出错有关的mapping
GifFrameLoader -> g:
    com.bumptech.glide.load.resource.gif.GifFrameLoader$FrameCallback callback -> a
    60:64:void setFrameTransformation(com.bumptech.glide.load.Transformation) -> a
    67:74:void start() -> a
    77:78:void stop() -> b
    81:88:void clear() -> c
    2077:2078:void stop():77:78 -> c
    2077:2078:void clear():81 -> c
    91:91:android.graphics.Bitmap getCurrentFrame() -> d
    95:106:void loadNextFrame() -> e

GifFrameLoader映射为g。在代码里面,每个类、类成员只有一个新的映射名称,其中stop出现了两次不同的映射。为什么会出现两次不同的映射?这两次不同的映射对增量混淆有影响吗?

ProGuard文档对于这个问题没有给出具体的原因和可靠的解决方案,在-applymapping一节提到如果代码发生结构性变化可能会输出上面的警告,建议使用-useuniqueclassmembernames参数来降低冲突的风险,这个参数并不能解决这个问题。

为了解决这个问题,我们决定探究一下ProGuard源码来看下为什么会出现这个问题,如何修复这个问题?

从源码中寻找答案

先看一下ProGuard怎么表示一个方法:

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
public void processMethodMapping(String className,
                                 int    firstLineNumber,
                                 int    lastLineNumber,
                                ...
                                 int    newFirstLineNumber,
                                 int    newLastLineNumber,
                                 String newMethodName)
{
    if (clazz != null && className.equals(newClassName))
    {
        String descriptor = ClassUtil.internalMethodDescriptor(methodReturnType,ListUtil.commaSeparatedList(methodArguments));
        Method method = clazz.findMethod(methodName, descriptor);
        if (method != null)
        {
            // Print out a warning if the mapping conflicts with a name that
            // was set before.
            // Make sure the mapping name will be kept.
            MemberObfuscator.setFixedNewMemberName(method, newMethodName);
        }
    }
}
2. 混淆处理

混淆以类为单位,可以分为两部分,第一部分是收集映射关系,第二部分是名称混淆。判断是否存在映射关系,如果不存在的话分配一个新名称。 第一部分:映射名称收集 MemberNameCollector收集ProgramMember的visitorInfo,并把相同描述符的方法或字段放入同一个map<混淆后名称,原始名称>

        String newName = MemberObfuscator.newMemberName(member);//获取visitorInfo
        if (newName != null)
        {
            String descriptor = member.getDescriptor(clazz);
            Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
            String otherName = (String)nameMap.get(newName);
            if (otherName == null                              ||
                MemberObfuscator.hasFixedNewMemberName(member) ||
                name.compareTo(otherName) < 0)
            {
                nameMap.put(newName, name);
            }
        }

如果visitorInfo出现相同名称,map中的键值对会被后出现的方法(以在Class中的顺序为准)覆盖,可能会导致错误映射覆盖正确映射。

第二部分:名称混淆

如果visitorInfo为null的话为member分配新名称,第一部分收集的map来确保NameFactory产生的新名称不会跟现有的冲突,nextName()这个里面有个计数器,每次产生新名称都自加,这就是出现a、b、c的原因。这一步只会保证map里面出现映射与新产生的映射不会出现冲突。

        Map nameMap = retrieveNameMap(descriptorMap, descriptor);
        String newName = newMemberName(member);
        if (newName == null)
        {  nameFactory.reset();
            do{newName = nameFactory.nextName();}
            while (nameMap.containsKey(newName));
            nameMap.put(newName, name);
            setNewMemberName(member, newName);
        }
3. 混淆名称冲突的处理

混淆冲突处理的第一步同混淆的第一步,先收集ProgramMember的visitorInfo,此时map跟混淆处理过程的状态一样。

冲突的判断代码:

        Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
        String newName = MemberObfuscator.newMemberName(member);
        String previousName = (String)nameMap.get(newName);
        if (previousName != null &&!name.equals(previousName))
        {   MemberObfuscator.setNewMemberName(member, null);
            member.accept(clazz, memberObfuscator);
        }

取出当前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生成过程中,除了生成类、方法、字段的映射关系,还记录了方法的内联的信息。

    2077:2078:void stop():77:78 -> c
    2077:2078:void clear():81 -> c

第一行表示:从右边的代码范围偏移到左侧的范围(方法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有用:

    81:88:void clear() -> c
    2077:2078:void stop():77:78 -> c//stop方法77-78行复制到c中偏移为2077-2078
    2077:2078:void clear():81 -> c//2077-2078插入到c中的81行后,c为clear方法

当内联处发生崩溃,根据2077-2078确定是stop方法发生崩溃,而stop实际clear的81行调用,根据2077-2078的偏移还原原始的堆栈应该是:clear方法81行调用stop方法(77-78行)发生崩溃。

行号的规则简化后如下: (被内联方法的代码行数+1000后/1000)x1000x内联发生的次数+offset,offset为被内联的起始行号。 Copy的代码最低行号为1000+起始行号,如果行数大于1k的话取整之后+起始行号。

对于被内联的方法还存在吗?

这个是不一定,可能不存在,也可能存在,如果存在的话mapping就会出现对此方法映射。如果被内联之后不会有其他方法调用这个方法不存在,但是该方法如果是因为继承关系(子类继承父类),这种方法通常存在。

整个流程是这样的

这几个模块并不是没关联的,接下来把整个流程串起来。

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。

当进入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的状态如下图:

因为内联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一些常见问题

除了本文提到的增量混淆方法映射混乱,开发者也会遇到下面这些情况:

  1. 反射,例如Class clazz=Class.forName("xxxx");clazz.getMethod("method_name").invoke(...)xxxx.class.getMethod("method_name").invoke(...)这两种写法效果一不一样的,后者混淆的时候能正确处理,而前者method_name可能找不到,需要在rule中keep反射的方法。

  2. 规则混写会导致配置错误如-optimizations !code/** method/**,只允许使用肯定或者或者否定规则,!号为否定规则。

  3. 在6.0之前的版本大量单线程操作,整个处理过程比较耗时,如果时间可以将-optimizationpasses参数改为1,这样只进行一次代码优化,后面的代码优化带来的提升很少。

总结

本文主要介绍了Java优化&混淆工具ProGuard的基本原理、ProGuard的几个模块之间的相互关系与影响、以及增量混淆使用-applymapping遇到部分方法映射错乱的Bug,Bug出现的原因以及修复方案。代码优化涉及的编译器理论比较抽象,实现也比较复杂,鉴于篇幅限制我们只介绍了代码优化对整个过程带来的影响,对于代码优化有兴趣的读者可以查阅编译器相关的书籍。

作者简介

李挺,美团点评技术专家,2014年加入美团。先后负责过多个业务项目和技术项目,致力于推动AOP和字节码技术在美团的应用。曾独立负责美团App预装项目并推动预装实现自动化。主导了美团插件化框架的设计和开发工作,目前工作重心是美团插件化框架的布道和推广。

夏伟,美团点评资深工程师,2017年加入美团。目前从事美团插件化开发,美团平台的一些底层工具优化,如AAPT、ProGuard等,专注于Hook技术、逆向研究,习惯从源码中寻找解决方案。

美团平台客户端技术团队,负责美团平台的基础业务和移动基础设施的开发工作。基于海量用户的美团平台,支撑了美团点评多条业务线的快速发展。同时,我们也在移动开发技术方面做了一些积极的探索,在动态化、质量保障、开发模型等方面有一定积累。客户端技术团队积极采用开源技术的同时,也把我们的一些积累回馈给开源社区,希望跟业界一起推动移动开发效率、质量的提升。

如果对我们团队感兴趣,可以关注我们的专栏