1. 程式人生 > >我為Dexposed續一秒——論ART上執行時 Method AOP實現

我為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方法的執行非常簡單:

  1. 想辦法拿到這個Java方法所代表的ArtMethod物件
  2. 取出其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) 這個函式的彙編程式碼:

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跳轉過去了。事實上,這個地址 0x7387a629就是TextView.setText(Charsequence)` 這個方法entrypoint的絕對地址;我們可以把系統編譯好的oat程式碼弄出來看一看:

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

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 32773 link

700a1000-72818000 r--p 00000000 103:1f 32772     /data/dalvik-cache/arm/[email protected]@boot.oat
72818000-74689000 r-xp 02777000 103:1f 32772     /data/dalvik-cache/arm/[email protected]@boot.oat
74689000-7468a000 rw-p 045e8000 103:1f 32772     /data/dalvik-cache/arm/[email protected]@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,所以可能會漏掉一些執行緒。比如:

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 。

這種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個位元組:

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

修改為一段跳轉指令:

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

這樣,在執行 Log.i 這個函式的時候,會通過這第一段跳板直接跳轉到 0x7f132450 這個地址開始執行。這個地址是我們預先分配好的一段記憶體,也是一段跳轉函式,我們姑且稱之為二段跳板。在接下來的二段跳板中,我們開始準備分發邏輯:

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的,如果不需要,跳轉到原函式的控制流,進而達到呼叫原函式的目的。接下來就是一些引數準備:

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很可能直接把這段記憶體直接使用了。

既然如此,我們只能把要傳遞的一個或者多個額外引數打包在一起(比如放在結構體),通過指標一塊傳遞了。再此觀察我們上面的二段跳板程式碼:

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函式,進而觸發各自對應的處理邏輯。

入口重合的問題

在二段跳板函式的開始處,有這麼一段程式碼:

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物件。這樣的函式確實有,那就是:

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 倉庫):

dependencies {
    compile 'me.weishu:epic:[email protected]'
}

然後就可以在你的專案中做AOP Hook,比如說要攔截所有Java執行緒的建立,我們可以用如下程式碼:

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指令集做過測試;其他的機型均未測試,因此這麼長的文章還讀到最後的你,不妨拿出你手頭的手機幫我測試一下,在下感激不盡 :)