愛奇藝 Android PLT hook 技術分享

Android PLT hook 概述

獲取代碼和資源

你始終能夠從 這裏 訪問本文的最新版本。html

文中使用的示例代碼能夠從 這裏 獲取。文中提到的 xhook 開源項目能夠從 這裏 獲取。node

開始

新的動態庫

咱們有一個新的動態庫:libtest.so。linux

頭文件 test.handroid

#ifndef TEST_H
#define TEST_H 1

#ifdef __cplusplus
extern "C" {
#endif

void say_hello();

#ifdef __cplusplus
}
#endif

#endif
複製代碼

源文件 test.cc++

#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)。git

咱們須要一個測試程序:main。github

源文件 main.cshell

#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 中開始使用它吧!bash

遺憾的是,正如你可能已經發現的,libtest.so 存在嚴重的內存泄露問題,每調用一次 say_hello 函數,就會泄露 1024 字節的內存。新版 APP 上線後崩潰率開始上升,各類詭異的崩潰信息和報障信息跌撞而至。

面臨的問題

幸運的是,咱們修復了 libtest.so 的問題。但是之後怎麼辦呢?咱們面臨 2 個問題:

  1. 當測試覆蓋不足時,如何及時發現和準肯定位線上 APP 的此類問題?
  2. 若是 libtest.so 是某些機型的系統庫,或者第三方的閉源庫,咱們如何修復它?若是監控它的行爲?

怎麼作?

若是咱們能對動態庫中的函數調用作 hook(替換,攔截,竊聽,或者你以爲任何正確的描述方式),那就可以作到不少咱們想作的事情。好比 hook malloccallocreallocfree,咱們就能統計出各個動態庫分配了多少內存,哪些內存一直被佔用沒有釋放。

這真的能作到嗎?答案是: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)的大體步驟是:

  1. 檢查已加載的 ELF 列表。(若是 libtest.so 已經加載,就再也不重複加載了,僅把 libtest.so 的引用計數加一,而後直接返回。)
  2. 從 libtest.so 的 .dynamic section 中讀取 libtest.so 的外部依賴的 ELF 列表,今後列表中剔除已加載的 ELF,最後獲得本次須要加載的 ELF 完整列表(包括 libtest.so 自身)。
  3. 逐個加載列表中的 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 的引用計數加一。
  4. 逐個調用列表中 ELF 的構造函數(constructor),這些構造函數的地址是以前從 .dynamic segment 中讀取到的(類型爲 DT_INITDT_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 個問題:

  1. 3f90 是個相對內存地址,須要把它換算成絕對地址。
  2. 3f90 對應的絕對地址極可能沒有寫入權限,直接對這個地址賦值會引發段錯誤。
  3. 新的函數地址即便賦值成功了,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 的流程:

  1. 讀 maps,獲取 ELF 的內存首地址(start address)。
  2. 驗證 ELF 頭信息。
  3. 從 PHT 中找到類型爲 PT_LOAD 且 offset 爲 0 的 segment。計算 ELF 基地址。
  4. 從 PHT 中找到類型爲 PT_DYNAMIC 的 segment,從中獲取到 .dynamic section,從 .dynamic section中獲取其餘各項 section 對應的內存地址。
  5. .dynstr section 中找到須要 hook 的 symbol 對應的 index 值。
  6. 遍歷全部的 .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 來講,精確的基地址計算流程是:

  1. 在 maps 中找到找到 offset 爲 0,且 pathname 爲目標 ELF 的行。保存該行的 start address 爲 p0
  2. 找出 ELF 的 PHT 中第一個類型爲 PT_LOAD 且 offset 爲 0 的 segment,保存該 segment 的虛擬內存相對地址(p_vaddr)爲 p1
  3. 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 中狀況:

  1. 直接調用。不管編譯選項如何,均可以被 hook 到。外部函數地址始終保存在 .got 中。
  2. 經過全局函數指針調用。不管編譯選項如何,均可以被 hook 到。外部函數地址始終保存在 .data 中。
  3. 經過局部函數指針調用。若是編譯選項爲 -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 中的實現,在源碼中搜索 siglongjmpsigsetjmp

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 許可證 受權。

相關文章
相關標籤/搜索