Android PLT hook 概述
獲取程式碼和資源
你始終可以從 這裡 訪問本文的最新版本。
文中使用的示例程式碼可以從 這裡 獲取。文中提到的 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 <stdlib.h>
#include <stdio.h>
void say_hello()
{
char *buf = malloc(1024);
if(NULL != buf)
{
snprintf(buf, 1024, "%s", "hello\n");
printf("%s", buf);
}
}
複製程式碼
say_hello
的功能是在終端列印出 hello\n
這6個字元(包括結尾的 \n
)。
我們需要一個測試程式:main。
原始檔 main.c
#include <test.h>
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 自己就夠了。
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
以外的重定位資訊。(比如通過全域性函式指標來呼叫外部函式)
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。載入步驟:
- 用
mmap
預留一塊足夠大的記憶體,用於後續對映 ELF。(MAP_PRIVATE
方式) - 讀 ELF 的 PHT,用
mmap
把所有型別為PT_LOAD
的 segment 依次對映到記憶體中。 - 從 .dynamic segment 中讀取各資訊項,主要是各個 section 的虛擬記憶體相對地址,然後計算並儲存各個 section 的虛擬記憶體絕對地址。
- 執行重定位操作(relocate),這是最關鍵的一步。重定位資訊可能存在於下面的一個或多個 secion 中:
.rel.plt
,.rela.plt
,.rel.dyn
,.rela.dyn
,.rel.android
,.rela.android
。動態連結器需要逐個處理這些.relxxx
section 中的重定位訴求。根據已載入的 ELF 的資訊,動態連結器查詢所需符號的地址(比如 libtest.so 的符號malloc
),找到後,將地址值填入.relxxx
中指明的目標地址中,這些“目標地址”一般存在於.got
或.data
中。 - 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 <say_hello@@Base>:
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 <malloc@plt>
f6c: 4604 mov r4, r0
f6e: b16c cbz r4, f8c <say_hello@@Base+0x2c>
f70: a507 add r5, pc, #28 ; (adr r5, f90 <say_hello@@Base+0x30>)
f72: a308 add r3, pc, #32 ; (adr r3, f94 <say_hello@@Base+0x34>)
f74: 4620 mov r0, r4
f76: f44f 6180 mov.w r1, #1024 ; 0x400
f7a: 462a mov r2, r5
f7c: f7ff ef30 blx de0 <snprintf@plt>
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 <malloc@plt>:
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
裡,這絕對不是巧合啊!還等什麼,趕緊改程式碼吧。我們的 main.c 應該改成這樣:
#include <test.h>
void *my_malloc(size_t size)
{
printf("%zu bytes memory are allocated by libtest.so\n", 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 的載入地址是隨機的,只有在執行時才能拿到載入地址,也就是基地址。我們需要知道 ELF 的基地址,才能將相對地址換算成絕對地址。
沒有錯,熟悉 Linux 開發的聰明的你一定知道,我們可以直接呼叫 dl_iterate_phdr
。詳細的定義見 這裡。
嗯,先等等,多年的 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 <sys/mman.h>
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
我們把 main.c
修改為:
#include <inttypes.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>
#include <test.h>
#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\n", 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 <stdlib.h>
#include <stdio.h>
#include <test.h>
#include <xhook.h>
void *my_malloc(size_t size)
{
printf("%zu bytes memory are allocated by libtest.so\n", 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 流程如下:- 讀 maps,確認當前 hook 地址的記憶體訪問許可權。
- 如果許可權不是可讀也可寫,則用
mprotect
修改訪問許可權為可讀也可寫。 - 如果呼叫方需要,就保留 hook 地址當前的值,用於返回。
- 將 hook 地址的值替換為新的值。(執行 hook)
- 如果之前用
mprotect
修改過記憶體訪問許可權,現在還原到之前的許可權。 - 清除 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。
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 了。
聯絡作者
caikelun#qiyi.com (請用 @ 替換 #)
許可證
Copyright (c) 2018, 愛奇藝, Inc. All rights reserved.
本文使用 Creative Commons 許可證 授權。