记一次StackOverflow崩溃分析: Proguard 5.3错改指令

144
作者 Shawon
2016.11.05 18:15* 字数 2956

proguard在5.3.2已修复这个问题

近期工作主要在细化项目的proguard混淆规则, 简单的去掉一些暴力keep, 可以减少项目1k+的方法数. 在删除某些配置之后, 尽管我知道对功能没有影响, 还是会去测一下功能是否正常. 于是就遇到了一个隐藏在主干上的StackOverflow崩溃.

从堆栈上看, 匿名内部类的runMyApp.startActivity, MyApp.startActivitynew了一个Runnable, 它的run继续调MyApp.startActivity, 最终栈溢出. 看了堆栈, 还以为是一个简单的问题, 直接去找到了代码.

反复删代码, 打包验证, 最后把问题锁定到一句代码上, 精简后的崩溃代码如下

public class MyApp extends Application {
    ......
    @Override
    public void startActivity(final Intent intent) {
        new Runnable() {
            @Override
            public void run() {
                MyApp.super.startActivity(intent);
            }
        }.run();
    }
    ......
}

看到这个情况, 我是有点懵的, 虽说我Java基础不牢固, 高级特性没怎么接触过, 但你告诉我这代码能无限递归造成栈溢出, 我有种Java书白看了的感觉.

看到代码有不可思议的崩溃, 一定要相信代码, 如果你觉得代码的执行有问题, 可以考虑多线程, 另一个方向是直接看指令, 指令也是不会骗人的.

我记得以前有一个空指针崩溃, 看代码肯定没问题, 几个人看了一上午, 没头绪, 最后领导来了, 反编译apk, 直接就明白了. 我第一个猜测是指令生成错了, 代码里要调super方法, 指令应该是invoke-super, 难道实际上生成的指令是invoke-virtual?

反编译的结果如下:
匿名内部类在dt.smali中, 其中有一句

    invoke-static {v0, v1}, Lcom/shaw/crash/MyApp;->a(Lcom/shaw/crash/MyApp;Landroid/content/Intent;)V

这里的a方法是混淆后的名字, 它是一个synthetic方法, 它的定义在MyApp.smali里:

.method static synthetic a(Lcom/shaw/crash/MyApp;Landroid/content/Intent;)V
    .registers 2

    .prologue
    .line 40
    invoke-virtual {p0, p1}, Landroid/app/Application;->startActivity(Landroid/content/Intent;)V

    return-void
.end method

可以看到, 这里使用的指令是invoke-virtual, 正常情况下应该是invoke-super.

可能有同学不明白, MyApp.super.startActivity(intent);这一句方法调用为什么被拆成了一个invoke-static和一个synthetic方法. 在Java里, 除非使用MethodHandles, 在Java代码层面, 在类的外部, 或者类的static方法里, 不可能去调用该类实例的某个super方法. 而在字节码层面, 只要是在类的内部, 包括static方法里, 都能直接去调super方法. 因此为了能在匿名内部类里调用外部类的super方法, Java编译器会在外部类里生成一个静态synthetic方法, 用于调用某个super方法, 而匿名内部类则通过调用这个synthetic方法并传入一个外部类引用来间接调用外部类super方法.

回到这个崩溃, 本应该是invoke-super的地方, 实际上却是invoke-virtual, 相当于我们的代码变成了下面这样

    @Override
    public void startActivity(final Intent intent) {
        new Runnable() {
            @Override
            public void run() {
                MyApp.this.startActivity(intent);  //注意是this, 不是super
            }
        }.run();
    }

毫无疑问, 这代码肯定是会StackOverflow的.

这是个很严重的问题, 按我们这个体量, 要是线上有这个问题, 绝对是个大事故, 我检查了我们最新发布版本的RC1~4的所有apk, 都是没问题的.

我猜测是打包机器的问题, 因为我一直用旧的平台在固定的机器上打包, 发布包则是新打包平台随机分配机器打包, 后来开了新打包平台权限, 恰巧用同一台机器分别打了主干和项目分支的包, 只有主干是有问题的.

接下来问题就简单了, 对比主干和项目分支的差异, 重点关注非代码修改, 最终定位到一个提交, 这个提交将proguard从5.1升级到5.3. 分别使用5.1和5.3打包, 确认使用5.1打出的包正常, 5.3打出的包指令错误.

这次升级我们是知道的, 主要原因在于proguard有bug, 导致在混淆阶段直接挂了, 因此我们不得不改写一部分代码, 外加升级proguard版本来解决这个问题. 这种情况我们是不希望降级的, 所以下了一份proguard 5.3的代码, 幸好它是Java写的.

Proguard 5.3中, 只有一个地方会尝试把invoke指令替换成invokevirtual, 在MethodInvocationFixer的visitConstantInstruction里, proguard里面的优化可以有好几轮, 通过-optimizionpasses n指定, 每轮优化后, proguard都会尝试修复invoke指令, 因为它的优化可能会改变方法的属性, 比如改成private, 但是不会立即改invoke指令, 而是依赖MethodInvocationFixer统一修复.

由于proguard处理的是Java字节码, 我们刚才看的是smali代码(dalvik字节码), 两者指令略有差别, 我们还是以普通Java工程来说明问题, 下面的Java工程可以重现proguard 5.3导致的栈溢出bug.

public class SubClass extends SuperClass {
    @Override
    public void stackOverflow() {
        new Runnable() {
            @Override
            public void run() {
                SubClass.super.stackOverflow();
            }
        }.run();
    }
}

public class Main {
    public static void main(String args[]) {
        Main mainObj = new Main();
        mainObj.testProguard();
    }

    void testProguard() {
        SuperClass sub = new SubClass();
        sub.stackOverflow();
    }
}

注意必须要在testProguard方法里调用stackOverflow, 不要直接在main里调用, 否则有可能无法重现.

下面是proguard配置文件

-optimizationpasses 1

-dontobfuscate

-keepclasseswithmembers public class * {
    public static void main(java.lang.String[]);
}

设置不混淆只是为了方便看反编译的代码, 设置优化轮数为1是为了防止proguard在错改指令后进一步内联方法导致重要信息被破坏.

编译后, 不混淆, 直接观察字节码, 可以看到和我们在smali里看到的类似结构, 匿名内部类是SubClass$1

final class com.shaw.SubClass$1 implements java.lang.Runnable {
  ......
  public final void run();
    Code:
       0: aload_0
       1: getfield      #5                  // Field this$0:Lcom/shaw/SubClass;
       4: invokestatic  #6                  // Method com/shaw/SubClass.access$001:(Lcom/shaw/SubClass;)V
       7: return
}

它调用SubClass的synthetic方法access$001:

  static void access$001(com.shaw.SubClass);
    Code:
       0: aload_0
       1: invokespecial #1                  // Method com/shaw/SuperClass.stackOverflow:()V
       4: return

这里用的指令是invokespecial. 在proguard 5.2及以下当一个invoke指令满足以下条件时, 就会被替换成invokevirtual:
前提条件: 被invoke指令调用的方法,非static,非private,非<init>,非interface方法.
满足前提条件的情况下, 满足以下任何一个条件,就会替换:

  1. invoke指令非invokespecial
  2. invoke指令所在类和invoke指令要调用的方法所在类相同
  3. invoke指令所在类不是继承自invoke指令要调用的方法所在类

显然, 上面这个invokespecial, 尽管满足了前提条件, 但是下面三个条件它全部不满足, 因此不会被替换, dx之后对应指令invoke-super.

而到了proguard 5.3, 当一个invoke指令满足以下条件时, 就会被替换成invokevirtual:
前提条件: 被invoke指令调用的方法,非static,非private,非<init>,非interface方法.
满足前提条件的情况下, 满足以下任何一个条件,就会替换:

  1. invoke指令非invokespecial
  2. invoke指令所在类和invoke指令要调用的方法所在类相同
  3. invoke指令所在类不是继承自invoke指令要调用的方法所在类
  4. invoke指令所在方法是static方法

5.3多加了一个条件, 刚才的invokespecial偏偏就满足这个条件, 于是在5.3中被替换成invokevirtual, dx之后对应指令invoke-virtual.

查看经过proguard 5.3处理后的SubClass字节码, 的确是这样:

  static void access$001(com.shaw.SubClass);
    Code:
       0: aload_0
       1: invokevirtual #11                 // Method com/shaw/SuperClass.stackOverflow:()V
       4: return

只要调用了SubClass.stackOverflow, 就会栈溢出.

当invokespecial调用的是一个super方法时, 是绝对不能替换成invokevirtual的, 换了就有可能是一个stackoverflow, 这也是条件2和条件3所要保护的情况, 当一个指令是invokespecial, 条件1天然不成立, 条件2和条件3都不成立的情况下, invokespecial必定是在调用super方法, 此时肯定不能替换.

而条件4的加入, 恰好使得access$001中的invokespecial指令在绕过了条件2和3的保护. 此时条件1,2,3均不成立, 仅条件4成立. 而我们知道条件2和3不成立时, 恰好是不能替换的.

为什么作者会这么写?

我的猜想是这样的:

MethodInvocationFixer本质上是一个invoke指令修复器, 目的是修复proguard优化导致的指令错误, 然而它不区分invoke指令调用的方法有没有被优化过, 而是无差别的尝试修复.

MethodInvocationFixer对单条invoke指令的修复逻辑如下:
不处理invokedynamic.

  1. 如果invoke指令调用的方法是static, 但invoke指令不是invokestatic, 将其改为invokestatic, 修复结束.
  2. 否则, 如果invoke指令调用的方法是private方法或<init>, 但invoke指令不是invokespecial, 将其改为invokespecial, 修复结束.
  3. 否则, 如果invoke指令调用的方法是实现的一个interface, 但这个invoke指令不正确,将其改为正确的invokeinterface, 修复结束.
  4. 否则, 尝试将非invokevirtual指令改成invokevirtual指令, 这里就是之前讨论的替换invoke指令为invokevirtual的逻辑.

从这个逻辑来看, 1,2,3保证了当进行到4的时候, 如果该指令是invokestatic或invokeinterface, 必定需要修复, 但是肯定不是修复成invokespecial, 因为proguard不会做这种优化, 那么唯一的选择就是修复成invokevirtual, 这就是条件1 invoke指令非invokespecialtrue时的情况.

剩下需要讨论的就是将invokespecial指令替换成invokevirtual指令的情况.

前面说过, invokespecial如果是调用一个super方法, 不能被替换成invokevirtual指令, 这是条件2与条件3均不成立时的情况.

那么剩下的invokespecial指令就只能替换成invokevirtual了.

作者新加的条件漏掉了什么?

我们知道, 在Java代码层面, 我们不能直接调用一个实例的super方法, 只能在类的实例方法里去调super, 即便是在类自身的static方法里面, 也不能调用本类实例的super方法.

作者大概认为, Java代码层面的限制, 使得static方法里不可能出现invokespecial调用super方法的情况, 加上了条件4 invoke指令所在方法是static方法直接替换这部分invokespecial指令.

可是, Java代码层面写不出来, 不代表实际指令不会出现. 在字节码层面, 只要是在类的内部, 就能调用该类实例的super方法, 即便是static方法也可以, 这也是为什么Java编译器生成的synthetic方法是static方法却又可以invokespecial一个super方法.

作者恰好没有考虑到Java编译器生成的synthetic方法, 使得proguard 5.3处理后的程序有可能发生StackOverflow. 而这个问题之所以一直没人发现, 主要还是因为很多人用的proguard都是旧版本, 这个bug只在5.3有, 而且想触发这个bug, 需要在匿名内部类里面去调用super方法, 这样的写法本身就很奇怪.

要解决这个bug, 只需要去掉这个新出现的条件即可.

我已经去信作者, 然而目前没有回应, 在作者修复这个bug之前, 如果想用proguard 5.3, 只能自己修复这个问题, 编译proguard, 目前我们就是这么干的. 如果不需要用最新的proguard, 可以用旧版本, 5.1, 5.2是没有这个问题的, 其他我就不清楚了.

这个崩溃最大的特点在于它并非我们的代码直接导致的崩溃, 而是特定的代码编译后生成的合成方法触发了proguard 5.3的bug, 导致指令被错误的修改, 最终崩溃. 分析崩溃的时候一方面要相信代码不会骗人, 也要考虑到代码的最终产物是什么, 对外部的处理保持合理的怀疑. 熟悉字节码也能更快的找到问题的原因. 追查这个问题时, JD-GUI这类工具并不牢靠, 经常看到它们展示的代码和字节码不符, 特别是遇到synthetic方法, 明显显示有问题. 推荐直接用javap和baksmali, 这样得到的信息最全.

Android研究