「幽灵调用」背后的真相:一个隐藏多年的Android原生Bug

三天前,群里看到Penguin大佬写的一篇文章:你的App是否有出现过幽灵调用。看完后不禁感慨:分析之深入、工具之强大,令人叹服。这篇文章写的很好,但想要看懂需要很多前置知识,另外结尾处戛然而止,让人意犹未尽。因此本文狗尾续貂,一来想多介绍些背景知识,降低原文的理解难度;二来想深入探究下问题的根因,如果是通用机制出了问题,那么可以联系谷歌从根上解决,造福其他App开发者。对调试部分不感兴趣、只对根因和解决方案感兴趣的,可以直接跳到"阶段五"。

阶段一

起因是他们的进程发生了SIGSEGV的内存错误。

yaml 复制代码
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000018
Cause: null pointer dereference
    x0  0000000000000000  x1  0000000002e62ec8  x2  0000000000000000  x3  0000000072223650
    x4  0000007c3ec13000  x5  3b7463656a624f2f  x6  3b7463656a624f2f  x7  0000007bbdababac
    x8  0000000000000002  x9  2542ebd30d0dfceb  x10 0000000000000000  x11 0000000000000002
    x12 00000000af950a08  x13 b400007d15e5fa50  x14 0000007f1598f880  x15 0000007bbd9446e8
    x16 0000007fea726e40  x17 0000000000000020  x18 0000007f15ca0000  x19 b400007d55e10be0
    x20 0000000000000000  x21 b400007d55e10ca0  x22 0000000002d51610  x23 0000000002e61b08
    x24 0000000000000005  x25 0000000000000002  x26 0000000002e62ec8  x27 0000000000000002
    x28 00000000031b1f38  x29 00000000ffffffff
    lr  00000000721b63c8  sp  0000007fea728e90  pc  00000000721b63d4  pst 0000000080001000101 total frames
backtrace:
      #00 pc 00000000008123d4  /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.jumpDrawablesToCurrentState+132)
      #01 pc 00000000007ca6c8  /system/framework/arm64/boot-framework.oat (android.view.View.onDetachedFromWindowInternal+472)
      #02 pc 00000000007bb8d0  /system/framework/arm64/boot-framework.oat (android.view.View.dispatchDetachedFromWindow+288)
      #03 pc 000000000080c444  /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+484)
      #04 pc 000000000080c34c  /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+236)
      #05 pc 000000000080c34c  /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+236)
...

针对内存错误,一般的分析思路就是查看错误处的汇编代码,以及相关的内存/寄存器值。oat文件虽然属于ELF文件,但一般我们通过oatdump来恢复出它的DEX CODE(字节码)和OAT CODE(汇编代码),原文使用了作者自研的core-parser工具,使用起来更加方便,但得到的信息是一样的。

yaml 复制代码
DEX CODE:
0x795fe114a2: 0212                     | const/4 v2, #+0
0x795fe114a4: 1235 000a                | if-ge v2, v1, 0x795fe114b8 //+10
0x795fe114a8: 0346 0200                | aget-object v3, v0, v2
0x795fe114ac: 106e 80d3 0003           | invoke-virtual {v3}, void android.view.View.jumpDrawablesToCurrentState() // method@32979

OAT CODE:
0x72f563b0: 6b18033f | cmp w25, w24
0x72f563b4: 540001aa | b.ge 0x72f563e8
0x72f563b8: 110032e0 | add w0, w23, #0xc
0x72f563bc: 1000007e | adr x30, 0x72f563c8
0x72f563c0: b59da314 | cbnz x20, 0x72e91820
0x72f563c4: b8797801 | ldr w1, [x0, x25, lsl #2]
0x72f563c8: aa0103fa | mov x26, x1
0x72f563cc: b9400020 | ldr w0, [x1]
0x72f563d0: f949c000 | ldr x0, [x0, #0x1380]
0x72f563d4: f9400c1e | ldr x30, [x0, #0x18]
0x72f563d8: d63f03c0 | blr x30
0x72f563dc: 11000739 | add w25, w25, #1

原始tombstone第0帧的pc值为0x721b63d4,而coredump里第0帧的pc值为0x72f563d4,二者不一样,主要是它们不是在同一次异常中产生的。但二者地址的低位都是0x3d4,则表明它们反映的调用栈应该是一致的。原因是文件加载到内存时需要按页对齐(新版本需要按16K的大页对齐),因此即便每一次加载时起始地址不同,但同一处代码的页内偏移是固定的,也就是这里的0x3d4。

具体的错误指令是ldr x30, [x0, 0x18],如果看OAT有经验的话,基本一眼就明白这是一个拿着ArtMethod*找entry_point_from_quick_compiled_code_的过程,也就是寻找接下来调用的Java方法的目标地址的过程。当然,对OAT没那么熟悉的朋友可以借助oatdump输出里的dex_pc和pc的映射关系,找到这段汇编对应的DEX字节码,也即invoke-virtual {v3}, void android.view.View.jumpDrawablesToCurrentState() // method@32979,通过字节码,我们也能够知道这是一个Java调用。

Fault address = 0x18,它是x0+0x18得到的,因此可以知道x0为0。它作为一个ArtMethod的指针值,显然是有问题的。因此接下来就要去排查,为何我们会得到一个值为0的ArtMethod*。

再看错误发生前的两行汇编:

assembly 复制代码
0x72f563cc: b9400020 | ldr w0, [x1]
0x72f563d0: f949c000 | ldr x0, [x0, #0x1380]
0x72f563d4: f9400c1e | ldr x30, [x0, #0x18]

这里需要一些对ART虚拟机运作的了解,也即我们是如何找到ArtMethod*的。虚方法的ArtMethod*一般存在类中,具体是存在Class的Embedded VTable中,这个我在以前写类加载时画过一张图,可以看到虚方法在类中具体的位置。

因此,ldr w0, [x1]就是从实例对象(x1)中取出Class*,而ldr x0, [x0, #0x1380]就是从类中取出ArtMethod*的过程。至于0x1380哪里来,这个是dex2oat过程中优化得到的值,dex2oat帮我们找到了具体的虚方法在类中的偏移,省了运行时再去做一遍查找。

既然得到的ArtMethod*有问题,那我们进一步就要确认:Class*是不是一个有问题的值?

Class*来自于x1,因此我们需要检查x1的内存情况。

通过coredump里的寄存器情况,我们可以知道x1=0x27e6d40。在64位的Java进程里,小于4G(地址有效位数≤8位)的地址都被用Java使用着,譬如Java堆,或者boot image等。

复制代码
x0  0x0000000000000000  x1  0x00000000027e6d40  x2  0x0000000000000000  x3  0x0000000072fc3650

接下来就是查看这个x1实例对象内部的内存值。

makefile 复制代码
core-parser> rd 0x00000000027e6d40 -e 0x00000000027e6e40
27e6d40: 8adbc7e1b0000888  0000000000000000  ................
27e6d50: 0000000000000000  0000000000000000  ................
27e6d60: 0000000000000000  0000000000000000  ................
27e6d70: 0000000000000000  0000000000000000  ................
27e6d80: 0000000000000000  0000000000000000  ................

对于一个Java对象而言,它的头部8个字节一定存的是这两个字段:

c++ 复制代码
// The Class representing the type of the object.
HeapReference<Class> klass_;
// Monitor and hash code information.
uint32_t monitor_;

第一个是klass_,第二个是monitor_。由于小端机制的作用,8adbc7e1b0000888实际上是后半部分属于第一个字段:klass_,因此klass_的值为0xb0000888。可是查看klass_的内存,发现它里面的数据竟然都是0,这显然是异常的,因此我们有理由怀疑,这里得到的0xb0000888是有问题的。

vbnet 复制代码
core-parser> rd 0xb0001c08 (这里原文看的是0xb0000888+0x1380的内存,但我相信0xb0000888起始位置的内存值就能看出它不是一个正常的Class对象)
b0001c08: 0000000000000000  .......

阶段二

分析到这里,我可能就会懵逼一会儿,然后对着这个异常的0xb0000888犯迷糊。按照我的经验,接下来会分析这个异常值周边的内存,寻找一些规律确定这个对象是谁,但其实这个工作耗时,而且需要一些运气。原文利用了它们自研工具的坏根检测机制,直接检测出这块内存异常的对象大小,这能给确定对象的类型带来极大的帮助。

最终,作者通过内存的数据规律,判断出该对象属于A类。

scala 复制代码
core-parser> class A -f
[0xb01f0888]
public final class A extends androidx.appcompat.widget.AppCompatImageView {
  // Object instance fields:
    [0x03b8] private boolean n
    [0x03b4] private a.b.c.d.u.o q
    [0x03b0] private volatile a.b.c.d.u.H k

  // extends androidx.appcompat.widget.AppCompatImageView
    [0x03ac] private final androidx.appcompat.widget.ld6 mImageHelper
    [0x03a8] private final androidx.appcompat.widget.q mBackgroundTintHelper
    [0x03a6] private boolean mHasLevel
...
}

那么异常值为什么异常也就确定了,类指针原本的值是0xb01f0888,而实际写的值是0xb0000888,二者的差异是0x1f,这不像是硬件的bitflip,而且多次错误都集中在同一调用栈,更加否定了bitflip的可能。因此唯一可能只能是内存踩踏,这个类指针值被其他人给踩了。

阶段三

正如原文里作者说的,Java堆上的内存踩踏是缺乏调试工具的,因为不论是HWASan还是MTE,它们其实都是针对malloc的hook,而Java的堆是虚拟机自行管理的,它不走malloc,因此也就无法追踪。那有人会问,为什么ART虚拟机不针对Java堆也搞个调试机制呢?一是没必要,能够用上的使用场景实在太少;二是很麻烦,因为GC会频繁地对存活对象进行搬移,搬移之后如何保持检测,处理起来比较复杂;三是性能牺牲太大,不论是HWASan还是MTE,都依赖64位地址的高位不用来寻址、可以另作他用的这个feature。而Java对象由于都放在低4G内存中,因此它们的地址只有32bit,没有多余的空间用来保存tag。如果要支持调试,只能走ASan那种风格,会对堆内存产生严重的浪费。

但恰好,这个问题就需要去寻找Java堆内存踩踏的元凶。原文作者先用BPF采集的方式将范围缩小,接着采用挂起→mprotect保护→恢复运行的方式,让踩踏变成SIGSEGV的错误,从而恢复出踩踏瞬间的调用栈,知道它在干嘛。

yaml 复制代码
  Native: #0  0000000072ef6cf4  /system/framework/arm64/boot-framework.oat+0x8facf4
  Native: #1  0000000072ef6cf0  /system/framework/arm64/boot-framework.oat+0x8facf0
  JavaKt: #00  0000006dfec2ae3e  android.widget.ImageView.onDetachedFromWindow
  JavaKt: #01  0000006d7d08ec04  B.onDetachedFromWindow
  JavaKt: #02  0000006dfeb491e4  android.view.View.dispatchDetachedFromWindow
  JavaKt: #03  0000006dfeb128e2  android.view.ViewGroup.dispatchDetachedFromWindow
  JavaKt: #04  0000006dfeb128e2  android.view.ViewGroup.dispatchDetachedFromWindow
scala 复制代码
core-parser> class B -f
[0xb00f0dd8]
public class B extends android.view.TextureView {

  // Object instance fields:
    [0x0374] private java.lang.Boolean y
    [0x0370] java.util.HashSet s

从上述调用栈,可以看到B.onDetachedFromWindow调用了android.widget.ImageView.onDetachedFromWindow,可是B继承的压根就不是ImageView,怎么会跑到它的方法里呢?

结合内存中的数据,发现正是因为android.widget.ImageView.onDetachedFromWindow方法将this对象当成了ImageView(而它实际上是B),而B的大小比ImageView小,所以ImageView里有些字段的偏移超出了。往这些字段里写数据,就会导致越界写,从而造成内存踩踏。

阶段四

那好端端的B.onDetachedFromWindow为什么会跳到android.widget.ImageView.onDetachedFromWindow里去呢?这还得回到B.onDetachedFromWindow的字节码来找原因。

css 复制代码
core-parser> method 0xb0476378 --dex --oat
protected void B.onDetachedFromWindow() [dex_method_idx=15974]
DEX CODE:
  0x6d7d08ec04: 106f 0525 0001           | invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
...
  0x6d7d08ec18: 000e                     | return-void

可以看到,它是通过invoke-super进入到的android.widget.ImageView.onDetachedFromWindow。上述调用栈是原文作者自研工具的产物,但我相信,如果是原始调用栈(tombstone或gdb的原始输出),我们应该可以从调用栈中看出B.onDetachedFromWindow是解释执行的。因此,需要从nterp的invoke-super源码中去了解目标方法的来源,也即这里错误的android.widget.ImageView.onDetachedFromWindow来自何处。

assembly 复制代码
%def invoke_direct_or_super(helper="", range="", is_super=""):
   EXPORT_PC
   // Fast-path which gets the method from thread-local cache.
%  fetch_from_thread_cache("x0", miss_label="2f")
1:
   // Load the first argument (the 'this' pointer).
   FETCH w1, 2
   .if !$range
   and w1, w1, #0xf
   .endif
   GET_VREG w1, w1
   cbz w1, common_errNullObject    // bail if null
   b $helper
2:
   mov x0, xSELF
   ldr x1, [sp]
   mov x2, xPC
   bl nterp_get_method
   .if $is_super
   b 1b
   .else
   tbz x0, #0, 1b
   and x0, x0, #-2 // Remove the extra bit that marks it's a String.<init> method.
   .if $range
   b NterpHandleStringInitRange
   .else
   b NterpHandleStringInit
   .endif
   .endif

这是它的源码,汇编格式。可以看到方法解析其实有两条路径,一条是fast path,一条是slow path。

Fast path就是fetch_from_thread_cache,所以我们有必要了解下thread cache在这里的含义。

assembly 复制代码
%def fetch_from_thread_cache(dest_reg, miss_label):
   // Fetch some information from the thread cache.
   // Uses ip and ip2 as temporaries.
   add      ip, xSELF, #THREAD_INTERPRETER_CACHE_OFFSET       // cache address
   ubfx     ip2, xPC, #2, #THREAD_INTERPRETER_CACHE_SIZE_LOG2  // entry index
   add      ip, ip, ip2, lsl #4            // entry address within the cache
   ldp      ip, ${dest_reg}, [ip]          // entry key (pc) and value (offset)
   cmp      ip, xPC
   b.ne     ${miss_label}

这是Android为解释执行引入的加速机制,因为每条invoke-*指令基本都会触发一次方法解析,为了减少这种解析开销,ART引入了per-thread的cache。Cache里可以存256个条目,每个条目是个键值对,key是dex_pc,也即字节码的地址;value则根据invoke类型的不同而不同,对于invoke-super而言,它存的就是最终的目标方法指针(ArtMethod*)。

fetch_from_thread_cache是取,那么什么地方会往cache里存呢?答案就是剩下的那条slow path。

Slow path会调用nterp_get_method,它会先FindSuperMethodToCall,然后把解析出来的方法存入cache。

c++ 复制代码
    NTERP_TRAMPOLINE nterp_get_method, NterpGetMethod

    LIBART_PROTECTED FLATTEN
    extern "C" size_t NterpGetMethod(Thread* self, ArtMethod* caller, const uint16_t* dex_pc_ptr)
        REQUIRES_SHARED(Locks::mutator_lock_) {
      ...
      if (invoke_type == kSuper) {
        resolved_method = caller->SkipAccessChecks()
            ? FindSuperMethodToCall</*access_check=*/false>(method_index, resolved_method, caller, self)
            : FindSuperMethodToCall</*access_check=*/true>(method_index, resolved_method, caller, self);
        if (resolved_method == nullptr) {
          DCHECK(self->IsExceptionPending());
          return 0;
        }
      }

      if (invoke_type == kInterface) {
      ...
      } else {
        UpdateCache(self, dex_pc_ptr, resolved_method);
        return reinterpret_cast<size_t>(resolved_method);
      }
    }

我们可以从当前线程的interpreter cache里根据invoke-super的dex_pc值取出存在ArtMethod*,不出意外,确实是android.widget.ImageView.onDetachedFromWindow。可是B压根没有继承ImageView,怎么会这样呢?

让我们重新思考cache的逻辑,key是dex_pc,意味着我们只要拿相同的dex_pc,那么就会得到相同的方法。所以可不可能是别人拿着相同的dex_pc往cache里面存了这个方法(android.widget.ImageView.onDetachedFromWindow)呢?

查找其他方法,发现有一个C.onDetachedFromWindow居然和B.onDetachedFromWindow共用同一段字节码(字节码的地址完全一样)。

java 复制代码
core-parser> method 0xb0404368 --dex --oat
protected void C.onDetachedFromWindow() [dex_method_idx=16079]
DEX CODE:
  0x6d7d08ec04: 106f 0525 0001           | invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
...
  0x6d7d08ec18: 000e                     | return-void

core-parser> method 0xb0476378 --dex --oat
protected void B.onDetachedFromWindow() [dex_method_idx=15974]
DEX CODE:
  0x6d7d08ec04: 106f 0525 0001           | invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
...
  0x6d7d08ec18: 000e                     | return-void

再进一步查看C的类型,继承自AppCompatImageView(未实现onDetachedFromWindow),而AppCompatImageView又继承自ImageView(实现了onDetachedFromWindow)。

scala 复制代码
core-parser> class C -i -f
[0xb0017e60]
public final class C extends androidx.appcompat.widget.AppCompatImageView {

好了,这下逻辑链条清晰了,一定是C先解析,将ImageView的onDetachedFromWindow方法存入cache。而后B再运行,拿着相同的dex_pc取出了C先前存入的ArtMethod*。原文分析到这里基本也就结束了,但为何二者会共用同一段字节码,以及最终该如何修复并没有展开。下面我们继续。

阶段五

要知道DEX字节码的共用来自于何处,我们需要对App的编译安装有些了解。

程序员编写的一般是kotlin或java,它们经过各自语言的编译器,出来的结果就是class文件,其中包含的是JVM字节码。

当App的minifyEnabled选项打开以后,class文件由R8进一步处理(未打开由D8处理,因此也不会有问题),它在JVM字节码到DEX字节码的转换过程中会做shrink(缩减)、obfuscate(混淆)和optimize(优化)的动作。

待到App安装到手机上时,dex2oat又会介入,它里面有个环节,是将原始的DEX文件转换成VDEX文件,其中关键有两步:verification和quickening,也就是验证和加速。加速主要是对一些指令进行改写,从而减少运行时的解析成本。

了解了以上三个环节,我们不禁要问:字节码复用到底发生在哪个环节?

我在本地做了些测试,最终发现复用发生在R8环节。因此,问题的矛盾点抓住了:R8的code deduplication机制在碰到invoke-super时,和ART内部的interpreter thread cache机制冲突了。R8认为字节码相同便可以复用,但ART认为同一段字节码,每次进来的invoke-super不可能变换目标。

其实这里还隐藏着一个问题:为什么两个类的继承关系明明不同,可是生成的invoke-super字节码却是一样的呢?

  • B → TextureView(未实现方法) → View
  • C → AppCompatImageView(未实现方法) → ImageView → View

它们两个生成的字节码都是:

java 复制代码
invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317

操作数里指向的类竟然都是android.view.View,也就是它们最顶端的父类。按照Java的语义,invoke-super指向的类应该是往上找到的第一个声明该方法的类,对C而言,应该是ImageView才是。

阶段六

带着上述的发现和疑惑,我把问题反馈给了谷歌,拉了ART和R8相关的工程师。因为这个问题影响的范围其实挺大的,而且时间应该不短。

谷歌的反馈很快,在看完分析后,他们承认这是一个原生bug。接着我又和他们沟通了一些技术细节,就修复方案给了些建议。下面把完整的前因后果按照时间线给大家梳理出来:

  • 2018年02月06日,ART里引入了code item dedupling的功能,旨在减少文件大小。链接

  • 2018年09月27日,ART里新增了interpreter thread-local cache的功能,可以在解释执行时跳过一些方法解析的操作,从而提升性能。链接

  • 2021年06月18日,ART里将code item dedupling功能关闭,原因就是发现它和interpreter cache功能冲突了。链接

  • 2021年11月03日,R8里引入patch,当一个method属于library method(所谓library method,通俗理解就是系统库里的方法,譬如android.widget.ImageView),且自Android起始就存在,那么R8会对他做rebind的动作。对于invoke-super而言,如果这个方法是系统库里的且不管哪个Android版本都有,那么会将他改写为topmost父类的方法,以此来减少dex文件里method_id的消耗。链接

  • 2023年05月08日,R8中引入code deduplication机制,进一步压缩DEX文件的大小。[链接](r8-review.googlesource.com/c/r8/+/7912... "Dedup code objects on api 31 and above")

至此以后,这个问题就一直存在着。

了解清楚了前因后果,那么接下来就是修复方案。既然是多种机制合在一块造成的问题,那么修复放在哪边就很有讲究了。

改ART侧?不能够。因为按照Java原始语义,包括javac生成的结果,都表明同一段invoke-super字节码,不应该存在多种理解。

改R8侧的rebind机制?有点用,但阻止不了问题的发生。这里涉及compile sdk和实际运行设备之间的区别,二者对于同一个类的方法定义可能是不同的。如果compile sdk里这个类没有实现这个方法,而运行版本实现了这个方法,问题依然会发生。

因此最好的修复方法就是改R8侧的code deduplication机制:如果字节码中有invoke-super,那么就不能够进行代码去重。具体修复在这里。今天刚刚提交。

结语

再次感谢Penguin大佬,这个修复的绝大数功劳应该归属于他,因为这种复杂问题一般人真搞不定。

对众多App开发者而言,我很好奇你们的App有没有受到过这个问题的影响?以及编译时用的R8版本是多少?谷歌也需要这个版本信息来决定修复需要backport到哪些历史版本中。所以大家有类似经历的话,欢迎留言!

相关推荐
卡尔特斯2 小时前
Android Kotlin 项目代理配置【详细步骤(可选)】
android·java·kotlin
ace望世界2 小时前
安卓的ViewModel
android
ace望世界2 小时前
kotlin的委托
android
CYRUS_STUDIO4 小时前
一文搞懂 Frida Stalker:对抗 OLLVM 的算法还原利器
android·逆向·llvm
zcychong5 小时前
ArrayMap、SparseArray和HashMap有什么区别?该如何选择?
android·面试
CYRUS_STUDIO5 小时前
Frida Stalker Trace 实战:指令级跟踪与寄存器变化监控全解析
android·逆向
ace望世界10 小时前
android的Parcelable
android
顾林海10 小时前
Android编译插桩之AspectJ:让代码像特工一样悄悄干活
android·面试·性能优化
叽哥10 小时前
Flutter Riverpod上手指南
android·flutter·ios