两年前阿里开源了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方法的执行非常简单:
- 想办法拿到这个Java方法所代表的ArtMethod对象
- 取出其entrypoint,然后跳转到此处开始执行
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 | private void callSetText(TextView textView) { |
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@framework@boot.oat
1 | 364: void android.widget.TextView.setText(java.lang.CharSequence) (dex_method_idx=28117) |
其中这个方法的code_offset = 0x037d8629; boot.oat的EXECUTABLE OFFSET 为0x02776000, boot.oat 在proc/
1 | 700a1000-72818000 r--p 00000000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat 72818000-74689000 r-xp 02777000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat 74689000-7468a000 rw-p 045e8000 103:1f 32772 /data/dalvik-cache/arm/system@framework@boot.oat |
其中 可执行段的地址为 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 | class MyThread extends Thread { |
上述例子中,如果仅仅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。
这种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正是使用这种方式实现的,它的基本原理如下图:
在讲解这张图之前,有必要说明一下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 | dff800f0 ; ldr pc, [pc] |
这样,在执行Log.i
这个函数的时候,会通过这第一段跳板直接跳转到 0x7f132450 这个地址开始执行。这个地址是我们预先分配好的一段内存,也是一段跳转函数,我们姑且称之为二段跳板。在接下来的二段跳板中,我们开始准备分发逻辑:
1 | ldr ip, 3f ; ip = source_method_address |
这段代码是用来判断是否需要执行Hook的,如果不需要,跳转到原函数的控制流,进而达到调用原函数的目的。接下来就是一些参数准备:
1 | str sp, [ip, #0] |
在参数准备好之后,直接跳转到另外一个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 | ldr ip, 4f |
其中,4f
处是我们预先分配好的一段16字节的内存(假设起始地址为base);我们把 sp 放到 (base)上,把r2寄存器(原第三个参数)放到 (base + 4),把r3(原第四个参数)放到 (base + 8),把 3f
(被Hook函数的地址)放到 (base + 12);然后把这个base 的地址放在r3寄存器里面,这样根据调用约定,我们的bridge函数就可以在第四个参数上收到四个打包好的数据,然后通过相同的访问方式就可以把原始数据取出来。这些数据中就包括了被Hook的原函数地址,通过这个地址,我们可以区分不同的被Hook函数,进而触发各自对应的处理逻辑。
入口重合的问题
在二段跳板函数的开始处,有这么一段代码:
1 | ldr ip, 3f ; ip = source_method_address |
也许你会问,这个比较逻辑是有必要的吗?除了达到调用原函数的目的之外,这个逻辑还有一个更重要的用途:区分入口相同,但是实际上Java方法完全不同的处理逻辑。
什么时候不同的Java函数的入口会一样呢?至少有下面几种情况:
- 所有ART版本上未被resolve的static函数
- Android N 以上的未被编译的所有函数
- 代码逻辑一模一样的函数
- 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 | dependencies { |
然后就可以在你的项目中做AOP Hook,比如说要拦截所有Java线程的创建,我们可以用如下代码:
1 | class ThreadMethodHook extends XC_MethodHook{ |
这里有2个AOP点,其一是 Thread.class 的run方法,拦截这个方法,我们可以知道所有通过Thread类本身创建的线程;其二是Thread的构造函数,这个Hook点我们可以知道运行时具体有哪些类继承了Thread.class类,在找到这样的子类之后,直接hook掉这个类的run方法,从而达到了拦截所有线程创建的目的。
当然,还有很多有趣的AOP点等待你去挖掘,这一切取决于您的想象力 :)
局限
上文提到,「要在ART上实现一个完善而稳定的Hook机制,几无可能」,epic也不例外:它也有它自己的缺点,有些是先天的,有些是后天的,还有一些我没有发现的 ~_~;比如说:
- 受限于dynamic callee-side rewrite机制,如果被Hook函数的code段太短以至于一个简单的trampoline跳转都放不下,那么epic无能为力。
- 如果ART中有深度内联,直接把本函数的代码内联到调用者,那么epic也搞不定。
- Android O(8.0)还没有去研究和实现。
- 当前仅支持thumb2/arm64指令集,arm32/x86/mips还没有支持。
- 在支持硬浮点的cpu架构,比如(armeabi-v7a, arm64-v8a)上,带有double/float参数的函数Hook可能有问题,没有充分测试。
- 还有一些其他机型上的,或者我没有发现的闪退。
我本人只在Android 5.0, 5.1, 6.0, 7.0, 7.1 的个别机型,以及这些机型的thumb2指令集,和6.0/7.1 的arm64指令集做过测试;其他的机型均未测试,因此这么长的文章还读到最后的你,不妨拿出你手头的手机帮我测试一下,在下感激不尽 :)