- 作者:老汪软件技巧
- 发表时间:2024-10-12 10:03
- 浏览量:
最近有一位大厂的兄弟联系我,说他们通过代理替换的方式hook方法时,有的可以生效,有的就不行,这让他百思不得其解。关于代理替换来实现hook的这种方式,可以参考维术的这篇文章,简言之,就是用一个新的对象来替换原有对象,而新的对象所属的类是我们通过静态或动态代理人为构造的类,这样一来,后续通过这个对象开展的方法调用就会进入到人为构造的类中。
问题
下面我们来看看这个问题的具体描述,为了聚焦问题,代码已做精简。
目标是替换Choreographer中的mHandler字段,hook它的dispatchMessage和sendMessageAtTime方法。
public final class Choreographer {
...
private final FrameHandler mHandler;
...
private final class FrameHandler extends Handler {
public FrameHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
...
}
}
...
}
为了实现这个目标,构造一个新类CustomHandler继承于Handler,然后实例化CustomHandler对象来替换Choreographer中的mHandler字段。修改完后,在release包里运行时出现了神奇的一幕:dispatchMeassage中的log会打印,但sendMessageAtTime中的log却没有打印;可是如果切换到debug包,两个方法的log又都会打印。
public class CustomHandler extends Handler {
@Override
public void dispatchMessage(Message msg) {
Log.i(TAG, "dispatchMessage is called.");
super.dispatchMessage(msg);
}
@Override
public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
Log.i(TAG, "sendMessageAtTime is called.");
return super.sendMessageAtTime(msg, uptimeMillis);
}
}
首先怀疑是不是sendMessageAtTime没有被调用到,增加调试信息后这个怀疑被排除,以下是该方法被调用的一个位置:
public void onVsync(long timestampNanos, long physicalDisplayId, int frame,
VsyncEventData vsyncEventData) {
...
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
...
}
如果是这个情况的话,那么问题只可能出在底层,因为上层的使用找不出纰漏。
侦探
像Handler和Choreographer这种framework中的代码,大部分都会提前编译,最终形成boot-framework.oat文件,在zygote启动时被加载进来。因此我们可以通过oatdump boot-framework.oat拿到Choreographer$FrameDisplayEventReceiver.onVsync方法的DEX字节码和编译后的机器码。为了简化讨论,下面只截取最关键的信息。
void android.view.Choreographer$FrameDisplayEventReceiver.onVsync(long, long, int, android.view.DisplayEventReceiver$VsyncEventData) (dex_method_idx=27333)
DEX CODE:
0x0074: 6e40 ce6a 4576 | invoke-virtual {v5, v4, v6, v7}, boolean android.view.Choreographer$FrameHandler.sendMessageAtTime(android.os.Message, long) // method@27342
CODE: (code_offset=0x006c0710 size=904)...
0x006c09f4: aa0003e2 mov x2, x0
0x006c09f8: aa0203fc mov x28, x2
0x006c09fc: aa1503e1 mov x1, x21
0x006c0a00: aa0303fd mov x29, x3
0x006c0a04: b0ff8520 adrp x0, #-0xf5b000 (addr -0x89b000)
0x006c0a08: 910e8000 add x0, x0, #0x3a0 (928)
0x006c0a0c: f9400c1e ldr lr, [x0, #24]
0x006c0a10: d63f03c0 blr lr
StackMap[23] (native_pc=0x6c0a14, dex_pc=0x74, register_mask=0x14600000, stack_mask=0b10000010000000000000000000000000000000000)
v0:#8 v1:#0 v2:r27 v3:r27/hi v4:r28 v5:r21 v6:r29 v7:r29/hi v8:r22 v9:r23 v10:r23/hi v11:r24 v12:r24/hi v13:r25 v14:r26
DEX字节码的指令为invoke-virtual,表明调用的sendMessageAtTime是virtual method。按照常规的理解,虚方法的调用需要通过vtable来找到具体实现,而vtable的查找则依赖对象所属的类。如果是这样的话,hook没理由不生效,因为对象所属的类已经被修改了。
可是机器码和我们的认知并不吻合:
adrp x0, #-0xf5b000 (addr -0x89b000)
add x0, x0, #0x3a0 (928)
ldr lr, [x0, #24]
blr lr
下面针对这四条指令做些详细的解释。
首先将该指令的地址按页(4KB)向下对齐:指令地址为0x6c0a04(文件内的相对地址,并非加载到内存里的绝对地址),4KB向下对齐后为0x6c0000。接着和立即数-0xf5b000相加:0x6c0000 + (-0xf5b000) = -0x89b000。最后将这个结果写入x0寄存器。它的真实目的是将boot-framework.oat文件向前偏移0x89b000的地址写入x0。那么这个文件向前偏移0x89b000指向什么呢?实际上它指向的是boot-framework.art的内存空间。.oat和.art文件不同,.oat里保存的主要是机器码,而.art保存的主要是Class、ArtMethod、ArtField这样的数据。将x0加上0x3a0,结果依然保存到x0中。这么做的目的是找到Handler类中sendMessageAtTime对应的ArtMethod的起始地址。-0xf5b000和0x3a0都是dex2oat编译期间根据内存布局计算而得的。将x0加上24得到一个新的地址,取出该地址处的值,存入lr寄存器中。这么做的目的是取出ArtMethod的entry_point_from_quick_compiled_code_,因为该字段在ArtMethod内部的偏移就是24。跳转到entry_point_from_quick_compiled_code_指向的地方去执行,这么做的目的是进行方法调用。
综合上面的分析可以看出,获得ArtMethod并不需要对象的类参与计算,也不需要vtable。这就是hook无法生效的原因,因为ArtMethod早已固定,根本不受代理对象的影响。可这与我们对虚方法的理解并不一致,若想彻底弄清楚这背后的原因,还得深入到dex2oat的源码里。
原因
代码编译环节,如果能在编译期间确定所调用的虚方法的最终实现,那么就可以将它转成static/direct(这二者处理方式一致)来处理,这算是编译器的某种优化(属于Sharpening优化的一部分)。具体的调用路径如下,感兴趣研究源码的兄弟可以查看这几个函数。
这项优化由下面这笔改动引入,按理说Android 12及之后的版本都会受到影响。
Devirtualize以后,HInvokeStaticOrDirect所生成的机器码并非只有一种,它取决于方法所在的位置。通常来说有三种情况,如下图所示。
第一种方式适用于代码位于Boot Image,且方法也位于Boot Image的情况;第二种方式适用于代码位于app,但方法位于Boot Image的情况;第三种方式适用于ArtMethod不位于Boot Image的情况,这些方法一般是运行时生成的。Choreographer$FrameDisplayEventReceiver.onVsync生成的机器码位于boot-framework.oat,而它调用的Handler.sendMessageAtTime(由于Choreographer$FrameHandler没有override该方法,所以最终调用的是父类的方法)所对应的ArtMethod位于boot-framework.art中,二者同属于Boot Image,所以适用于第一种情况,因此最终生成的机器码也如上文所述。
那么什么样的invoke-virtual会被devirtualize成invoke-static/direct呢?上文只提到了“如果能在编译期间确定所调用的虚方法的最终实现”,那么具体情况又是如何呢?
判断是否可以被devirtualize的逻辑位于FindVirtualOrInterfaceTarget函数,其中逻辑较多,最为常见的是该方法或者方法所属的类是否声明为final。如果声明为final,则表明方法不会被override,因此不会存在子类重新实现的情况,也就可以在编译期间确定invoke-virtual最终的调用目标。回到之前的源码,我们可以发现Choreographer$FrameHandler被声明为final,因此符合devirtualize的条件。
private final class FrameHandler extends Handler
行文至此,其实还有两个问题没有解释。
一个是dispatchMessage为什么可以hook成功?该方法主要调用的地方是Looper.loopOnce。查看它的机器码,可以发现这里采用的是vtable的方式,因此代理对象的类会参与ArtMethod的查询过程。
DEX CODE:
0x00a7: 6e20 8fa9 d500 | invoke-virtual {v5, v13}, void android.os.Handler.dispatchMessage(android.os.Message) // method@43407
CODE:
ldr w0, [x1]
ldr x0, [x0, #224]
ldr lr, [x0, #24]
blr lr
上面的机器码中,ldr w0, [x1]是从对象中取出class,ldr x0, [x0, #224]是从vtable中取出具体的ArtMethod指针。这里省去vtable pointer的获取,是因为vtable被embedded到class的尾部,如下所示。
那么这里为什么没有被devirtualize呢?原因是Message里的target字段属于Handler类,Handler并非final,且dispatchMessage也没有被声明为final,因此这个方法的具体实现在编译期间无法确定,只能等到运行时才知道。
[loopOnce]
msg.target.dispatchMessage(msg);
[Message.java]
/*package*/ Handler target;
第二个问题是为什么debug包里sendMessageAtTime也可以hook成功?原因是debuggable条件下,程序会走解释执行,因此AOT里的优化不起效果。
优化评估
采用vtable方式的invoke-virtual生成出来的机器码有三条load指令,而devirtualize成static/direct方式的机器码只有一条或两条load指令。更少的连续且独立的load指令意味着更好的性能,尤其当这些数据不在cache中的时候。
后记
通过代理替换的方式来hook虚方法,应该是不少App都会采用的方式。但随着虚拟机优化策略的调整,这种方式在某种条件下可能会失效。对于方法hook而言,替换ArtMethod的entrypoint会有更广的覆盖面,我之前的文章有过介绍,但它也会受到JIT的影响。所以世间难得万全法,最终还是回到那八个字:具体情况,具体分析。