爱奇艺 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 许可证 受权。

相关文章
相关标签/搜索