把動態庫的內存操作玩出了新花樣!
文中使用的示例代碼可以從 這里 獲取。https://github.com/iqiyi/xHook/tree/master/docs/overview/code
文中提到的 xhook 開源項目可以從 這里 獲取。
https://github.com/iqiyi/xHook
新的動態庫
我們有一個新的動態庫:libtest.so。
頭文件 test.h
#ifndef TEST_H
#define TEST_H 1
#ifdef __cplusplus
extern "C" {
#endif
void say_hello();
#ifdef __cplusplus
}
#endif
#endif
源文件 test.c
#include
#include
void say_hello()
{
char *buf = malloc(1024);
if(NULL != buf)
{
snprintf(buf, 1024, "%s", "hello");
printf("%s", buf);
}
}
say_hello 的功能是在終端打印出hello這6個字符(包括結尾的 )。
我們需要一個測試程序:main。
源文件 main.c
#include
int main()
{
say_hello();
return 0;
}
編譯它們分別生成libtest.so和 main。運行一下:
caikelun@debian:~$ adb push ./libtest.so ./main /data/local/tmp caikelun@debian:~$ adb shell "chmod +x /data/local/tmp/main" caikelun@debian:~$ adb shell "export LD_LIBRARY_PATH=/data/local/tmp; /data/local/tmp/main" hello caikelun@debian:~$
太棒了!libtest.so的代碼雖然看上去有些愚蠢,但是它居然可以正確的工作,那還有什么可抱怨的呢?
趕緊在新版APP中開始使用它吧!
遺憾的是,正如你可能已經發現的,libtest.so 存在嚴重的內存泄露問題,每調用一次say_hello函數,就會泄露1024字節的內存。
新版APP上線后崩潰率開始上升,各種詭異的崩潰信息和報障信息跌撞而至。
面臨的問題
幸運的是,我們修復了libtest.so的問題。可是以后怎么辦呢?我們面臨2個問題:
- 當測試覆蓋不足時,如何及時發現和準確定位線上 APP 的此類問題?
- 如果 libtest.so 是某些機型的系統庫,或者第三方的閉源庫,我們如何修復它?如果監控它的行為?
怎么做?
如果我們能對動態庫中的函數調用做 hook(替換,攔截,竊聽,或者你覺得任何正確的描述方式),那就能夠做到很多我們想做的事情。
比如 hook malloc,calloc,realloc 和 free,我們就能統計出各個動態庫分配了多少內存,哪些內存一直被占用沒有釋放。
這真的能做到嗎?答案是:hook我們自己的進程是完全可以的。
hook 其他進程需要root權限(對于其他進程,沒有root權限就沒法修改它的內存空間,也沒法注入代碼)。
幸運的是,我們只要hook自己就夠了。
道哥注解:
如果去 hook 不屬于自己的進程,那就真的屬于病毒了!
進程級別的隔離,一般由操作系統來處理!
ELF
道哥注解:
關于 ELF 的詳細介紹,也可以看一下我之前寫的一篇文章:Linux系統中編譯、鏈接的基石-ELF文件:扒開它的層層外衣,從字節碼的粒度來探索。
這篇文章的內容非常詳細,就像剝洋蔥一樣,一層一層分析 ELF 文件的結構。
并且以圖片的方式,把 ELF 文件中的二進制內容與相關的結構體成員變量一一對應起來,比較直觀。
概述
ELF(Executable and Linkable Format)是一種行業標準的二進制數據封裝格式,主要用于封裝可執行文件、動態庫、object 文件和 core dumps 文件。
使用google NDK對源代碼進行編譯和鏈接,生成的動態庫或可執行文件都是ELF格式的。
用readelf可以查看ELF文件的基本信息,用objdump可以查看ELF文件的反匯編輸出。
ELF 格式的概述可以參考這里,完整定義可以參考這里。
其中最重要的部分是:ELF 文件頭、SHT(section header table)、PHT(program header table)。
ELF 文件頭
ELF 文件的起始處,有一個固定格式的定長的文件頭(32 位架構為52字節,64 位架構為64字節)。ELF 文件頭以magic number 0x7F 0x45 0x4C 0x46開始(其中后3個字節分別對應可見字符 E L F)。
libtest.so 的ELF文件頭信息:
caikelun@debian:~$ arm-linux-androideabi-readelf -h ./libtest.so ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: ARM Version: 0x1 Entry point address: 0x0 Start of program headers: 52 (bytes into file) Start of section headers: 12744 (bytes into file) Flags: 0x5000200, Version5 EABI, soft-float ABI Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 8 Size of section headers: 40 (bytes) Number of section headers: 25 Section header string table index: 24
ELF 文件頭中包含了SHT和PHT在當前ELF文件中的起始位置和長度。
例如,libtest.so 的SHT起始位置為 12744,長度40字節;
PHT 起始位置 52,長度32字節。
SHT(section header table)
ELF 以section為單位來組織和管理各種信息。
ELF 使用SHT來記錄所有section的基本信息。

主要包括:section 的類型、在文件中的偏移量、大小、加載到內存后的虛擬內存相對地址、內存中字節的對齊方式等。
libtest.so 的 SHT:
caikelun@debian:~$ arm-linux-androideabi-readelf -S ./libtest.so There are 25 section headers, starting at offset 0x31c8: Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .note.android.ide NOTE 00000134 000134 000098 00 A 0 0 4 [ 2] .note.gnu.build-i NOTE 000001cc 0001cc 000024 00 A 0 0 4 [ 3] .dynsym DYNSYM 000001f0 0001f0 0003a0 10 A 4 1 4 [ 4] .dynstr STRTAB 00000590 000590 0004b1 00 A 0 0 1 [ 5] .hash HASH 00000a44 000a44 000184 04 A 3 0 4 [ 6] .gnu.version VERSYM 00000bc8 000bc8 000074 02 A 3 0 2 [ 7] .gnu.version_d VERDEF 00000c3c 000c3c 00001c 00 A 4 1 4 [ 8] .gnu.version_r VERNEED 00000c58 000c58 000020 00 A 4 1 4 [ 9] .rel.dyn REL 00000c78 000c78 000040 08 A 3 0 4 [10] .rel.plt REL 00000cb8 000cb8 0000f0 08 AI 3 18 4 [11] .plt PROGBITS 00000da8 000da8 00017c 00 AX 0 0 4 [12] .text PROGBITS 00000f24 000f24 0015a4 00 AX 0 0 4 [13] .ARM.extab PROGBITS 000024c8 0024c8 00003c 00 A 0 0 4 [14] .ARM.exidx ARM_EXIDX 00002504 002504 000100 08 AL 12 0 4 [15] .fini_array FINI_ARRAY 00003e3c 002e3c 000008 04 WA 0 0 4 [16] .init_array INIT_ARRAY 00003e44 002e44 000004 04 WA 0 0 1 [17] .dynamic DYNAMIC 00003e48 002e48 000118 08 WA 4 0 4 [18] .got PROGBITS 00003f60 002f60 0000a0 00 WA 0 0 4 [19] .data PROGBITS 00004000 003000 000004 00 WA 0 0 4 [20] .bss NOBITS 00004004 003004 000000 00 WA 0 0 1 [21] .comment PROGBITS 00000000 003004 000065 01 MS 0 0 1 [22] .note.gnu.gold-ve NOTE 00000000 00306c 00001c 00 0 0 4 [23] .ARM.attributes ARM_ATTRIBUTES 00000000 003088 00003b 00 0 0 1 [24] .shstrtab STRTAB 00000000 0030c3 000102 00 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), y (noread), p (processor specific)
比較重要,且和hook關系比較大的幾個section是:
dynstr:保存了所有的字符串常量信息。
dynsym:保存了符號(symbol)的信息(符號的類型、起始地址、大小、符號名稱在 .dynstr 中的索引編號等)。函數也是一種符號。
text:程序代碼經過編譯后生成的機器指令。
dynamic:供動態鏈接器使用的各項信息,記錄了當前 ELF 的外部依賴,以及其他各個重要 section 的起始位置等信息。
got:Global Offset Table。用于記錄外部調用的入口地址。動態鏈接器(linker)執行重定位(relocate)操作時,這里會被填入真實的外部調用的絕對地址。
plt:Procedure Linkage Table。外部調用的跳板,主要用于支持 lazy binding 方式的外部調用重定位。(Android 目前只有 MIPS 架構支持 lazy binding)
rel.plt:對外部函數直接調用的重定位信息。
rel.dyn:除 .rel.plt 以外的重定位信息。(比如通過全局函數指針來調用外部函數)
道哥注解:
ELF 文件中,dynamic 這個section是非常重要的!
當一個動態庫被加載到內存中時,動態鏈接器就是讀取這個section的內容,比如:
依賴于其他哪些共享對象;
動態鏈接符號表的位置(.dynsym);
動態鏈接重定位表的位置;
初始化代碼的位置;
...
使用指令:readelf -d xxx.so,即可查看一個動態庫中 .dynamic 的內容。
另外,got和plt 這兩個 section,主要就是用來處理地址無關的功能。
如果您查詢-fPIC的相關內容,一定會講解這兩個知識點。
總的來說就是:Linux 下的動態庫,把代碼段中地址有關的部分,通過“增加一層”的原理,全部變成“地址無關”的。
這樣的話,動態庫的代碼段在加載到物理內存中之后,就可以被多個不同的進程來共享了,只要把代碼段的物理地址,映射到每個進程自己的虛擬地址即可。
而“地址相關”的部分,就放在 got(對變量的引用) 和plt(對函數的引用) 中。
PHT(program header table)
·ELF 被加載到內存時,是以 segment 為單位的。一個 segment 包含了一個或多個 section`。
ELF 使用PHT來記錄所有segment的基本信息。
主要包括:segment 的類型、在文件中的偏移量、大小、加載到內存后的虛擬內存相對地址、內存中字節的對齊方式等。
libtest.so 的 PHT:
caikelun@debian:~$ arm-linux-androideabi-readelf -l ./libtest.so Elf file type is DYN (Shared object file) Entry point 0x0 There are 8 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000034 0x00000034 0x00000034 0x00100 0x00100 R 0x4 LOAD 0x000000 0x00000000 0x00000000 0x02604 0x02604 R E 0x1000 LOAD 0x002e3c 0x00003e3c 0x00003e3c 0x001c8 0x001c8 RW 0x1000 DYNAMIC 0x002e48 0x00003e48 0x00003e48 0x00118 0x00118 RW 0x4 NOTE 0x000134 0x00000134 0x00000134 0x000bc 0x000bc R 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10 EXIDX 0x002504 0x00002504 0x00002504 0x00100 0x00100 R 0x4 GNU_RELRO 0x002e3c 0x00003e3c 0x00003e3c 0x001c4 0x001c4 RW 0x4 Section to Segment mapping: Segment Sections... 00 01 .note.android.ident .note.gnu.build-id .dynsym .dynstr .hash .gnu.version .gnu.version_d .gnu.version_r .rel.dyn .rel.plt .plt .text .ARM.extab .ARM.exidx 02 .fini_array .init_array .dynamic .got .data 03 .dynamic 04 .note.android.ident .note.gnu.build-id 05 06 .ARM.exidx 07 .fini_array .init_array .dynamic .got
所有類型為PT_LOAD的segment都會被動態鏈接器(linker)映射(mmap)到內存中。
連接視圖(Linking View)和執行視圖(Execution View)
連接視圖:ELF 未被加載到內存執行前,以 section 為單位的數據組織形式。
執行視圖:ELF 被加載到內存后,以 segment 為單位的數據組織形式。


我們關心的hook操作,屬于動態形式的內存操作,因此主要關心的是執行視圖,即ELF被加載到內存后,ELF 中的數據是如何組織和存放的。
.dynamic section
這是一個十分重要和特殊的 section,其中包含了ELF中其他各個section的內存位置等信息。
在執行視圖中,總是會存在一個類型為PT_DYNAMIC的 segment,這個segment就包含了.dynamic section的內容。
無論是執行hook操作時,還是動態鏈接器執行動態鏈接時,都需要通過PT_DYNAMIC segment來找到.dynamic section的內存位置,再進一步讀取其他各項section的信息。
libtest.so 的 .dynamic section:
caikelun@debian:~$ arm-linux-androideabi-readelf -d ./libtest.so Dynamic section at offset 0x2e48 contains 30 entries: Tag Type Name/Value 0x00000003 (PLTGOT) 0x3f7c 0x00000002 (PLTRELSZ) 240 (bytes) 0x00000017 (JMPREL) 0xcb8 0x00000014 (PLTREL) REL 0x00000011 (REL) 0xc78 0x00000012 (RELSZ) 64 (bytes) 0x00000013 (RELENT) 8 (bytes) 0x6ffffffa (RELCOUNT) 3 0x00000006 (SYMTAB) 0x1f0 0x0000000b (SYMENT) 16 (bytes) 0x00000005 (STRTAB) 0x590 0x0000000a (STRSZ) 1201 (bytes) 0x00000004 (HASH) 0xa44 0x00000001 (NEEDED) Shared library: [libc.so] 0x00000001 (NEEDED) Shared library: [libm.so] 0x00000001 (NEEDED) Shared library: [libstdc++.so] 0x00000001 (NEEDED) Shared library: [libdl.so] 0x0000000e (SONAME) Library soname: [libtest.so] 0x0000001a (FINI_ARRAY) 0x3e3c 0x0000001c (FINI_ARRAYSZ) 8 (bytes) 0x00000019 (INIT_ARRAY) 0x3e44 0x0000001b (INIT_ARRAYSZ) 4 (bytes) 0x0000001e (FLAGS) BIND_NOW 0x6ffffffb (FLAGS_1) Flags: NOW 0x6ffffff0 (VERSYM) 0xbc8 0x6ffffffc (VERDEF) 0xc3c 0x6ffffffd (VERDEFNUM) 1 0x6ffffffe (VERNEED) 0xc58 0x6fffffff (VERNEEDNUM) 1 0x00000000 (NULL) 0x0
動態鏈接器(linker)
安卓中的動態鏈接器程序是 linker。源碼在這里。
動態鏈接(比如執行 dlopen)的大致步驟是:
- 檢查已加載的 ELF 列表。(如果 libtest.so 已經加載,就不再重復加載了,僅把 libtest.so 的引用計數加一,然后直接返回。)
- 從 libtest.so 的 .dynamic section 中讀取 libtest.so 的外部依賴的 ELF 列表,從此列表中剔除已加載的 ELF,最后得到本次需要加載的 ELF 完整列表(包括 libtest.so 自身)。
- 逐個加載列表中的 ELF。加載步驟:
(1) 用 mmap 預留一塊足夠大的內存,用于后續映射 ELF。(MAP_PRIVATE 方式)
(2) 讀 ELF 的 PHT,用 mmap 把所有類型為 PT_LOAD 的 segment 依次映射到內存中。
(3) 從 .dynamic segment 中讀取各信息項,主要是各個 section 的虛擬內存相對地址,然后計算并保存各個 section 的虛擬內存絕對地址。
(4) 執行重定位操作(relocate),這是最關鍵的一步。重定位信息可能存在于下面的一個或多個 secion 中:.rel.plt, .rela.plt, .rel.dyn, .rela.dyn, .rel.android, .rela.android。動態鏈接器需要逐個處理這些 .relxxx section 中的重定位訴求。根據已加載的 ELF 的信息,動態鏈接器查找所需符號的地址(比如 libtest.so 的符號 malloc),找到后,將地址值填入 .relxxx 中指明的目標地址中,這些“目標地址”一般存在于.got 或 .data 中。
(5) ELF 的引用計數加一。
- 逐個調用列表中 ELF 的構造函數(constructor),這些構造函數的地址是之前從 .dynamic segment 中讀取到的(類型為 DT_INIT 和 DT_INIT_ARRAY)。各 ELF 的構造函數是按照依賴關系逐層調用的,先調用被依賴 ELF 的構造函數,最后調用 libtest.so 自己的構造函數。(ELF 也可以定義自己的析構函數(destructor),在 ELF 被 unload 的時候會被自動調用)
等一下!我們似乎發現了什么!再看一遍重定位操作(relocate)的部分。
難道我們只要從這些.relxxx中獲取到“目標地址”,然后在“目標地址”中重新填上一個新的函數地址,這樣就完成hook了嗎?也許吧。
追蹤
靜態分析驗證一下還是很容易的。以armeabi-v7a架構的libtest.so為例。
先看一下say_hello函數對應的匯編代碼吧。
caikelun@debian:~/$ arm-linux-androideabi-readelf -s ./libtest.so
Symbol table '.dynsym' contains 58 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FUNC GLOBAL DEFAULT UND __cxa_finalize@LIBC (2)
2: 00000000 0 FUNC GLOBAL DEFAULT UND snprintf@LIBC (2)
3: 00000000 0 FUNC GLOBAL DEFAULT UND malloc@LIBC (2)
4: 00000000 0 FUNC GLOBAL DEFAULT UND __cxa_atexit@LIBC (2)
5: 00000000 0 FUNC GLOBAL DEFAULT UND printf@LIBC (2)
6: 00000f61 60 FUNC GLOBAL DEFAULT 12 say_hello
...............
...............
找到了!say_hello 在地址 f61,對應的匯編指令體積為 60(10 進制)字節。
用objdump查看say_hello的反匯編輸出。
caikelun@debian:~$ arm-linux-androideabi-objdump -D ./libtest.so
...............
...............
00000f60 :
f60: b5b0 push {r4, r5, r7, lr}
f62: af02 add r7, sp, #8
f64: f44f 6080 mov.w r0, #1024 ; 0x400
f68: f7ff ef34 blx dd4
f6c: 4604 mov r4, r0
f6e: b16c cbz r4, f8c
f70: a507 add r5, pc, #28 ; (adr r5, f90 )
f72: a308 add r3, pc, #32 ; (adr r3, f94 )
f74: 4620 mov r0, r4
f76: f44f 6180 mov.w r1, #1024 ; 0x400
f7a: 462a mov r2, r5
f7c: f7ff ef30 blx de0
f80: 4628 mov r0, r5
f82: 4621 mov r1, r4
f84: e8bd 40b0 ldmia.w sp!, {r4, r5, r7, lr}
f88: f001 ba96 b.w 24b8 <_Unwind_GetTextRelBase@@Base+0x8>
f8c: bdb0 pop {r4, r5, r7, pc}
f8e: bf00 nop
f90: 7325 strb r5, [r4, #12]
f92: 0000 movs r0, r0
f94: 6568 str r0, [r5, #84] ; 0x54
f96: 6c6c ldr r4, [r5, #68] ; 0x44
f98: 0a6f lsrs r7, r5, #9
f9a: 0000 movs r0, r0
...............
...............
對malloc函數的調用對應于指令 blx dd4。跳轉到了地址 dd4。
看看這個地址里有什么吧:
caikelun@debian:~$ arm-linux-androideabi-objdump -D ./libtest.so ............... ............... 00000dd4 : dd4: e28fc600 add ip, pc, #0, 12 dd8: e28cca03 add ip, ip, #12288 ; 0x3000 ddc: e5bcf1b4 ldr pc, [ip, #436]! ; 0x1b4 ............... ...............
果然,跳轉到了.plt中,經過了幾次地址計算,最后跳轉到了地址3f90中的值指向的地址處,3f90 是個函數指針。
稍微解釋一下:因為arm處理器使用3級流水線,所以第一條指令取到的pc的值是當前執行的指令地址 + 8。
于是:dd4 + 8 + 3000 + 1b4 = 3f90。
地址3f90在哪里呢:
caikelun@debian:~$ arm-linux-androideabi-objdump -D ./libtest.so
...............
...............
00003f60 <.got>:
...
3f70: 00002604 andeq r2, r0, r4, lsl #12
3f74: 00002504 andeq r2, r0, r4, lsl #10
...
3f88: 00000da8 andeq r0, r0, r8, lsr #27
3f8c: 00000da8 andeq r0, r0, r8, lsr #27
3f90: 00000da8 andeq r0, r0, r8, lsr #27
...............
...............
果然,在.got里。
順便再看一下.rel.plt:
caikelun@debian:~$ arm-linux-androideabi-readelf -r ./libtest.so Relocation section '.rel.plt' at offset 0xcb8 contains 30 entries: Offset Info Type Sym.Value Sym. Name 00003f88 00000416 R_ARM_JUMP_SLOT 00000000 __cxa_atexit@LIBC 00003f8c 00000116 R_ARM_JUMP_SLOT 00000000 __cxa_finalize@LIBC 00003f90 00000316 R_ARM_JUMP_SLOT 00000000 malloc@LIBC ............... ...............
malloc 的地址居然正好存放在3f90里,這絕對不是巧合啊!
道哥注解:
.rel.plt 這個section中,記錄了重定位表的信息,也就是哪些函數地址需要被重定位。
鏈接器把所有被依賴的共享對象加載到內存中時,會把每個共享對象中的符號給匯總起來,得到全局符號表。
然后再檢查每個共享對象中的 .rel.plt,是否需要對一些地址進行重定位。
如果需要的話,就從全局符號表中找到該符號的內存地址,然后填寫到 .plt 中對應的位置。
還等什么,趕緊改代碼吧。我們的main.c應該改成這樣:
#include
void *my_malloc(size_t size)
{
printf("%zu bytes memory are allocated by libtest.so", size);
return malloc(size);
}
int main()
{
void **p = (void **)0x3f90;
*p = (void *)my_malloc; // do hook
say_hello();
return 0;
}
編譯運行一下:
caikelun@debian:~$ adb push ./main /data/local/tmp caikelun@debian:~$ adb shell "chmod +x /data/local/tmp/main" caikelun@debian:~$ adb shell "export LD_LIBRARY_PATH=/data/local/tmp; /data/local/tmp/main" Segmentation fault caikelun@debian:~$
思路是正確的。但之所以還是失敗了,是因為這段代碼存在下面的3個問題:
- 3f90 是個相對內存地址,需要把它換算成絕對地址。
- 3f90 對應的絕對地址很可能沒有寫入權限,直接對這個地址賦值會引起段錯誤。
- 新的函數地址即使賦值成功了,my_malloc 也不會被執行,因為處理器有指令緩存(instruction cache)。
我們需要解決這些問題。
內存
基地址
在進程的內存空間中,各種ELF的加載地址是隨機的,只有在運行時才能拿到加載地址,也就是基地址。
道哥注解:
我們在查看一個動態鏈接庫時,看到的入口地址都是 0x0000_0000。
動態庫在被加載到內存中時,因為存在加載順序的問題,所以加載地址不是固定的。
還有一種說法:對于某一個進程而言,它在被加載到內存中時,它所依賴的所有動態庫的順序是一定的。
因此,每個動態庫的加載地址也是固定的,因此,理論上可以在第一次重定位之后,把重定位之后的代碼段存儲下來。
這樣,以后再次啟動這個進程時,就不需要重定位了,加快程序的啟動速度。
我們需要知道ELF的基地址,才能將相對地址換算成絕對地址。
沒有錯,熟悉Linux開發的聰明的你一定知道,我們可以直接調用 dl_iterate_phdr。詳細的定義見這里。
道哥注解:
dl_iterate_phdr 這個函數真的很有用,以回調函數的形式可到每一個動態鏈接庫的加載地址等信息。
如果沒有這個函數,很多信息就需要從 /proc/xxx/maps 中來獲取,執行速度慢,因為要處理很多字符串信息。
嗯,先等等,多年的Android開發被坑經歷告訴我們,還是再看一眼NDK里的linker.h頭文件吧:
#if defined(__arm__) #if __ANDROID_API__ >= 21 int dl_iterate_phdr(int (*__callback)(struct dl_phdr_info*, size_t, void*), void* __data) __INTRODUCED_IN(21); #endif /* __ANDROID_API__ >= 21 */ #else int dl_iterate_phdr(int (*__callback)(struct dl_phdr_info*, size_t, void*), void* __data); #endif
為什么?!ARM 架構的Android 5.0以下版本居然不支持 dl_iterate_phdr!
我們的APP可是要支持Android 4.0以上的所有版本啊。
特別是 ARM,怎么能不支持呢?!這還讓不讓人寫代碼啦!
幸運的是,我們想到了,我們還可以解析 /proc/self/maps:
root@android:/ # ps | grep main ps | grep main shell 7884 7882 2616 1016 hrtimer_na b6e83824 S /data/local/tmp/main root@android:/ # cat /proc/7884/maps cat /proc/7884/maps address perms offset dev inode pathname --------------------------------------------------------------------- ........... ........... b6e42000-b6eb5000 r-xp 00000000 b3:17 57457 /system/lib/libc.so b6eb5000-b6eb9000 r--p 00072000 b3:17 57457 /system/lib/libc.so b6eb9000-b6ebc000 rw-p 00076000 b3:17 57457 /system/lib/libc.so b6ec6000-b6ec9000 r-xp 00000000 b3:19 753708 /data/local/tmp/libtest.so b6ec9000-b6eca000 r--p 00002000 b3:19 753708 /data/local/tmp/libtest.so b6eca000-b6ecb000 rw-p 00003000 b3:19 753708 /data/local/tmp/libtest.so b6f03000-b6f20000 r-xp 00000000 b3:17 32860 /system/bin/linker b6f20000-b6f21000 r--p 0001c000 b3:17 32860 /system/bin/linker b6f21000-b6f23000 rw-p 0001d000 b3:17 32860 /system/bin/linker b6f25000-b6f26000 r-xp 00000000 b3:19 753707 /data/local/tmp/main b6f26000-b6f27000 r--p 00000000 b3:19 753707 /data/local/tmp/main becd5000-becf6000 rw-p 00000000 00:00 0 [stack] ffff0000-ffff1000 r-xp 00000000 00:00 0 [vectors] ........... ...........
maps 返回的是指定進程的內存空間中mmap的映射信息,包括各種動態庫、可執行文件(如:linker),棧空間,堆空間,甚至還包括字體文件。
maps 格式的詳細說明見這里。
我們的libtest.so在maps中有3行記錄。
offset 為0的第一行的起始地址b6ec6000在絕大多數情況下就是我們尋找的基地址。
內存訪問權限
maps 返回的信息中已經包含了權限訪問信息。
如果要執行 hook,就需要寫入的權限,可以使用mprotect來完成:
#include int mprotect(void *addr, size_t len, int prot);
注意修改內存訪問權限時,只能以“頁”為單位。
mprotect 的詳細說明見這里。
指令緩存
注意.got和.data的section類型是 PROGBITS,也就是執行代碼。處理器可能會對這部分數據做緩存。
修改內存地址后,我們需要清除處理器的指令緩存,讓處理器重新從內存中讀取這部分指令。
方法是調用 __builtin___clear_cache:
void __builtin___clear_cache (char *begin, char *end);
注意清除指令緩存時,也只能以“頁”為單位。__builtin___clear_cache 的詳細說明見這里。
驗證
我們把main.c修改為:
#include
#include
#include
#include
#include
#include
#define PAGE_START(addr) ((addr) & PAGE_MASK)
#define PAGE_END(addr) (PAGE_START(addr) + PAGE_SIZE)
void *my_malloc(size_t size)
{
printf("%zu bytes memory are allocated by libtest.so", size);
return malloc(size);
}
void hook()
{
char line[512];
FILE *fp;
uintptr_t base_addr = 0;
uintptr_t addr;
//find base address of libtest.so
if(NULL == (fp = fopen("/proc/self/maps", "r"))) return;
while(fgets(line, sizeof(line), fp))
{
if(NULL != strstr(line, "libtest.so") &&
sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1)
break;
}
fclose(fp);
if(0 == base_addr) return;
//the absolute address
addr = base_addr + 0x3f90;
//add write permission
mprotect((void *)PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
//replace the function address
*(void **)addr = my_malloc;
//clear instruction cache
__builtin___clear_cache((void *)PAGE_START(addr), (void *)PAGE_END(addr));
}
int main()
{
hook();
say_hello();
return 0;
}
重新編譯運行:
caikelun@debian:~$ adb push ./main /data/local/tmp caikelun@debian:~$ adb shell "chmod +x /data/local/tmp/main" caikelun@debian:~$ adb shell "export LD_LIBRARY_PATH=/data/local/tmp; /data/local/tmp/main" 1024 bytes memory are allocated by libtest.so hello caikelun@debian:~$
是的,成功了!
我們并沒有修改libtest.so的代碼,甚至沒有重新編譯它。我們僅僅修改了main程序。
libtest.so 和main的源碼放在github上,可以從這里獲取到。
(根據你使用的編譯器不同,或者編譯器的版本不同,生成的libtest.so中,也許malloc對應的地址不再是 0x3f90,這時你需要先用readelf確認,然后再到main.c中修改。)
使用 xhook
當然,我們已經開源了一個叫xhook的工具庫。
使用 xhook,你可以更優雅的完成對libtest.so的hook操作,也不必擔心硬編碼0x3f90導致的兼容性問題。
#include
#include
#include
#include
void *my_malloc(size_t size)
{
printf("%zu bytes memory are allocated by libtest.so", size);
return malloc(size);
}
int main()
{
xhook_register(".*/libtest\\.so$", "malloc", my_malloc, NULL);
xhook_refresh(0);
say_hello();
return 0;
}
xhook 支持 armeabi, armeabi-v7a 和 arm64-v8a。
支持Android 4.0 (含)以上版本 (API level >= 14)。
經過了產品級的穩定性和兼容性驗證。可以在這里 獲取 xhook。
總結一下xhook中執行PLT hook的流程:
- 讀 maps,獲取 ELF 的內存首地址(start address)。
- 驗證 ELF 頭信息。
- 從 PHT 中找到類型為 PT_LOAD 且 offset 為 0 的 segment。計算 ELF 基地址。
- 從 PHT 中找到類型為 PT_DYNAMIC 的 segment,從中獲取到 .dynamic section,從 .dynamic section中獲取其他各項 section 對應的內存地址。
- 在 .dynstr section 中找到需要 hook 的 symbol 對應的 index 值。
- 遍歷所有的 .relxxx section(重定位 section),查找 symbol index 和 symbol type 都匹配的項,對于這項重定位項,執行 hook 操作。hook 流程如下:
(1) 讀 maps,確認當前 hook 地址的內存訪問權限。
(2) 如果權限不是可讀也可寫,則用 mprotect 修改訪問權限為可讀也可寫。
(3) 如果調用方需要,就保留 hook 地址當前的值,用于返回。
(4) 將 hook 地址的值替換為新的值。(執行 hook)
(5) 如果之前用 mprotect 修改過內存訪問權限,現在還原到之前的權限。
(6) 清除 hook 地址所在內存頁的處理器指令緩存。
FAQ
可以直接從文件中讀取 ELF 信息嗎?
可以。
而且對于格式解析來說,讀文件是最穩妥的方式,因為ELF在運行時,原理上有很多section不需要一直保留在內存中,可以在加載完之后就從內存中丟棄,這樣可以節省少量的內存。
但是從實踐的角度出發,各種平臺的動態鏈接器和加載器,都不會這么做,可能它們認為增加的復雜度得不償失。
所以我們從內存中讀取各種ELF信息就可以了,讀文件反而增加了性能損耗。
另外,某些系統庫ELF文件,APP 也不一定有訪問權限。
計算基地址的精確方法是什么?
正如你已經注意到的,前面介紹libtest.so基地址獲取時,為了簡化概念和編碼方便,用了“絕大多數情況下”這種不應該出現的描述方式。
對于hook來說,精確的基地址計算流程是:
- 在 maps 中找到找到 offset 為 0,且 pathname 為目標 ELF 的行。保存該行的 start address 為 p0。
- 找出 ELF 的 PHT 中第一個類型為 PT_LOAD 且 offset 為 0 的 segment,保存該 segment 的虛擬內存相對地址(p_vaddr)為 p1。
- p0 - p1 即為該 ELF 當前的基地址。
絕大多數的ELF第一個PT_LOAD segment的p_vaddr都是 0。
另外,之所以要在maps里找offset為0的行,是因為我們在執行hook之前,希望對內存中的ELF文件頭進行校驗,確保當前操作的是一個有效的 ELF,而這種 ELF 文件頭只能出現在offset為0的mmap區域。
可以在Android linker的源碼中搜索“load_bias”,可以找到很多詳細的注釋說明,也可以參考linker中對 load_bias_ 變量的賦值程序邏輯。
目標 ELF 使用的編譯選項對 hook 有什么影響?
會有一些影響。
對于外部函數的調用,可以分為3種情況:
- 直接調用。無論編譯選項如何,都可以被 hook 到。外部函數地址始終保存在 .got 中。
- 通過全局函數指針調用。無論編譯選項如何,都可以被 hook 到。外部函數地址始終保存在 .data 中。
- 通過局部函數指針調用。如果編譯選項為 -O2(默認值),調用將被優化為直接調用(同情況 1)。如果編譯選項為 -O0,則在執行 hook 前已經被賦值到臨時變量中的外部函數的指針,通過 PLT 方式無法 hook;對于執行 hook 之后才被賦值的,可以通過 PLT 方式 hook。
一般情況下,產品級的ELF很少會使用-O0進行編譯,所以也不必太糾結。
但是如果你希望你的ELF盡量不被別人 PLT hook,那可以試試使用-O0來編譯,然后盡量早的將外部函數的指針賦值給局部函數指針變量,之后一直使用這些局部函數指針來訪問外部函數。
總之,查看C/C++的源代碼對這個問題的理解沒有意義,需要查看使用不同的編譯選項后,生成的ELF的反匯編輸出,比較它們的區別,才能知道哪些情況由于什么原因導致無法被 PLT hook。
hook 時遇到偶發的段錯誤是什么原因?如何處理?
我們有時會遇到這樣的問題:
- 讀取 /proc/self/maps 后發現某個內存區域的訪問權限為可讀,當我們讀取該區域的內容做 ELF 文件頭校驗時,發生了段錯誤(sig: SIGSEGV, code: SEGV_ACCERR)。
- 已經用 mprotect() 修改了某個內存區域的訪問權限為可寫,mprotect() 返回修改成功,然后再次讀取 /proc/self/maps 確認對應內存區域的訪問權限確實為可寫,執行寫入操作(替換函數指針,執行 hook)時發生段錯誤(sig: SIGSEGV, code: SEGV_ACCERR)。
- 讀取和驗證 ELF 文件頭成功了,根據 ELF 頭中的相對地址值,進一步讀取 PHT 或者 .dynamic section 時發生段錯誤(sig: SIGSEGV, code: SEGV_ACCERR 或 SEGV_MAPERR)。
可能的原因是:
- 進程的內存空間是多線程共享的,我們在執行 hook 時,其他線程(甚至 linker)可能正在執行 dlclose(),或者正在用 mprotect() 修改這塊內存區域的訪問權限。
- 不同廠家、機型、版本的 Android ROM 可能有未公開的行為,比如在某些情況下對某些內存區域存在寫保護或者讀保護機制,而這些保護機制并不反應在 /proc/self/maps 的內容中。
問題分析:
- 讀內存時發生段錯誤其實是無害的。
- 我在 hook 執行的流程中,需要直接通過計算內存地址的方式來寫入數據的地方只有一處:即替換函數指針的最關鍵的那一行。只要其他地方的邏輯沒有錯誤,這里就算寫入失敗了,也不會對其他內存區域造成破壞。
- 加載運行安卓平臺的 APP 進程時,加載器已經向我們注入了 signal handler 的注冊邏輯,以便 APP 崩潰時與系統的 debuggerd 守護進程通訊,debuggerd 使用 ptrace 調試崩潰進程,獲取需要的崩潰現場信息,記錄到 tombstone 文件中,然后 APP 自殺。
- 系統會精確的把段錯誤信號發送給“發生段錯誤的線程”。
- 我們希望能有一種隱秘的,且可控的方式來避免段錯誤引起 APP 崩潰。
先明確一個觀點:
不要只從應用層程序開發的角度來看待段錯誤,段錯誤不是洪水猛獸,它只是內核與用戶進程的一種正常的交流方式。
當用戶進程訪問了無權限或未mmap的虛擬內存地址時,內核向用戶進程發送SIGSEGV信號,來通知用戶進程,僅此而已。
只要段錯誤的發生位置是可控的,我們就可以在用戶進程中處理它。
解決方案:
- 當 hook 邏輯進入我們認為的危險區域(直接計算內存地址進行讀寫)之前,通過一個全局 flag 來進行標記,離開危險區域后將 flag 復位。
- 注冊我們自己的 signal handler,只捕獲段錯誤。在 signal handler 中,通過判斷 flag 的值,來判斷當前線程邏輯是否在危險區域中。如果是,就用 siglongjmp 跳出 signal handler,直接跳到我們預先設置好的“危險區域以外的下一行代碼處”;如果不是,就恢復之前加載器向我們注入的 signal handler,然后直接返回,這時系統會再次向我們的線程發送段錯誤信號,由于已經恢復了之前的 signal handler,這時會進入默認的系統 signal handler 中走正常邏輯。
- 我們把這種機制簡稱為:SFP (segmentation fault protection,段錯誤保護)
- 注意:SFP需要一個開關,讓我們隨時能夠開啟和關閉它。在 APP 開發調試階段,SFP 應該始終被關閉,這樣就不會錯過由于編碼失誤導致的段錯誤,這些錯誤是應該被修復的;在正式上線后 SFP 應該被開啟,這樣能保證 APP 不會崩潰。(當然,以采樣的形式部分關閉 SFP,用以觀察和分析 hook 機制本身導致的崩潰,也是可以考慮的)
具體代碼可以參考xhook中的實現,在源碼中搜索siglongjmp和 sigsetjmp。
ELF 內部函數之間的調用能 hook 嗎?
我們這里介紹的hook方式為 PLT hook,不能做ELF內部函數之間調用的 hook。
道哥注解:
外部函數是被記錄到.plt這個section中的,因此可以在這個section中一步一步找到它的重定位地址,然后進行修改。
對于內部函數來說,比如一個使用static關鍵字修飾的函數,編譯器在編譯時,可能就直接把函數的地址“硬編碼”在引用它的地方了。
這也是為什么:如果一個函數只在文件內部使用,最好加上 static 關鍵字。
一個原因是安全,防止與其他文件中的符號重名,還有一個原因是加快啟動速度,因為不需要重定位啊!
inline hook 可以做到,你需要先知道想要hook的內部函數符號名(symbol name)或者地址,然后可以 hook。
有很多開源和非開源的inline hook實現,比如:
substrate:http://www.cydiasubstrate.com/
frida:https://www.frida.re/
inline hook 方案強大的同時可能帶來以下的問題:
- 由于需要直接解析和修改 ELF 中的機器指令(匯編碼),對于不同架構的處理器、處理器指令集、編譯器優化選項、操作系統版本可能存在不同的兼容性和穩定性問題。
- 發生問題后可能難以分析和定位,一些知名的 inline hook 方案是閉源的。
- 實現起來相對復雜,難度也較大。
- 未知的坑相對較多,這個可以自行 google。
建議如果PLT hook夠用的話,就不必嘗試inline hook了。