我为Dexposed续一秒——论ART上运行时 Method AOP实现
两年前阿里开源了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...
剩余内容已隐藏