7

我为Dexposed续一秒——论ART上运行时 Method AOP实现

 3 years ago
source link: http://weishu.me/2017/11/23/dexposed-on-art/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

两年前阿里开源了Dexposed 项目,它能够在Dalvik上无侵入地实现运行时方法拦截,正如其介绍「enable ‘god’ mode for single android application」所言,能在非root情况下掌控自己进程空间内的任意Java方法调用,给我们带来了很大的想象空间。比如能实现运行时AOP,在线热修复,做性能分析工具(拦截线程、IO等资源的创建和销毁)等等。然而,随着ART取代Dalvik成为Android的运行时,一切都似乎戛然而止。

今天,我在ART上重新实现了Dexposed,在它能支持的平台(Android 5.0 ~ 7.1 Thumb2/ARM64)上,有着与Dexposed完全相同的能力和API;项目地址在这里 epic,感兴趣的可以先试用下:) 然后我们聊一聊ART上运行时Method AOP的故事。

ART有什么特别的?

为什么Dexposed能够在Dalvik上为所欲为,到ART时代就不行了呢?排除其他非技术因素来讲,ART确实比Dalvik复杂太多;更要命的是,从Android L到Android O,每一个Android版本中的ART变化都是天翻地覆的,大致列举一下:

  • Android L(5.0/5.1) 上的ART是在Dalvik上的JIT编译器魔改过来的,名为quick(虽然有个portable编译器,但是从未启用过);这个编译器会做一定程度的方法内联,因此很多基于入口替换的Hook方式一上来就跪了。
  • Android M(6.0) 上的ART编译器完全重新实现了:Optimizing。且不说之前在Android L上的Hook实现要在M上重新做一遍,这个编译器的寄存器分配比quick好太多,结果就是hook实现的时候你要是乱在栈或者寄存器上放东西,代码很容易就跑飞。
  • Android N(7.0/7.1) N 开始采用了混合编译的方式,既有AOT也有JIT,还伴随着解释执行;混合模式对Hook影响是巨大的,以至于Xposed直到今年才正式支持Android N。首先JIT的出现导致方法入口不固定,跑着跑着入口就变了,更麻烦的是还会有OSR(栈上替换),不仅入口变了,正在运行时方法的汇编代码都可能发生变化;其次,JIT的引入带来了更深度的运行时方法内联,这些都使得虚拟机层面的Hook更为复杂。
  • Android O(8.0) Android O的Runtime做了很多优化,传统Java VM有的一些优化手段都已经实现,比如类层次分析,循环优化,向量化等;除此之外,DexCache被删除,跨dex方法内联以及Concurrent compacting GC的引入,使得Hook技术变的扑朔迷离。

可以看出,ART不仅复杂,而且还爱折腾;一言不合就魔改,甚至重写。再加上Android的碎片化,这使得实现一个稳定的虚拟机层面上运行时Java Method AOP几无可能。

说到这里也许你会问,那substrate,frida等hook机制不是挺成熟了吗?跟这里说的ART Hook有什么联系与区别?事实上,substrate/frida 主要处理native层面的Hook,可以实现任意C/C++ 函数甚至地址处的调用拦截;而ART Java Method Hook/AOP 更多地是在虚拟机层面,用来Hook和拦截Java方法,虚拟机层面的Hook底层会使用于substrate等类似的Hook技术,但是还要处理虚拟机独有的特点,如GC/JNI/JIT等。

已有的一些方案

虽然ART上的运行时Java Method AOP实现较为困难,但还是有很多先驱者和探索者。最有名的莫过于AndFix(虽然它不能实现AOP);在学术界,还有两篇研究ART Hook的论文,一篇实现了Callee side dynamic rewrite,另一篇基于虚函数调用原理实现了vtable hook。另外,除了在讲epic之前,我们先看看这些已有的方案。

首先简单介绍下ART上的方法调用原理(本文不讨论解释模式,所有entrypoint均指compiled_code_entry_point)。在ART中,每一个Java方法在虚拟机(注:ART与虚拟机虽有细微差别,但本文不作区分,两者含义相同,下同)内部都由一个ArtMethod对象表示(native层,实际上是一个C++对象),这个native 的 ArtMethod对象包含了此Java方法的所有信息,比如名字,参数类型,方法本身代码的入口地址(entrypoint)等;暂时放下trampoline以及interpreter和jit不谈,一个Java方法的执行非常简单:

  1. 想办法拿到这个Java方法所代表的ArtMethod对象
  2. 取出其entrypoint,然后跳转到此处开始执行
1511369316918.png

entrypoint replacement

从上面讲述的ART方法调用原理可以得到一种很自然的Hook办法————直接替换entrypoint。通过把原方法对应的ArtMethod对象的entrypoint替换为目标方法的entrypoint,可以使得原方法被调用过程中取entrypoint的时候拿到的是目标方法的entry,进而直接跳转到目标方法的code段;从而达到Hook的目的。

AndFix就是基于这个原理来做热修复的,Sophix 对这个方案做了一些改进,也即整体替换,不过原理上都一样。二者在替换方法之后把原方法直接丢弃,因此无法实现AOP。AndroidMethodHook 基于Sophix的原理,用dexmaker动态生成类,将原方法保存下来,从而实现了AOP。

不过这种方案能够生效有一个前提:方法调用必须是先拿到ArtMethod,再去取entrypoint然后跳转实现调用。但是很多情况下,第一步是不必要的;系统知道你要调用的这个方法的entrypoint是什么,直接写死在汇编代码里,这样方法调用的时候就不会有取ArtMethod这个动作,从而不会去拿被替换的entrypoint,导致Hook失效。这种调用很典型的例子就是系统函数,我们看一下Android 5.0上 调用TextView.setText(Charsequence) 这个函数的汇编代码:

1
2
3
private void callSetText(TextView textView) {
textView.setText("hehe");
}

OAT文件中的汇编代码:

1
0x00037e10: e92d40e0	push    {r5, r6, r7, lr}
0x00037e14: b088    	sub     sp, sp, #32
0x00037e16: 1c07    	mov     r7, r0
0x00037e18: 9000    	str     r0, [sp, #0]
0x00037e1a: 910d    	str     r1, [sp, #52]
0x00037e1c: 1c16    	mov     r6, r2
0x00037e1e: 6978    	ldr     r0, [r7, #20]
0x00037e20: f8d00ef0	ldr.w   r0, [r0, #3824]
0x00037e24: b198    	cbz     r0, +38 (0x00037e4e)
0x00037e26: 1c05    	mov     r5, r0
0x00037e28: f24a6e29	movw    lr, #42537
0x00037e2c: f2c73e87	movt    lr, #29575
0x00037e30: f24560b0	movw    r0, #22192
0x00037e34: f6c670b4	movt    r0, #28596
0x00037e38: 1c31    	mov     r1, r6
0x00037e3a: 1c2a    	mov     r2, r5
0x00037e3c: f8d1c000	ldr.w   r12, [r1, #0]
suspend point dex PC: 0x0002
GC map objects:  v0 (r5), v1 ([sp + #52]), v2 (r6)
0x00037e40: 47f0    	blx     lr

看这两句代码:

1
0x00037e28: f24a6e29	movw    lr, #42537
0x00037e2c: f2c73e87	movt    lr, #29575

什么意思呢?lr = 0x7387a629,然后接着就blx跳转过去了。事实上,这个地址 0x7387a629 就是TextView.setText(Charsequence)` 这个方法entrypoint的绝对地址;我们可以把系统编译好的oat代码弄出来看一看:

adb shell oatdump –oat-file=/data/dalvik-cache/arm/system@[email protected]

1
2
3
4
5
6
7
364: void android.widget.TextView.setText(java.lang.CharSequence) (dex_method_idx=28117)
// 略掉无关内容
QuickMethodFrameInfo
frame_size_in_bytes: 48
core_spill_mask: 0x000081e0 (r5, r6, r7, r8, r15)
fp_spill_mask: 0x00000000
CODE: (code_offset=0x037d8629 size_offset=0x037d8624 size=64).

其中这个方法的code_offset = 0x037d8629; boot.oat的EXECUTABLE OFFSET 为0x02776000, boot.oat 在proc//maps 中的基址如下:

rw-p 00000000 103:1f 32773link
1
700a1000-72818000 r--p 00000000 103:1f 32772     /data/dalvik-cache/arm/system@[email protected]
72818000-74689000 r-xp 02777000 103:1f 32772     /data/dalvik-cache/arm/system@[email protected]
74689000-7468a000 rw-p 045e8000 103:1f 32772     /data/dalvik-cache/arm/system@[email protected]

其中 可执行段的地址为 0x72818000,因此算出来的 TextView.setText(CharSequence) 这个方法的地址为 0x037d8629 - 0x02776000 + 0x72818000 = 0x7387a629;丝毫不差。

为什么会这么干呢?因为boot.oat 这个文件在内存中的加载地址是固定的(如果发生变化,所有APP的oat文件会重新生成,于是又重新固定),因此里面的每一个函数的绝对地址也是固定的,如果你调用系统函数,ART编译器知道系统每一个函数入口的绝对地址,根本没有必要再去查找方法,因此生成的代码中没有任何查找过程。

所以,从原理上讲,如果要支持系统方法的Hook,这种方案在很多情况下是行不通的。当然如果你Hook自己App的代码,并且调用方和被调用方在不同的dex,在Android O之前是没什么问题的(在Android O之前跨dex一定会走方法查找)。

从上面的分析可以看出,就算不查找ArtMethod,这个ArtMethod的enntrypoint所指向代码是一定要用到的(废话,不然CPU执行什么,解释执行在暂不讨论)。既然替换入口的方式无法达到Hook所有类型方法的目的,那么如果不替换入口,而是直接修改入口里面指向的代码呢?(这种方式有个高大上的学名:callee side dynamic rewriting)

dynamic callee-side rewriting

第一次学到这个词是在 Wißfeld, Marvin 的论文 ArtHook: Callee-side Method Hook Injection on the New Android Runtime ART上。这篇文章很精彩,讲述了各种Hook的原理,并且他还在ART上实现了 dynamic callee-side rewriting 的Hook技术,代码在github上:ArtHook

通俗地讲,dynamic callee-side rewriting其实就是修改entrypoint 所指向的代码。但是有个基本问题:Hook函数和原函数的代码长度基本上是不一样的,而且为了实现AOP,Hook函数通常比原函数长很多。如果直接把Hook函数的代码段copy到原函数entrypoint所指向的代码段,很可能没地儿放。因此,通常的做法是写一段trampoline。也就是把原函数entrypoint所指向代码的开始几个字节修改为一小段固定的代码,这段代码的唯一作用就是跳转到新的位置开始执行,如果这个「新的位置」就是Hook函数,那么基本上就实现了Hook;这种跳板代码我们一般称之为trampoline/stub,比如Android源码中的 art_quick_invoke_stub/art_quick_resolution_trampoline等。

这篇论文基本上指明了ART上Method Hook的方向,而且Wißfeld 本人的项目 ArtHook也差不多达到了这个目的。不过他的Hook实现中,被用来替换的方法必须写死在代码中,因此无法达到某种程度上的动态Hook。比如,我想知道所有线程的创建和销毁,因此选择拦截Thread.class 的run方法;但是Thread子类实现的run方法不一定会调用 Thread 的run,所以可能会漏掉一些线程。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyThread extends Thread {
@Override
public void run() {
// do not call super
Log.i(TAG, "dang dang dang..");
}
}

new Thread(new Runnable() {
@Override
public void run() {
Log.i(TAG, "I am started..");
}
}).start(); // Thread1

new Thread() {
@Override
public void run() {
// super.run();
// do not call super.
}
}.start(); // Thread 2

new MyThread().start();// Thread 3

上述例子中,如果仅仅Hook Thread.class 的run方法,只有 Thread1能被发现,其他两个都是漏网之鱼。既然如此,我们可以Hook线程的构造函数(子类必定调用父类),从而知道有哪些自定义的线程类被创建,然后直接Hook这些在运行时才发现的类,就能知道所有Java线程的创建和销毁。

要解决「不写死Hook方法」这个问题有两种思路:其一,直接在运行时凭空创建出一个Method;其二,把Hook收拢到一个统一的方法,在这个方法中做分发处理。

第一种方式:凭空创建Method,并非new 一个Method对象就行了,这个方法必须要有你想执行的代码,以及必要的declaring_class, dex_method_index 等成员;要达到这个目的,可以使用运行时字节码生成技术,比如 dexmaker。另外,Java本身的动态代理机制也可以也会动态生成代理类,在代理类中有全新创建的方法,如果合适处理,也能达到目的;不过这种方案貌似还无人实现,反倒是entrypoint replcement中有人这么做 :(

第二种方式:用一个函数来处理hook的分发逻辑,这是典型的xposed/dexposed 实现方式。不过Xposed支持Android N过程中直接修改了 libart.so,这种方式对进程内Hook是行不通的。dexposed的 dev_art 分支有尝试过实现,但是几乎不可用。

有趣地是,还有另外一个项目 YAHFA 也提出了一种Hook方案;不过他这种方案看起来是entrypoint replacement和dynamic callee-side rewriting的结合体:把entrypoint替换为自己的trampoline函数的地址,然后在trampoline继续处理跳转逻辑。作者的博客值得一看。

vtable replacement

除了传统的类inline hook 的 dynamic callee-side rewriting 的Hook方式,也有基于虚拟机特定实现的Hook技术,比如vtable hook。ART中的这种Hook方式首先是在论文 ARTDroid: A Virtual-Method Hooking Framework on Android ART Runtime 中提出的,作者的实现代码也在github上 art-hooking-vtable

1511342723016.png

这种Hook方式是基于invoke-virtual调用原理的;简单来讲,ART中调用一个virtual method的时候,会查相应Class类里面的一张表,如果修改这张表对应项的指向,就能达到Hook的目的。更详细的实现原理,作者的论文以及他的博客讲的很详细,感兴趣的可以自行围观。

这种方式最大的缺点是只能Hook virtual方法,虽然根据作者提供的数据:

59.2% of these methods are declared as virtual
1.0% are non-virtual
39.8% methods not found

高达99%的方法都能被hook住,不管你信不信,反正我是不信。所以,这种Hook方式无法Hook所有的调用过程,只能作为一种补充手段使用。

epic的实现

了解到已有项目的一些实现原理以及当前的现状,我们可以知道,要实现一个较为通用的Hook技术,几乎只有一条路———基于dynamic dispatch的dynamic callee-side rewriting。epic正是使用这种方式实现的,它的基本原理如下图:

1511354138004.png

在讲解这张图之前,有必要说明一下ART中的函数的调用约定。以Thumb2为例,子函数调用的参数传递是通过寄存器r0~r3 以及sp寄存器完成的。r0 ~ r3 依次传递第一个至第4个参数,同时 sp, (sp + 4), (sp + 8), (sp + 12) 也存放着r0~r3上对应的值;多余的参数通过 sp传递,比如 *(sp + 16)放第四个参数,以此类推。同时,函数的返回值放在r0寄存器。如果一个参数不能在一个寄存器中放下,那么会占用2个或多个寄存器。

在ART中,r0寄存器固定存放被调用方法的ArtMethod指针,如果是non-static 方法,r1寄存器存放方法的this对象;另外,只有long/double 占用8bytes,其余所有基本类型和对象类型都占用4bytes。不过这只是基本情形,不同的ART版本对这个调用约定有不同的处理,甚至不完全遵循。

好了我们回到epic。如上图所述,如果我们要Hook android.util.Log.i 这个方法,那么首先需要找到这个方法的entrypoint,可以通过这个方法的ArtMethod对象得到;然后我们直接修改内存,把这个函数的前8个字节:

1
e92d40e0  ; push    {r5, r6, r7, lr} 
b088      ; sub     sp, sp, #32 
1c07      ; mov     r7, r0

修改为一段跳转指令:

1
2
dff800f0  ; ldr pc, [pc]
7f132450 ; trampoline2 address

这样,在执行Log.i 这个函数的时候,会通过这第一段跳板直接跳转到 0x7f132450 这个地址开始执行。这个地址是我们预先分配好的一段内存,也是一段跳转函数,我们姑且称之为二段跳板。在接下来的二段跳板中,我们开始准备分发逻辑:

1
2
3
ldr ip, 3f  ; ip = source_method_address
cmp r0, ip ; r0 == ip ?
bne.w 5f ; if r0 != source_method_address, then jump to label5.

这段代码是用来判断是否需要执行Hook的,如果不需要,跳转到原函数的控制流,进而达到调用原函数的目的。接下来就是一些参数准备:

1
2
3
4
5
6
7
8
str sp, [ip, #0]
str r2, [ip, #4]
str r3, [ip, #8]
mov r3, ip
ldr r2, 3f
str r2, [ip, #12]
mov r2, r9
ldr pc, 2f ; jump to target_method_entry

在参数准备好之后,直接跳转到另外一个Java方法的入口开始执行,这个方法称之为bridge方法。bridge方法接管控制流之后我们就回到了Java世界,自此之后我们就可以开始处理AOP逻辑。

基本原理比较简单,但是在实现过程中会有很多问题,这里简单交代一下。

bridge函数分发以及堆栈平衡

从上面的基本介绍我们可以知道,方法的AOP逻辑是交给一个Java的bridge函数统一处理的,那么这个统一的函数如何区分每一个被Hook的方法,进而调用对应的回调函数呢?

最直接的办法是把被Hook的方法通过额外参数直接传递给bridge函数,而传递参数可以通过寄存器和堆栈实现。用来传递参数的寄存器(如r0~r3)最好是不要直接改的,不然我们的处理函数可能就收到不到原函数对应的参数,进而无法完成调用原函数的逻辑。如果用堆栈传递参数的话,我们是直接在堆栈上分配内存吗?

事实证明这样做是不行的,如果我们在二段跳板代码里面开辟堆栈,进而修改了sp寄存器;那么在我们修改sp到调用bridge函数的这段时间里,堆栈结构与不Hook的时候是不一样的(虽然bridge函数执行完毕之后我们可以恢复正常);在这段时间里如果虚拟机需要进行栈回溯,sp被修改的那一帧会由于回溯不到对应的函数引发致命错误,导致Runtime 直接Abort。什么时候会回溯堆栈?发生异常或者GC的时候。最直观的感受就是,如果bridge函数里面有任何异常抛出(即使被try..catch住)就会使虚拟机直接崩溃。dexposed的 dev_art 分支中的AOP实现就有这个问题。

既然无法分配新的堆栈,那么能否找到空闲的空间使用呢?上面我们在介绍Thumb2调用约定的时候提到,r0~r3传递第一至第四个参数,sp ~ sp + 12 也传递第一至第四个参数,看起来好像是重复了;我们能否把 sp ~ sp + 12 这段空间利用起来呢?

但是实际实现的过程中又发现,此路不通。你以为就你会耍这点小聪明吗?虚拟机本身也是知道 sp + 12 这段空间相当于是浪费的,因此他直接把这段空间当做类似寄存器使用了;如果你把额外的参数丢在这里,那么根本就收不到参数,因为函数调用一旦发生,ART很可能直接把这段内存直接使用了。

既然如此,我们只能把要传递的一个或者多个额外参数打包在一起(比如放在结构体),通过指针一块传递了。再此观察我们上面的二段跳板代码:

1
2
3
4
5
6
7
ldr ip, 4f
str sp, [ip, #0]
str r2, [ip, #4]
str r3, [ip, #8]
mov r3, ip
ldr r2, 3f
str r2, [ip, #12]

其中,4f 处是我们预先分配好的一段16字节的内存(假设起始地址为base);我们把 sp 放到 (base)上,把r2寄存器(原第三个参数)放到 (base + 4),把r3(原第四个参数)放到 (base + 8),把 3f(被Hook函数的地址)放到 (base + 12);然后把这个base 的地址放在r3寄存器里面,这样根据调用约定,我们的bridge函数就可以在第四个参数上收到四个打包好的数据,然后通过相同的访问方式就可以把原始数据取出来。这些数据中就包括了被Hook的原函数地址,通过这个地址,我们可以区分不同的被Hook函数,进而触发各自对应的处理逻辑。

入口重合的问题

在二段跳板函数的开始处,有这么一段代码:

1
2
3
ldr ip, 3f  ; ip = source_method_address
cmp r0, ip ; r0 == ip ?
bne.w 5f ; if r0 != source_method_address, then jump to label5.

也许你会问,这个比较逻辑是有必要的吗?除了达到调用原函数的目的之外,这个逻辑还有一个更重要的用途:区分入口相同,但是实际上Java方法完全不同的处理逻辑。

什么时候不同的Java函数的入口会一样呢?至少有下面几种情况:

  1. 所有ART版本上未被resolve的static函数
  2. Android N 以上的未被编译的所有函数
  3. 代码逻辑一模一样的函数
  4. JNI函数

static函数是lazy resolve的,在方法没有被调用之前,static函数的入口地址是一个跳板函数,名为 art_quick_resolution_trampoline,这个跳转函数做的事情就是去resvole原始函数,然后进行真正的调用逻辑;因此没有被调用的static函数的entrypoint都是一样的。

Android N以上,APK安装的时候,默认是不会触发AOT编译的;因此如果刚安装完你去看apk生成的OAT文件,会发现里面的code都是空。在这些方法被resolve的时候,如果ART发现code是空,会把entrypoint设置为解释执行的入口;接下来如果此方法被执行会直接进入到解释器。所以,Android N上未被编译的所有方法入口地址都相同。

如果代码逻辑完全一样,那么AOT编译器会发现这完全可以用一个函数来代替,于是这些函数都有了同一个入口地址;而JNI函数由于函数体都是空(也即所有代码相同),理所当然会共享同一个入口。

如果没有这段处理逻辑,你会发现你Hook一个函数的时候,很可能莫名其妙滴Hook了一堆你压根都不知道是什么的函数。

指针与对象转换

在基本的bridge函数调用(从汇编进入Java世界)的问题搞定之后,我们会碰到一个新问题:在bridge函数中接受到的参数都是一些地址,但是原函数的参数明明是一些对象,怎么把地址还原成原始的参数呢?

如果传递的是基本类型,那么接受到的地址其实就是基本类型值的表示;但是如果传递的是对象,那接受到的 int/long 是个什么东西?

这个问题一言难尽,它的背后是ART的对象模型;这里我简单说明一下。一个最直观的问题就是:JNI中的 jobject,Java中的Object,ART 中的 art::mirror::Object 到底是个什么关系?

实际上,art::mirror::Object 是 Java的Object在Runtime中的表示,java.lang.Object的地址就是art::mirror::Object的地址;但是jobject略有不同,它并非地址,而是一个句柄(或者说透明引用)。为何如此?

因为JNI对于ART来说是外部环境,如果直接把ART中的对象地址交给JNI层(也就是jobject直接就是Object的地址),其一不是很安全,其二直接暴露内部实现不妥。就拿GC来说,虚拟机在GC过程中很可能移动对象,这样对象的地址就会发生变化,如果JNI直接使用地址,那么对GC的实现提出了很高要求。因此,典型的Java虚拟机对JNI的支持中,jobject都是句柄(或者称之为透明引用);ART虚拟机内部可以在joject与 art::mirror::Object中自由转换,但是JNI层只能拿这个句柄去标志某个对象。

那么jobject与java.lang.Object如何转换呢?这个so easy,直接通过一次JNI调用,ART就自动完成了转换。

因此归根结底,我们需要找到一个函数,它能实现把 art::mirror::Object 转换为 jobject对象,这样我们可以通过JNI进而转化为Java对象。这样的函数确实有,那就是:

1
art::JavaVMExt::AddWeakGlobalReference(art::Thread*, art::mirror::Object*)

此函数在 libart.so中,我们可以通过 dlsym拿到函数指针,然后直接调用。不过这个函数有一个art::Thread 的参数,如何拿到这个参数呢?查阅 art::Thread 的源码发现,这个 art::Thread 与 java.lang.Thread 也有某种对应关系,它们是通过peer结合在一起的(JNI文档中有讲)。也就是说,java.lang.Thread类中的 nativePeer 成员代表的就是当前线程的 art::Thread对象。这个问题迎刃而解。

Android N无法dlsym

上文提到,为了实现对象和指针的转换,我们需要 dlsym 一个 libart.so 中的导出函数;但不幸地是,在Android N中,Google禁止了这种行为,如果你用 dlsym 去取符号,返回的结果是nullptr。怎么办呢?

libart.so 不过是一个加载在内存中的elf文件而已。我们通过读取 /proc/self/maps 拿到这个文件的加载基地址,然后直接解析ELF文件格式,查出这个符号在ELF文件中的偏移,再加上内存基址,就能得到这个符号真正的地址。不过这过程已经有人实现了,而且放在了github上:Nougat_dlfunctions 可以直接使用 :)

Android N 解释执行

Android N采用了混合编译的模式,既有解释执行,也有AOT和JIT;APK刚安装完毕是解释执行的,运行时JIT会收集方法调用信息,必要的时候直接编译此方法,甚至栈上替换;在设备空闲时,系统会根据收集到的信息执行AOT操作。

那么在APK刚装完然后使用的那么几次,方法都是解释执行的,我们要Hook掉解释执行的入口吗?这当然可以,但是如果解释执行到一半方法入口被替换为JIT编译好的机器码的入口,那么本次Hook就会失效;我们还需要把JIT编译的机器码入口也拦截住。但是问题是,我们何时知道JIT执行完成?

所以这种方式实行起来比较麻烦,还不如一开始就全部是机器码 这样我们只用Hook机器码的entrypoint就可以了。事实上,Android N可以手动触发AOT全量编译,如 官方文档 所述,可以通过如下命令手动执行AOT编译:

adb shell cmd package compile -m speed -f

这样一来,我们一般情况下就不用管解释器的事了。

虽然多这么一个步骤,勉强能解决问题,但还是有点小瑕疵;(毕竟要多这么一步嘛!何况如果这个投入线上使用,你指望用户给你主动编译?)在研究了一段时间的JIT代码之后,我发现可以主动调用JIT编译某个方法。这样,在Hook之前我们可以先请求JIT编译此方法,得到机器码的entrypoint,然后按照正常的流程Hook即可。具体如何调用JIT可以参阅epic的源码

Android N JIT编译

上文提到Android N上开启了JIT编译器,即使我们手动触发全量AOT编译,在运行时这种机制依然存在;JIT的一个潜在隐患就是,他有可能动态修改代码,这使得在Android N上的Hook可能随机出现crash。

记得我在刚实现完Android N上的Hook之后,发现我的测试case偶尔会崩溃,崩溃过程完全没有规律,而且崩溃的错误几乎都是SIG 11。当时追查了一段时间,觉得这种随机崩溃可能跟2个原因有关:GC或者JIT;不过一直没有找到证据。

某天半夜我发现一个有趣的现象,如果我把测试case中的Logcat日志输出关掉,崩溃的概率会小很多——如果输出Logcat可能测试八九次就闪退了,但如果关掉日志,要数十次或者几乎不会闪退。当时我就怀疑是不是碰上了薛定谔猫。

理性分析了一番之后我觉得这种尺度不可能触发量子效应,于是我只能把锅摔倒Log头上。我在想是不是Log有IO操作导致hook过程太慢了使得这段时间别的线程有机会修改代码?于是我在Hook过程中Sleep 5s发现一点问题没有。实在没辙,我就一条条删Log,结果发现一个神奇的现象:Log越多越容易崩。然后我就写个循环输出日志100次,结果几乎是毕现闪退。

事情到这里我就瞬间明白了:调用Log的过程中很有可能由于Log函数调用次数过多进而达到JIT编译的阈值从而触发了JIT,这时候JIT线程修改了被执行函数的代码,而Hook的过程也会修改代码,这导致内存中的值不可预期,从而引发随机crash。

按照这种情况推测的话,JIT的存在导致Android N上的Hook几乎是毕现闪退的。因为我的测试demo代码量很少,一个稍微有点规模的App很容易触发JIT编译,一旦在JIT过程中执行Hook,那么必崩无疑。

因此比较好的做法是,在Hook的过程中暂停所有其他线程,不让它们有机会修改代码;在Hook完毕之后在恢复执行。那么问题来了,如何暂停/恢复所有线程?Google了一番发现有人通过ptrace实现:开一个linux task然后挨个ptrace本进程内的所有子线程,这样就是实现了暂停。这种方式很重而且不是特别稳定,于是我就放弃了。ART虚拟机内部一定也有暂停线程的需求(比如GC),因此我可以选择直接调用ART的内部函数。

在源码里面捞了一番之后果然在thread_list.cc 中找到了这样的函数 resumeAll/suspendAll;不过遗憾的是这两个函数是ThreadList类的成员函数,要调用他们必须拿到ThreadList的指针;一般情况下是没有比较稳定的方式拿到这个对象的。不过好在Android 源码通过RAII机制对 suspendAll/resumeAll做了一个封装,名为 ScopedSuspendAll 这类的构造函数里面执行暂停操作,析构函数执行恢复操作,在栈上分配变量此类型的变量之后,在这个变量的作用域内可以自动实现暂停和恢复。因此我只需要用 dlsym 拿到构造函数和析构函数的符号之后,直接调用就能实现暂停恢复功能。详细实现见 epic 源码

写了这么多,实际上还有很多想写的没有写完;比如Android M Optimizing编译器上的寄存器分配问题,long/double参数的处理细节,不同ART版本的调用约定 与 ATPCS/AAPCS之间不同等;不过来日方长,这些问题以后在慢慢道来吧 :)

扯了这么久的实现原理,我们来看看这玩意儿具体怎么用吧。只需要在你的项目中加入epic的依赖即可(jcenter 仓库):

1
2
3
dependencies {
compile 'me.weishu:epic:0.1.2@aar'
}

然后就可以在你的项目中做AOP Hook,比如说要拦截所有Java线程的创建,我们可以用如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ThreadMethodHook extends XC_MethodHook{
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Thread t = (Thread) param.thisObject;
Log.i(TAG, "thread:" + t + ", started..");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Thread t = (Thread) param.thisObject;
Log.i(TAG, "thread:" + t + ", exit..");
}
}

DexposedBridge.hookAllConstructors(Thread.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Thread thread = (Thread) param.thisObject;
Class<?> clazz = thread.getClass();
if (clazz != Thread.class) {
Log.d(TAG, "found class extend Thread:" + clazz);
DexposedBridge.findAndHookMethod(clazz, "run", new ThreadMethodHook());
}
Log.d(TAG, "Thread: " + thread.getName() + " class:" + thread.getClass() + " is created.");
}
});
DexposedBridge.findAndHookMethod(Thread.class, "run", new ThreadMethodHook());

这里有2个AOP点,其一是 Thread.class 的run方法,拦截这个方法,我们可以知道所有通过Thread类本身创建的线程;其二是Thread的构造函数,这个Hook点我们可以知道运行时具体有哪些类继承了Thread.class类,在找到这样的子类之后,直接hook掉这个类的run方法,从而达到了拦截所有线程创建的目的。

当然,还有很多有趣的AOP点等待你去挖掘,这一切取决于您的想象力 :)

上文提到,「要在ART上实现一个完善而稳定的Hook机制,几无可能」,epic也不例外:它也有它自己的缺点,有些是先天的,有些是后天的,还有一些我没有发现的 ~_~;比如说:

  1. 受限于dynamic callee-side rewrite机制,如果被Hook函数的code段太短以至于一个简单的trampoline跳转都放不下,那么epic无能为力。
  2. 如果ART中有深度内联,直接把本函数的代码内联到调用者,那么epic也搞不定。
  3. Android O(8.0)还没有去研究和实现。
  4. 当前仅支持thumb2/arm64指令集,arm32/x86/mips还没有支持。
  5. 在支持硬浮点的cpu架构,比如(armeabi-v7a, arm64-v8a)上,带有double/float参数的函数Hook可能有问题,没有充分测试。
  6. 还有一些其他机型上的,或者我没有发现的闪退。

我本人只在Android 5.0, 5.1, 6.0, 7.0, 7.1 的个别机型,以及这些机型的thumb2指令集,和6.0/7.1 的arm64指令集做过测试;其他的机型均未测试,因此这么长的文章还读到最后的你,不妨拿出你手头的手机帮我测试一下,在下感激不尽 :)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK