兩年前阿里開源了Dexposed 項(xiàng)目晋被,它能夠在Dalvik上無侵入地實(shí)現(xiàn)運(yùn)行時(shí)方法攔截墨微,正如其介紹「enable 'god' mode for single android application」所言泉瞻,能在非root情況下掌控自己進(jìn)程空間內(nèi)的任意Java方法調(diào)用牺蹄,給我們帶來了很大的想象空間沙兰。比如能實(shí)現(xiàn)運(yùn)行時(shí)AOP舀奶,在線熱修復(fù),做性能分析工具(攔截線程涧至、IO等資源的創(chuàng)建和銷毀)等等南蓬。然而,隨著ART取代Dalvik成為Android的運(yùn)行時(shí)蒜焊,一切都似乎戛然而止。
今天优妙,我在ART上重新實(shí)現(xiàn)了Dexposed,在它能支持的平臺(Android 5.0 ~ 7.1 Thumb2/ARM64)上邪意,有著與Dexposed完全相同的能力和API;項(xiàng)目地址在這里 epic策菜,感興趣的可以先試用下:) 然后我們聊一聊ART上運(yùn)行時(shí)Method AOP的故事翠霍。
ART有什么特別的?
為什么Dexposed能夠在Dalvik上為所欲為蒋情,到ART時(shí)代就不行了呢?排除其他非技術(shù)因素來講狈谊,ART確實(shí)比Dalvik復(fù)雜太多;更要命的是赎瞎,從Android L到Android O,每一個(gè)Android版本中的ART變化都是天翻地覆的敞临,大致列舉一下:
- Android L(5.0/5.1) 上的ART是在Dalvik上的JIT編譯器魔改過來的,名為quick(雖然有個(gè)portable編譯器,但是從未啟用過)洽沟;這個(gè)編譯器會做一定程度的方法內(nèi)聯(lián),因此很多基于入口替換的Hook方式一上來就跪了踪区。
- Android M(6.0) 上的ART編譯器完全重新實(shí)現(xiàn)了:Optimizing。且不說之前在Android L上的Hook實(shí)現(xiàn)要在M上重新做一遍传泊,這個(gè)編譯器的寄存器分配比quick好太多,結(jié)果就是hook實(shí)現(xiàn)的時(shí)候你要是亂在棧或者寄存器上放東西校读,代碼很容易就跑飛。
- Android N(7.0/7.1) N 開始采用了混合編譯的方式雁芙,既有AOT也有JIT,還伴隨著解釋執(zhí)行裂明;混合模式對Hook影響是巨大的,以至于Xposed直到今年才正式支持Android N。首先JIT的出現(xiàn)導(dǎo)致方法入口不固定荠瘪,跑著跑著入口就變了趁餐,更麻煩的是還會有OSR(棧上替換),不僅入口變了臀突,正在運(yùn)行時(shí)方法的匯編代碼都可能發(fā)生變化;其次,JIT的引入帶來了更深度的運(yùn)行時(shí)方法內(nèi)聯(lián)边翁,這些都使得虛擬機(jī)層面的Hook更為復(fù)雜。
- Android O(8.0) Android O的Runtime做了很多優(yōu)化,傳統(tǒng)Java VM有的一些優(yōu)化手段都已經(jīng)實(shí)現(xiàn)焰坪,比如類層次分析,循環(huán)優(yōu)化,向量化等炬守;除此之外酣藻,DexCache被刪除衣洁,跨dex方法內(nèi)聯(lián)以及Concurrent compacting GC的引入砖第,使得Hook技術(shù)變的撲朔迷離。
可以看出,ART不僅復(fù)雜考赛,而且還愛折騰;一言不合就魔改,甚至重寫鸠项。再加上Android的碎片化,這使得實(shí)現(xiàn)一個(gè)穩(wěn)定的虛擬機(jī)層面上運(yùn)行時(shí)Java Method AOP幾無可能。
說到這里也許你會問,那substrate呼巴,frida等hook機(jī)制不是挺成熟了嗎诊赊?跟這里說的ART Hook有什么聯(lián)系與區(qū)別遵馆?事實(shí)上秆撮,substrate/frida 主要處理native層面的Hook,可以實(shí)現(xiàn)任意C/C++ 函數(shù)甚至地址處的調(diào)用攔截舒裤;而ART Java Method Hook/AOP 更多地是在虛擬機(jī)層面亏栈,用來Hook和攔截Java方法黎侈,虛擬機(jī)層面的Hook底層會使用于substrate等類似的Hook技術(shù),但是還要處理虛擬機(jī)獨(dú)有的特點(diǎn)休吠,如GC/JNI/JIT等梅尤。
已有的一些方案
雖然ART上的運(yùn)行時(shí)Java Method AOP實(shí)現(xiàn)較為困難赡盘,但還是有很多先驅(qū)者和探索者葱淳。最有名的莫過于AndFix(雖然它不能實(shí)現(xiàn)AOP)霉咨;在學(xué)術(shù)界蛙紫,還有兩篇研究ART Hook的論文,一篇實(shí)現(xiàn)了Callee side dynamic rewrite途戒,另一篇基于虛函數(shù)調(diào)用原理實(shí)現(xiàn)了vtable hook。另外僵驰,除了在講epic之前,我們先看看這些已有的方案蒜茴。
首先簡單介紹下ART上的方法調(diào)用原理(本文不討論解釋模式星爪,所有entrypoint均指compiled_code_entry_point)。在ART中粉私,每一個(gè)Java方法在虛擬機(jī)(注:ART與虛擬機(jī)雖有細(xì)微差別顽腾,但本文不作區(qū)分,兩者含義相同诺核,下同)內(nèi)部都由一個(gè)ArtMethod對象表示(native層抄肖,實(shí)際上是一個(gè)C++對象),這個(gè)native 的 ArtMethod對象包含了此Java方法的所有信息窖杀,比如名字漓摩,參數(shù)類型,方法本身代碼的入口地址(entrypoint)等入客;暫時(shí)放下trampoline以及interpreter和jit不談管毙,一個(gè)Java方法的執(zhí)行非常簡單:
- 想辦法拿到這個(gè)Java方法所代表的ArtMethod對象
- 取出其entrypoint,然后跳轉(zhuǎn)到此處開始執(zhí)行
entrypoint replacement
從上面講述的ART方法調(diào)用原理可以得到一種很自然的Hook辦法————直接替換entrypoint桌硫。通過把原方法對應(yīng)的ArtMethod對象的entrypoint替換為目標(biāo)方法的entrypoint夭咬,可以使得原方法被調(diào)用過程中取entrypoint的時(shí)候拿到的是目標(biāo)方法的entry,進(jìn)而直接跳轉(zhuǎn)到目標(biāo)方法的code段铆隘;從而達(dá)到Hook的目的卓舵。
AndFix就是基于這個(gè)原理來做熱修復(fù)的,Sophix 對這個(gè)方案做了一些改進(jìn)咖驮,也即整體替換边器,不過原理上都一樣训枢。二者在替換方法之后把原方法直接丟棄,因此無法實(shí)現(xiàn)AOP忘巧。AndroidMethodHook 基于Sophix的原理恒界,用dexmaker動態(tài)生成類,將原方法保存下來砚嘴,從而實(shí)現(xiàn)了AOP十酣。
不過這種方案能夠生效有一個(gè)前提:方法調(diào)用必須是先拿到ArtMethod,再去取entrypoint然后跳轉(zhuǎn)實(shí)現(xiàn)調(diào)用际长。但是很多情況下耸采,第一步是不必要的;系統(tǒng)知道你要調(diào)用的這個(gè)方法的entrypoint是什么工育,直接寫死在匯編代碼里虾宇,這樣方法調(diào)用的時(shí)候就不會有取ArtMethod這個(gè)動作,從而不會去拿被替換的entrypoint如绸,導(dǎo)致Hook失效嘱朽。這種調(diào)用很典型的例子就是系統(tǒng)函數(shù),我們看一下Android 5.0上 調(diào)用TextView.setText(Charsequence)
這個(gè)函數(shù)的匯編代碼:
private void callSetText(TextView textView) {
textView.setText("hehe");
}
OAT文件中的匯編代碼:
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
看這兩句代碼:
0x00037e28: f24a6e29 movw lr, #42537
0x00037e2c: f2c73e87 movt lr, #29575
什么意思呢怔接?lr = 0x7387a629搪泳,然后接著就blx跳轉(zhuǎn)過去了。事實(shí)上扼脐,這個(gè)地址 0x7387a629
就是TextView.setText(Charsequence)` 這個(gè)方法entrypoint的絕對地址岸军;我們可以把系統(tǒng)編譯好的oat代碼弄出來看一看:
adb shell oatdump --oat-file=/data/dalvik-cache/arm/system@framework@boot.oat
364: void android.widget.TextView.setText(java.lang.CharSequence) (dex_method_idx=28117)
// 略掉無關(guān)內(nèi)容
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).
其中這個(gè)方法的code_offset = 0x037d8629; boot.oat的EXECUTABLE OFFSET 為0x02776000, boot.oat 在proc/<pid>/maps 中的基址如下:
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
其中 可執(zhí)行段的地址為 0x72818000,因此算出來的 TextView.setText(CharSequence) 這個(gè)方法的地址為 0x037d8629 - 0x02776000 + 0x72818000 = 0x7387a629
瓦侮;絲毫不差艰赞。
為什么會這么干呢?因?yàn)閎oot.oat 這個(gè)文件在內(nèi)存中的加載地址是固定的(如果發(fā)生變化脏榆,所有APP的oat文件會重新生成猖毫,于是又重新固定),因此里面的每一個(gè)函數(shù)的絕對地址也是固定的须喂,如果你調(diào)用系統(tǒng)函數(shù)吁断,ART編譯器知道系統(tǒng)每一個(gè)函數(shù)入口的絕對地址,根本沒有必要再去查找方法坞生,因此生成的代碼中沒有任何查找過程仔役。
所以,從原理上講是己,如果要支持系統(tǒng)方法的Hook又兵,這種方案在很多情況下是行不通的。當(dāng)然如果你Hook自己App的代碼,并且調(diào)用方和被調(diào)用方在不同的dex沛厨,在Android O之前是沒什么問題的(在Android O之前跨dex一定會走方法查找)宙地。
從上面的分析可以看出,就算不查找ArtMethod逆皮,這個(gè)ArtMethod的enntrypoint所指向代碼是一定要用到的(廢話宅粥,不然CPU執(zhí)行什么,解釋執(zhí)行在暫不討論)电谣。既然替換入口的方式無法達(dá)到Hook所有類型方法的目的秽梅,那么如果不替換入口,而是直接修改入口里面指向的代碼呢剿牺?(這種方式有個(gè)高大上的學(xué)名:callee side dynamic rewriting)
dynamic callee-side rewriting
第一次學(xué)到這個(gè)詞是在 Wi?feld, Marvin 的論文 ArtHook: Callee-side Method Hook Injection on the New Android Runtime ART上企垦。這篇文章很精彩,講述了各種Hook的原理晒来,并且他還在ART上實(shí)現(xiàn)了 dynamic callee-side rewriting 的Hook技術(shù)钞诡,代碼在github上:ArtHook
通俗地講,dynamic callee-side rewriting其實(shí)就是修改entrypoint 所指向的代碼潜索。但是有個(gè)基本問題:Hook函數(shù)和原函數(shù)的代碼長度基本上是不一樣的臭增,而且為了實(shí)現(xiàn)AOP,Hook函數(shù)通常比原函數(shù)長很多竹习。如果直接把Hook函數(shù)的代碼段copy到原函數(shù)entrypoint所指向的代碼段,很可能沒地兒放列牺。因此整陌,通常的做法是寫一段trampoline。也就是把原函數(shù)entrypoint所指向代碼的開始幾個(gè)字節(jié)修改為一小段固定的代碼瞎领,這段代碼的唯一作用就是跳轉(zhuǎn)到新的位置開始執(zhí)行泌辫,如果這個(gè)「新的位置」就是Hook函數(shù),那么基本上就實(shí)現(xiàn)了Hook九默;這種跳板代碼我們一般稱之為trampoline/stub震放,比如Android源碼中的 art_quick_invoke_stub/art_quick_resolution_trampoline等。
這篇論文基本上指明了ART上Method Hook的方向驼修,而且Wi?feld 本人的項(xiàng)目 ArtHook也差不多達(dá)到了這個(gè)目的殿遂。不過他的Hook實(shí)現(xiàn)中,被用來替換的方法必須寫死在代碼中乙各,因此無法達(dá)到某種程度上的動態(tài)Hook墨礁。比如,我想知道所有線程的創(chuàng)建和銷毀耳峦,因此選擇攔截Thread.class 的run方法恩静;但是Thread子類實(shí)現(xiàn)的run方法不一定會調(diào)用 Thread 的run,所以可能會漏掉一些線程蹲坷。比如:
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能被發(fā)現(xiàn),其他兩個(gè)都是漏網(wǎng)之魚级乐。既然如此疙咸,我們可以Hook線程的構(gòu)造函數(shù)(子類必定調(diào)用父類),從而知道有哪些自定義的線程類被創(chuàng)建唇牧,然后直接Hook這些在運(yùn)行時(shí)才發(fā)現(xiàn)的類罕扎,就能知道所有Java線程的創(chuàng)建和銷毀。
要解決「不寫死Hook方法」這個(gè)問題有兩種思路:其一丐重,直接在運(yùn)行時(shí)憑空創(chuàng)建出一個(gè)Method腔召;其二,把Hook收攏到一個(gè)統(tǒng)一的方法扮惦,在這個(gè)方法中做分發(fā)處理臀蛛。
第一種方式:憑空創(chuàng)建Method,并非new 一個(gè)Method對象就行了崖蜜,這個(gè)方法必須要有你想執(zhí)行的代碼浊仆,以及必要的declaring_class, dex_method_index 等成員;要達(dá)到這個(gè)目的豫领,可以使用運(yùn)行時(shí)字節(jié)碼生成技術(shù)抡柿,比如 dexmaker。另外等恐,Java本身的動態(tài)代理機(jī)制也可以也會動態(tài)生成代理類洲劣,在代理類中有全新創(chuàng)建的方法,如果合適處理课蔬,也能達(dá)到目的囱稽;不過這種方案貌似還無人實(shí)現(xiàn),反倒是entrypoint replcement中有人這么做 :(
第二種方式:用一個(gè)函數(shù)來處理hook的分發(fā)邏輯二跋,這是典型的xposed/dexposed 實(shí)現(xiàn)方式战惊。不過Xposed支持Android N過程中直接修改了 libart.so,這種方式對進(jìn)程內(nèi)Hook是行不通的扎即。dexposed的 dev_art 分支有嘗試過實(shí)現(xiàn)吞获,但是幾乎不可用。
有趣地是铺遂,還有另外一個(gè)項(xiàng)目 YAHFA 也提出了一種Hook方案衫哥;不過他這種方案看起來是entrypoint replacement和dynamic callee-side rewriting的結(jié)合體:把entrypoint替換為自己的trampoline函數(shù)的地址,然后在trampoline繼續(xù)處理跳轉(zhuǎn)邏輯襟锐。作者的博客值得一看撤逢。
vtable replacement
除了傳統(tǒng)的類inline hook 的 dynamic callee-side rewriting 的Hook方式,也有基于虛擬機(jī)特定實(shí)現(xiàn)的Hook技術(shù),比如vtable hook蚊荣。ART中的這種Hook方式首先是在論文 ARTDroid: A Virtual-Method Hooking Framework on Android ART Runtime 中提出的初狰,作者的實(shí)現(xiàn)代碼也在github上 art-hooking-vtable。
<img src="http://7xp3xc.com1.z0.glb.clouddn.com/201601/1511342723016.png" width="340"/>
這種Hook方式是基于invoke-virtual調(diào)用原理的互例;簡單來講奢入,ART中調(diào)用一個(gè)virtual method的時(shí)候,會查相應(yīng)Class類里面的一張表媳叨,如果修改這張表對應(yīng)項(xiàng)的指向腥光,就能達(dá)到Hook的目的。更詳細(xì)的實(shí)現(xiàn)原理糊秆,作者的論文以及他的博客講的很詳細(xì)武福,感興趣的可以自行圍觀。
這種方式最大的缺點(diǎn)是只能Hook virtual方法痘番,雖然根據(jù)作者提供的數(shù)據(jù):
59.2% of these methods are declared as virtual
1.0% are non-virtual
39.8% methods not found
高達(dá)99%的方法都能被hook住捉片,不管你信不信,反正我是不信汞舱。所以伍纫,這種Hook方式無法Hook所有的調(diào)用過程,只能作為一種補(bǔ)充手段使用昂芜。
epic的實(shí)現(xiàn)
基本原理
了解到已有項(xiàng)目的一些實(shí)現(xiàn)原理以及當(dāng)前的現(xiàn)狀莹规,我們可以知道,要實(shí)現(xiàn)一個(gè)較為通用的Hook技術(shù)泌神,幾乎只有一條路———基于dynamic dispatch的dynamic callee-side rewriting访惜。epic正是使用這種方式實(shí)現(xiàn)的,它的基本原理如下圖:
<img src="http://7xp3xc.com1.z0.glb.clouddn.com/201601/1511354138004.png" width="765"/>
在講解這張圖之前腻扇,有必要說明一下ART中的函數(shù)的調(diào)用約定。以Thumb2為例砾嫉,子函數(shù)調(diào)用的參數(shù)傳遞是通過寄存器r0~r3 以及sp寄存器完成的幼苛。r0 ~ r3 依次傳遞第一個(gè)至第4個(gè)參數(shù),同時(shí) *sp, *(sp + 4), *(sp + 8), *(sp + 12) 也存放著r0~r3上對應(yīng)的值焕刮;多余的參數(shù)通過 sp傳遞舶沿,比如 *(sp + 16)放第四個(gè)參數(shù),以此類推配并。同時(shí)括荡,函數(shù)的返回值放在r0寄存器。如果一個(gè)參數(shù)不能在一個(gè)寄存器中放下溉旋,那么會占用2個(gè)或多個(gè)寄存器畸冲。
在ART中,r0寄存器固定存放被調(diào)用方法的ArtMethod指針,如果是non-static 方法邑闲,r1寄存器存放方法的this對象算行;另外,只有l(wèi)ong/double 占用8bytes苫耸,其余所有基本類型和對象類型都占用4bytes州邢。不過這只是基本情形,不同的ART版本對這個(gè)調(diào)用約定有不同的處理褪子,甚至不完全遵循量淌。
好了我們回到epic。如上圖所述嫌褪,如果我們要Hook android.util.Log.i
這個(gè)方法呀枢,那么首先需要找到這個(gè)方法的entrypoint,可以通過這個(gè)方法的ArtMethod對象得到渔扎;然后我們直接修改內(nèi)存硫狞,把這個(gè)函數(shù)的前8個(gè)字節(jié):
e92d40e0 ; push {r5, r6, r7, lr}
b088 ; sub sp, sp, #32
1c07 ; mov r7, r0
修改為一段跳轉(zhuǎn)指令:
dff800f0 ; ldr pc, [pc]
7f132450 ; trampoline2 address
這樣,在執(zhí)行Log.i
這個(gè)函數(shù)的時(shí)候晃痴,會通過這第一段跳板直接跳轉(zhuǎn)到 0x7f132450 這個(gè)地址開始執(zhí)行残吩。這個(gè)地址是我們預(yù)先分配好的一段內(nèi)存,也是一段跳轉(zhuǎn)函數(shù)倘核,我們姑且稱之為二段跳板泣侮。在接下來的二段跳板中悠抹,我們開始準(zhǔn)備分發(fā)邏輯:
ldr ip, 3f ; ip = source_method_address
cmp r0, ip ; r0 == ip ?
bne.w 5f ; if r0 != source_method_address, then jump to label5.
這段代碼是用來判斷是否需要執(zhí)行Hook的驳庭,如果不需要,跳轉(zhuǎn)到原函數(shù)的控制流仔涩,進(jìn)而達(dá)到調(diào)用原函數(shù)的目的漏益。接下來就是一些參數(shù)準(zhǔn)備:
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
在參數(shù)準(zhǔn)備好之后蛹锰,直接跳轉(zhuǎn)到另外一個(gè)Java方法的入口開始執(zhí)行,這個(gè)方法稱之為bridge方法绰疤。bridge方法接管控制流之后我們就回到了Java世界铜犬,自此之后我們就可以開始處理AOP邏輯。
一些問題
基本原理比較簡單轻庆,但是在實(shí)現(xiàn)過程中會有很多問題癣猾,這里簡單交代一下。
bridge函數(shù)分發(fā)以及堆棧平衡
從上面的基本介紹我們可以知道余爆,方法的AOP邏輯是交給一個(gè)Java的bridge函數(shù)統(tǒng)一處理的纷宇,那么這個(gè)統(tǒng)一的函數(shù)如何區(qū)分每一個(gè)被Hook的方法,進(jìn)而調(diào)用對應(yīng)的回調(diào)函數(shù)呢蛾方?
最直接的辦法是把被Hook的方法通過額外參數(shù)直接傳遞給bridge函數(shù)像捶,而傳遞參數(shù)可以通過寄存器和堆棧實(shí)現(xiàn)上陕。用來傳遞參數(shù)的寄存器(如r0~r3)最好是不要直接改的,不然我們的處理函數(shù)可能就收到不到原函數(shù)對應(yīng)的參數(shù)作岖,進(jìn)而無法完成調(diào)用原函數(shù)的邏輯唆垃。如果用堆棧傳遞參數(shù)的話,我們是直接在堆棧上分配內(nèi)存嗎痘儡?
事實(shí)證明這樣做是不行的辕万,如果我們在二段跳板代碼里面開辟堆棧,進(jìn)而修改了sp寄存器沉删;那么在我們修改sp到調(diào)用bridge函數(shù)的這段時(shí)間里渐尿,堆棧結(jié)構(gòu)與不Hook的時(shí)候是不一樣的(雖然bridge函數(shù)執(zhí)行完畢之后我們可以恢復(fù)正常);在這段時(shí)間里如果虛擬機(jī)需要進(jìn)行椃澹回溯砖茸,sp被修改的那一幀會由于回溯不到對應(yīng)的函數(shù)引發(fā)致命錯(cuò)誤,導(dǎo)致Runtime 直接Abort殴穴。什么時(shí)候會回溯堆棧凉夯?發(fā)生異常或者GC的時(shí)候采幌。最直觀的感受就是劲够,如果bridge函數(shù)里面有任何異常拋出(即使被try..catch住)就會使虛擬機(jī)直接崩潰休傍。dexposed的 dev_art 分支中的AOP實(shí)現(xiàn)就有這個(gè)問題征绎。
既然無法分配新的堆棧,那么能否找到空閑的空間使用呢磨取?上面我們在介紹Thumb2調(diào)用約定的時(shí)候提到人柿,r0~r3傳遞第一至第四個(gè)參數(shù),sp ~ sp + 12 也傳遞第一至第四個(gè)參數(shù)忙厌,看起來好像是重復(fù)了凫岖;我們能否把 sp ~ sp + 12 這段空間利用起來呢?
但是實(shí)際實(shí)現(xiàn)的過程中又發(fā)現(xiàn)逢净,此路不通隘截。你以為就你會耍這點(diǎn)小聰明嗎?虛擬機(jī)本身也是知道 sp + 12 這段空間相當(dāng)于是浪費(fèi)的汹胃,因此他直接把這段空間當(dāng)做類似寄存器使用了;如果你把額外的參數(shù)丟在這里东臀,那么根本就收不到參數(shù)着饥,因?yàn)楹瘮?shù)調(diào)用一旦發(fā)生,ART很可能直接把這段內(nèi)存直接使用了惰赋。
既然如此宰掉,我們只能把要傳遞的一個(gè)或者多個(gè)額外參數(shù)打包在一起(比如放在結(jié)構(gòu)體)呵哨,通過指針一塊傳遞了。再此觀察我們上面的二段跳板代碼:
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
處是我們預(yù)先分配好的一段16字節(jié)的內(nèi)存(假設(shè)起始地址為base)孟害;我們把 sp 放到 *(base)上,把r2寄存器(原第三個(gè)參數(shù))放到 *(base + 4)挪拟,把r3(原第四個(gè)參數(shù))放到 *(base + 8)挨务,把 3f
(被Hook函數(shù)的地址)放到 *(base + 12);然后把這個(gè)base 的地址放在r3寄存器里面玉组,這樣根據(jù)調(diào)用約定谎柄,我們的bridge函數(shù)就可以在第四個(gè)參數(shù)上收到四個(gè)打包好的數(shù)據(jù),然后通過相同的訪問方式就可以把原始數(shù)據(jù)取出來惯雳。這些數(shù)據(jù)中就包括了被Hook的原函數(shù)地址朝巫,通過這個(gè)地址,我們可以區(qū)分不同的被Hook函數(shù)石景,進(jìn)而觸發(fā)各自對應(yīng)的處理邏輯劈猿。
入口重合的問題
在二段跳板函數(shù)的開始處,有這么一段代碼:
ldr ip, 3f ; ip = source_method_address
cmp r0, ip ; r0 == ip ?
bne.w 5f ; if r0 != source_method_address, then jump to label5.
也許你會問潮孽,這個(gè)比較邏輯是有必要的嗎揪荣?除了達(dá)到調(diào)用原函數(shù)的目的之外,這個(gè)邏輯還有一個(gè)更重要的用途:區(qū)分入口相同恩商,但是實(shí)際上Java方法完全不同的處理邏輯变逃。
什么時(shí)候不同的Java函數(shù)的入口會一樣呢?至少有下面幾種情況:
- 所有ART版本上未被resolve的static函數(shù)
- Android N 以上的未被編譯的所有函數(shù)
- 代碼邏輯一模一樣的函數(shù)
- JNI函數(shù)
static函數(shù)是lazy resolve的怠堪,在方法沒有被調(diào)用之前揽乱,static函數(shù)的入口地址是一個(gè)跳板函數(shù),名為 art_quick_resolution_trampoline粟矿,這個(gè)跳轉(zhuǎn)函數(shù)做的事情就是去resvole原始函數(shù)凰棉,然后進(jìn)行真正的調(diào)用邏輯;因此沒有被調(diào)用的static函數(shù)的entrypoint都是一樣的陌粹。
Android N以上撒犀,APK安裝的時(shí)候,默認(rèn)是不會觸發(fā)AOT編譯的掏秩;因此如果剛安裝完你去看apk生成的OAT文件或舞,會發(fā)現(xiàn)里面的code都是空。在這些方法被resolve的時(shí)候蒙幻,如果ART發(fā)現(xiàn)code是空映凳,會把entrypoint設(shè)置為解釋執(zhí)行的入口;接下來如果此方法被執(zhí)行會直接進(jìn)入到解釋器邮破。所以诈豌,Android N上未被編譯的所有方法入口地址都相同仆救。
如果代碼邏輯完全一樣,那么AOT編譯器會發(fā)現(xiàn)這完全可以用一個(gè)函數(shù)來代替矫渔,于是這些函數(shù)都有了同一個(gè)入口地址彤蔽;而JNI函數(shù)由于函數(shù)體都是空(也即所有代碼相同),理所當(dāng)然會共享同一個(gè)入口庙洼。
如果沒有這段處理邏輯顿痪,你會發(fā)現(xiàn)你Hook一個(gè)函數(shù)的時(shí)候,很可能莫名其妙滴Hook了一堆你壓根都不知道是什么的函數(shù)送膳。
指針與對象轉(zhuǎn)換
在基本的bridge函數(shù)調(diào)用(從匯編進(jìn)入Java世界)的問題搞定之后员魏,我們會碰到一個(gè)新問題:在bridge函數(shù)中接受到的參數(shù)都是一些地址,但是原函數(shù)的參數(shù)明明是一些對象叠聋,怎么把地址還原成原始的參數(shù)呢撕阎?
如果傳遞的是基本類型,那么接受到的地址其實(shí)就是基本類型值的表示碌补;但是如果傳遞的是對象虏束,那接受到的 int/long 是個(gè)什么東西?
這個(gè)問題一言難盡厦章,它的背后是ART的對象模型镇匀;這里我簡單說明一下。一個(gè)最直觀的問題就是:JNI中的 jobject袜啃,Java中的Object汗侵,ART 中的 art::mirror::Object 到底是個(gè)什么關(guān)系?
實(shí)際上群发,art::mirror::Object 是 Java的Object在Runtime中的表示晰韵,java.lang.Object的地址就是art::mirror::Object的地址;但是jobject略有不同熟妓,它并非地址雪猪,而是一個(gè)句柄(或者說透明引用)。為何如此起愈?
因?yàn)镴NI對于ART來說是外部環(huán)境只恨,如果直接把ART中的對象地址交給JNI層(也就是jobject直接就是Object的地址),其一不是很安全抬虽,其二直接暴露內(nèi)部實(shí)現(xiàn)不妥官觅。就拿GC來說,虛擬機(jī)在GC過程中很可能移動對象阐污,這樣對象的地址就會發(fā)生變化缰猴,如果JNI直接使用地址,那么對GC的實(shí)現(xiàn)提出了很高要求疤剑。因此滑绒,典型的Java虛擬機(jī)對JNI的支持中,jobject都是句柄(或者稱之為透明引用)隘膘;ART虛擬機(jī)內(nèi)部可以在joject與 art::mirror::Object中自由轉(zhuǎn)換疑故,但是JNI層只能拿這個(gè)句柄去標(biāo)志某個(gè)對象。
那么jobject與java.lang.Object如何轉(zhuǎn)換呢弯菊?這個(gè)so easy纵势,直接通過一次JNI調(diào)用,ART就自動完成了轉(zhuǎn)換管钳。
因此歸根結(jié)底钦铁,我們需要找到一個(gè)函數(shù),它能實(shí)現(xiàn)把 art::mirror::Object 轉(zhuǎn)換為 jobject對象才漆,這樣我們可以通過JNI進(jìn)而轉(zhuǎn)化為Java對象牛曹。這樣的函數(shù)確實(shí)有,那就是:
art::JavaVMExt::AddWeakGlobalReference(art::Thread*, art::mirror::Object*)
此函數(shù)在 libart.so中醇滥,我們可以通過 dlsym
拿到函數(shù)指針黎比,然后直接調(diào)用。不過這個(gè)函數(shù)有一個(gè)art::Thread* 的參數(shù)鸳玩,如何拿到這個(gè)參數(shù)呢阅虫?查閱 art::Thread 的源碼發(fā)現(xiàn),這個(gè) art::Thread 與 java.lang.Thread 也有某種對應(yīng)關(guān)系不跟,它們是通過peer結(jié)合在一起的(JNI文檔中有講)颓帝。也就是說,java.lang.Thread類中的 nativePeer 成員代表的就是當(dāng)前線程的 art::Thread*對象窝革。這個(gè)問題迎刃而解购城。
Android N無法dlsym
上文提到,為了實(shí)現(xiàn)對象和指針的轉(zhuǎn)換聊闯,我們需要 dlsym
一個(gè) libart.so 中的導(dǎo)出函數(shù)工猜;但不幸地是,在Android N中菱蔬,Google禁止了這種行為篷帅,如果你用 dlsym
去取符號,返回的結(jié)果是nullptr拴泌。怎么辦呢魏身?
libart.so 不過是一個(gè)加載在內(nèi)存中的elf文件而已。我們通過讀取 /proc/self/maps
拿到這個(gè)文件的加載基地址蚪腐,然后直接解析ELF文件格式箭昵,查出這個(gè)符號在ELF文件中的偏移,再加上內(nèi)存基址回季,就能得到這個(gè)符號真正的地址家制。不過這過程已經(jīng)有人實(shí)現(xiàn)了正林,而且放在了github上:Nougat_dlfunctions 可以直接使用 :)
Android N 解釋執(zhí)行
Android N采用了混合編譯的模式,既有解釋執(zhí)行颤殴,也有AOT和JIT觅廓;APK剛安裝完畢是解釋執(zhí)行的,運(yùn)行時(shí)JIT會收集方法調(diào)用信息涵但,必要的時(shí)候直接編譯此方法杈绸,甚至棧上替換;在設(shè)備空閑時(shí)矮瘟,系統(tǒng)會根據(jù)收集到的信息執(zhí)行AOT操作瞳脓。
那么在APK剛裝完然后使用的那么幾次,方法都是解釋執(zhí)行的澈侠,我們要Hook掉解釋執(zhí)行的入口嗎劫侧?這當(dāng)然可以,但是如果解釋執(zhí)行到一半方法入口被替換為JIT編譯好的機(jī)器碼的入口埋涧,那么本次Hook就會失效板辽;我們還需要把JIT編譯的機(jī)器碼入口也攔截住。但是問題是棘催,我們何時(shí)知道JIT執(zhí)行完成劲弦?
所以這種方式實(shí)行起來比較麻煩,還不如一開始就全部是機(jī)器碼 這樣我們只用Hook機(jī)器碼的entrypoint就可以了醇坝。事實(shí)上邑跪,Android N可以手動觸發(fā)AOT全量編譯,如 官方文檔 所述呼猪,可以通過如下命令手動執(zhí)行AOT編譯:
adb shell cmd package compile -m speed -f <package-name>
這樣一來画畅,我們一般情況下就不用管解釋器的事了。
雖然多這么一個(gè)步驟宋距,勉強(qiáng)能解決問題轴踱,但還是有點(diǎn)小瑕疵;(畢竟要多這么一步嘛谚赎!何況如果這個(gè)投入線上使用淫僻,你指望用戶給你主動編譯?)在研究了一段時(shí)間的JIT代碼之后壶唤,我發(fā)現(xiàn)可以主動調(diào)用JIT編譯某個(gè)方法雳灵。這樣,在Hook之前我們可以先請求JIT編譯此方法闸盔,得到機(jī)器碼的entrypoint悯辙,然后按照正常的流程Hook即可。具體如何調(diào)用JIT可以參閱epic的源碼。
Android N JIT編譯
上文提到Android N上開啟了JIT編譯器躲撰,即使我們手動觸發(fā)全量AOT編譯针贬,在運(yùn)行時(shí)這種機(jī)制依然存在;JIT的一個(gè)潛在隱患就是拢蛋,他有可能動態(tài)修改代碼坚踩,這使得在Android N上的Hook可能隨機(jī)出現(xiàn)crash。
記得我在剛實(shí)現(xiàn)完Android N上的Hook之后瓤狐,發(fā)現(xiàn)我的測試case偶爾會崩潰,崩潰過程完全沒有規(guī)律批幌,而且崩潰的錯(cuò)誤幾乎都是SIG 11础锐。當(dāng)時(shí)追查了一段時(shí)間,覺得這種隨機(jī)崩潰可能跟2個(gè)原因有關(guān):GC或者JIT荧缘;不過一直沒有找到證據(jù)皆警。
某天半夜我發(fā)現(xiàn)一個(gè)有趣的現(xiàn)象,如果我把測試case中的Logcat日志輸出關(guān)掉截粗,崩潰的概率會小很多——如果輸出Logcat可能測試八九次就閃退了信姓,但如果關(guān)掉日志,要數(shù)十次或者幾乎不會閃退绸罗。當(dāng)時(shí)我就懷疑是不是碰上了薛定諤貓意推。
理性分析了一番之后我覺得這種尺度不可能觸發(fā)量子效應(yīng),于是我只能把鍋摔倒Log頭上珊蟀。我在想是不是Log有IO操作導(dǎo)致hook過程太慢了使得這段時(shí)間別的線程有機(jī)會修改代碼菊值?于是我在Hook過程中Sleep 5s發(fā)現(xiàn)一點(diǎn)問題沒有。實(shí)在沒轍育灸,我就一條條刪Log腻窒,結(jié)果發(fā)現(xiàn)一個(gè)神奇的現(xiàn)象:Log越多越容易崩。然后我就寫個(gè)循環(huán)輸出日志100次磅崭,結(jié)果幾乎是畢現(xiàn)閃退儿子。
事情到這里我就瞬間明白了:調(diào)用Log的過程中很有可能由于Log函數(shù)調(diào)用次數(shù)過多進(jìn)而達(dá)到JIT編譯的閾值從而觸發(fā)了JIT,這時(shí)候JIT線程修改了被執(zhí)行函數(shù)的代碼砸喻,而Hook的過程也會修改代碼柔逼,這導(dǎo)致內(nèi)存中的值不可預(yù)期,從而引發(fā)隨機(jī)crash恩够。
按照這種情況推測的話卒落,JIT的存在導(dǎo)致Android N上的Hook幾乎是畢現(xiàn)閃退的。因?yàn)槲业臏y試demo代碼量很少蜂桶,一個(gè)稍微有點(diǎn)規(guī)模的App很容易觸發(fā)JIT編譯儡毕,一旦在JIT過程中執(zhí)行Hook,那么必崩無疑。
因此比較好的做法是腰湾,在Hook的過程中暫停所有其他線程雷恃,不讓它們有機(jī)會修改代碼;在Hook完畢之后在恢復(fù)執(zhí)行费坊。那么問題來了倒槐,如何暫停/恢復(fù)所有線程?Google了一番發(fā)現(xiàn)有人通過ptrace實(shí)現(xiàn):開一個(gè)linux task然后挨個(gè)ptrace本進(jìn)程內(nèi)的所有子線程附井,這樣就是實(shí)現(xiàn)了暫停讨越。這種方式很重而且不是特別穩(wěn)定,于是我就放棄了永毅。ART虛擬機(jī)內(nèi)部一定也有暫停線程的需求(比如GC)把跨,因此我可以選擇直接調(diào)用ART的內(nèi)部函數(shù)。
在源碼里面撈了一番之后果然在thread_list.cc 中找到了這樣的函數(shù) resumeAll/suspendAll沼死;不過遺憾的是這兩個(gè)函數(shù)是ThreadList類的成員函數(shù)着逐,要調(diào)用他們必須拿到ThreadList的指針;一般情況下是沒有比較穩(wěn)定的方式拿到這個(gè)對象的意蛀。不過好在Android 源碼通過RAII機(jī)制對 suspendAll/resumeAll做了一個(gè)封裝耸别,名為 ScopedSuspendAll
這類的構(gòu)造函數(shù)里面執(zhí)行暫停操作,析構(gòu)函數(shù)執(zhí)行恢復(fù)操作县钥,在棧上分配變量此類型的變量之后秀姐,在這個(gè)變量的作用域內(nèi)可以自動實(shí)現(xiàn)暫停和恢復(fù)。因此我只需要用 dlsym
拿到構(gòu)造函數(shù)和析構(gòu)函數(shù)的符號之后魁蒜,直接調(diào)用就能實(shí)現(xiàn)暫湍野猓恢復(fù)功能。詳細(xì)實(shí)現(xiàn)見 epic 源碼
寫了這么多兜看,實(shí)際上還有很多想寫的沒有寫完锥咸;比如Android M Optimizing編譯器上的寄存器分配問題,long/double參數(shù)的處理細(xì)節(jié)细移,不同ART版本的調(diào)用約定 與 ATPCS/AAPCS之間不同等搏予;不過來日方長,這些問題以后在慢慢道來吧 :)
使用
扯了這么久的實(shí)現(xiàn)原理弧轧,我們來看看這玩意兒具體怎么用吧雪侥。只需要在你的項(xiàng)目中加入epic的依賴即可(jcenter 倉庫):
dependencies {
compile 'me.weishu:epic:0.1.2@aar'
}
然后就可以在你的項(xiàng)目中做AOP Hook,比如說要攔截所有Java線程的創(chuàng)建精绎,我們可以用如下代碼:
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個(gè)AOP點(diǎn)速缨,其一是 Thread.class 的run方法,攔截這個(gè)方法代乃,我們可以知道所有通過Thread類本身創(chuàng)建的線程旬牲;其二是Thread的構(gòu)造函數(shù)仿粹,這個(gè)Hook點(diǎn)我們可以知道運(yùn)行時(shí)具體有哪些類繼承了Thread.class類,在找到這樣的子類之后原茅,直接hook掉這個(gè)類的run方法吭历,從而達(dá)到了攔截所有線程創(chuàng)建的目的。
當(dāng)然擂橘,還有很多有趣的AOP點(diǎn)等待你去挖掘晌区,這一切取決于您的想象力 :)
局限
上文提到,「要在ART上實(shí)現(xiàn)一個(gè)完善而穩(wěn)定的Hook機(jī)制通贞,幾無可能」朗若,epic也不例外:它也有它自己的缺點(diǎn),有些是先天的昌罩,有些是后天的捡偏,還有一些我沒有發(fā)現(xiàn)的 _;比如說:
- 受限于dynamic callee-side rewrite機(jī)制峡迷,如果被Hook函數(shù)的code段太短以至于一個(gè)簡單的trampoline跳轉(zhuǎn)都放不下,那么epic無能為力你虹。
- 如果ART中有深度內(nèi)聯(lián)绘搞,直接把本函數(shù)的代碼內(nèi)聯(lián)到調(diào)用者,那么epic也搞不定傅物。
- Android O(8.0)還沒有去研究和實(shí)現(xiàn)夯辖。
- 當(dāng)前僅支持thumb2/arm64指令集,arm32/x86/mips還沒有支持董饰。
- 在支持硬浮點(diǎn)的cpu架構(gòu)蒿褂,比如(armeabi-v7a, arm64-v8a)上,帶有double/float參數(shù)的函數(shù)Hook可能有問題卒暂,沒有充分測試啄栓。
- 還有一些其他機(jī)型上的,或者我沒有發(fā)現(xiàn)的閃退也祠。
我本人只在Android 5.0, 5.1, 6.0, 7.0, 7.1 的個(gè)別機(jī)型昙楚,以及這些機(jī)型的thumb2指令集,和6.0/7.1 的arm64指令集做過測試诈嘿;其他的機(jī)型均未測試顿仇,因此這么長的文章還讀到最后的你惭等,不妨拿出你手頭的手機(jī)幫我測試一下,在下感激不盡 :)