阅读 650

Android Native Hook

背景

在目前的安卓APP测试中对于Native Hook的需求越来越大,越来越多的APP开始逐渐使用NDK来开发核心或者敏感代码逻辑。 个人认为原因如下:

  • 安全的考虑。各大APP越来越注重安全性,NDK所编译出来的so库逆向难度明显高于java代码产生的dex文件。越是敏感的加密算法与数据就越是需要用NDK进行开发。
  • 性能的追求。NDK对于一些高性能的功能需求是java层无法比拟的。
  • 手游的兴起。虚幻4,Unity等引擎开发的手游中都有大量包含游戏逻辑的so库。

Android Native Hook工具目前的现状:

  • PLT Hook
  • Inline Hook

PLT Hook

先来介绍一下Android PLT Hook的基本原理。Linux在执行动态链接的ELF的时候,为了优化性能使用了一个叫延时绑定的策略。相关资料有很多,这边简述一下:这个策略是为了解决原本静态编译时要把各种系统API的具体实现代码都编译进当前ELF文件里导致文件巨大臃肿的问题。所以当在动态链接的ELF程序里调用共享库的函数时,第一次调用时先去查找PLT表中相应的项目,而PLT表中再跳跃到GOT表中希望得到该函数的实际地址,但这时GOT表中指向的是PLT中那条跳跃指令下面的代码,最终会执行_dl_runtime_resolve()并执行目标函数。第二次调用时也是PLT跳转到GOT表,但是GOT中对应项目已经在第一次_dl_runtime_resolve()中被修改为函数实际地址,因此第二次及以后的调用直接就去执行目标函数,不用再去执行_dl_runtime_resolve()了。因此,PLT Hook通过直接修改GOT表,使得在调用该共享库的函数时跳转到的是用户自定义的Hook功能代码。

了解PLT Hook的原理后,可以进一步分析出这种技术的特点:

  • 由于修改的是GOT表中的数据,因此修改后,所有对该函数进行调用的地方就都会被Hook到。这个效果的影响范围是该PLT和GOT所处的整个so库。因此,当目标so库中多行被执行代码都调用了该PLT项所对应的函数,那它们都会去执行Hook功能。
  • PLT与GOT表中仅仅包含本ELF需要调用的共享库函数项目,因此不在PLT表中的函数无法Hook到。

那么这些特点会导致什么呢?

  • 可以大量Hook那些系统API,但是难以精准Hook住某次函数调用。这比较适用于开发者对于自家APP性能监控的需求。比如Hook住malloc使其输出参数,这样就能大量统计评估该APP对于内存的需求。但是对于一些对Hook对象有一定精准度要求的需求来说很不利,比如说是安全测试或者逆向分析的工作需求,这些工作中往往需要对于目标so中的某些关键点有准确的观察。
  • 对于一些so内部自定义的函数无法Hook到。因为这些函数不在PLT表和GOT表里。这个缺点对于不少软件分析者来说可能是无法忍受的。因为许多关键或核心的代码逻辑往往都是自定义的。例如NDK中实现的一些加密工作,即使使用了共享库中的加密函数,但秘钥的保存管理等依然需要进一步分析,而这些工作对于自定义函数甚至是某行汇编代码的监控能力要求是远远超出PLT Hook所能提供的范围。
  • 在回调原函数方面,PLT Hook在hook目标函数时,如果需要回调原来的函数,那就在Hook后的功能函数中直接调用目标函数即可。可能有点绕,详细解释一下:假设对目标函数malloc()的调用在1.so中,用户用PLT Hook技术开发的HookMalloc()功能函数在2.so中。(因为通常情况下目标函数与用户的自定义Hook功能函数不在一个ELF文件里)当1.so中调用malloc()时会去1.so的PLT表中查询,结果是执行流程进入了2.so中的HookMalloc()中。如果这时候HookMalloc中希望调用原目标函数malloc(),那就直接调用malloc()就好了。因为这里的malloc会去2.so中的PLT表中查询,不受1.so中那个被修改过的PLT表的影响。

典型的PLT Hook工具推荐

本技术路线的典型代表是爱奇艺开源的xHook工具库。xhook 是一个针对 Android 平台 ELF (可执行文件和动态库) 的 PLT (Procedure Linkage Table) hook 库。从维护频率和项目标志设计来看这是一款产品级的开源工具。

这个工具库主要是用于开发者开发时把该项目集成进自己的APP,然后使用这个工具库来帮助开发者监控APK运行时那些他们关心的性能数据。比如通过hook malloc来监控内存分配等。由于这个库是被开发者集成进了APP中,所以它对于这个app的监控是不需要Root权限的。

PLT Hook技术应用的方向就应该是对自家开发的APP的性能监控。用该技术进行Hook的最大优势就在于其对于目标API可以进行批量Hook,使得开发者可以节省下大量的原本需要在APP中各个功能点上插入相关日志输出的工作。

Inline Hook

基本原理是在代码段中插入跳转指令,从而把程序执行流程引向用户需要的功能代码中去,以此达到Hook的效果,如下图所示:

这张图是一张arm下最基本的hook流程,从上图中可以看出主要有如下几个步骤:

  • 在想要Hook的目标代码处备份下面的几条指令,然后插入跳转指令,把程序流程转移到一个stub段上去。
  • 在stub代码段上先把所有寄存器的状态保存好,并调用用户自定义的Hook功能函数,然后把所有寄存器的状态恢复并跳转到备份代码处。
  • 在备份代码处把当初备份的那几条指令都执行一下,然后跳转到当初备份代码位置的下面接着执行程序。

由此可以看出使用Inline Hook有如下的Hook效果特点:

  • 完全不受函数是否在PLT表中的限制,直接在目标so中的任意代码位置都可进行Hook。这个Hook精准度是汇编指令级的。这对于逆向分析人员和安全测试人员来说是个非常好的特性!
  • 可以介入任意函数的操作。由于汇编指令级的Hook精度,以及不受PLT表的限制,Inline Hook技术可以去函数执行中的任意代码行间进行Hook功能操作,从而读取或修改任意寄存器,使得函数的操作流程完全可以被控制。
  • 对Hook功能函数的限制较小。由于在第二步调用Hook功能函数前已经把所有之前的寄存器状态都进行保存了,因此此时的Hook功能函数几乎就是个独立的函数,它无需受限于原本目标函数的参数形式,完全都由自己说了算。并且执行完后也完全是一个正常的函数退出形式释放栈空间。
  • 对于PLT Hook的强制批量Hook的特性,Native Hook要灵活许多。当想要进行批量Hook一些系统API时也可以直接去找内存里对应的如libc.so这些库,对它们中的API进行Hook,这样的话,所有对这个API的调用也就都被批量Hook了。

对比

如果只对于系统调用有参数或者性能上的监控需求,那可以考虑采用PLT Hook技术路线。一般适合APP的官方员工。 而如果是希望应对各种各样APP自己独有的NDK函数或者代码段的话,目前只能选择Inline Hook。适合APP逆向人员,软件分析人员,CTF Android逆向解题等。

PLT Hook 实践

新的动态库

假设有一个新的动态库: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。将它们拷贝进手机/data/local/tmp目录下,运行一下:

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 存在严重的内存泄露问题,每调用一次 say_hello 函数,就会泄露 1024 字节的内存。新版 APP 上线后崩溃率开始上升,各种诡异的崩溃信息和报障信息跌撞而至。

面临的问题

幸运的是,我们修复了 libtest.so 的问题。可是以后怎么办呢?我们面临 2 个问题:

当测试覆盖不足时,如何及时发现和准确定位线上 APP 的此类问题? 如果 libtest.so 是某些机型的系统库,或者第三方的闭源库,我们如何修复它?如果监控它的行为?

如果我们能对动态库中的函数调用做 hook(替换,拦截,窃听),那就能够做到很多我们想做的事情。比如 hook malloc,calloc,realloc 和 free,我们就能统计出各个动态库分配了多少内存,哪些内存一直被占用没有释放。

这真的能做到吗?答案是:hook 我们自己的进程是完全可以的。hook 其他进程需要 root 权限(对于其他进程,没有 root 权限就没法修改它的内存空间,也没法注入代码)。

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 的基地址,才能将相对地址换算成绝对地址。

我们可以直接调用 dl_iterate_phdr。详细的定义见 这里

看一眼 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

我们还可以解析 /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 中,也许 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 地址所在内存页的处理器指令缓存。

可以直接从文件中读取 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 实现,比如:

inline hook 方案强大的同时可能带来以下的问题:

  • 由于需要直接解析和修改 ELF 中的机器指令(汇编码),对于不同架构的处理器、处理器指令集、编译器优化选项、操作系统版本可能存在不同的兼容性和稳定性问题。
  • 发生问题后可能难以分析和定位,一些知名的 inline hook 方案是闭源的。
  • 实现起来相对复杂,难度也较大。
  • 未知的坑相对较多,这个可以自行 google。

建议如果 PLT hook 够用的话,就不必尝试 inline hook 了。

PLTHook 技术来获取线程创建的堆栈

配置Cmake

Cmake 主要作用是根据 CMakeList.txt(跨平台通用) 生成不同操作系统平台(**nix/win) 的 Makefile 和工程文件,简化项目编译配置流程。

  • CMake 官网 安装 Cmake 工具
  • 项目根目录创建 CMakeLists.txt 文件
  • 执行 cmake . (后面的点表示 CMakeLists.txt 文件路径)
  • 执行 make 进行编译

单个源文件

对于简单的项目,只需要写几行代码就可以了。例如,假设现在我们的项目中只有一个源文件 main.cc ,该程序的用途是计算一个数的指数幂。

#include <stdio.h>
#include <stdlib.h>
/**
 * power - Calculate the power of number.
 * @param base: Base value.
 * @param exponent: Exponent value.
 *
 * @return base raised to the power exponent.
 */
double power(double base, int exponent)
{
    int result = base;
    int i;
    
    if (exponent == 0) {
        return 1;
    }
    
    for(i = 1; i < exponent; ++i){
        result = result * base;
    }
    return result;
}
int main(int argc, char *argv[])
{
    if (argc < 3){
        printf("Usage: %s base exponent \n", argv[0]);
        return 1;
    }
    double base = atof(argv[1]);
    int exponent = atoi(argv[2]);
    double result = power(base, exponent);
    printf("%g ^ %d is %g\n", base, exponent, result);
    return 0;
}
复制代码

首先编写 CMakeLists.txt 文件,并保存在与 main.cc 源文件同个目录下:

# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo1)
# 指定生成目标
add_executable(Demo main.cc)
复制代码

CMakeLists.txt 的语法比较简单,由命令、注释和空格组成,其中命令是不区分大小写的。符号 # 后面的内容被认为是注释。命令由命令名称、小括号和参数组成,参数之间使用空格进行间隔。

对于上面的 CMakeLists.txt 文件,依次出现了几个命令:

  • cmake_minimum_required:指定运行此配置文件所需的 CMake 的最低版本;
  • project:参数值是 Demo1,该命令表示项目的名称是 Demo1 。
  • add_executable: 将名为 main.cc 的源文件编译成一个名称为 Demo 的可执行文件。

编译项目 之后,在当前目录执行 cmake . ,得到 Makefile 后再使用 make 命令编译得到 Demo1 可执行文件。

多个源文件

同一目录,多个源文件

上面的例子只有单个源文件。现在假如把 power 函数单独写进一个名为 MathFunctions.c 的源文件里,使得这个工程变成如下的形式:

./Demo2
    |
    +--- main.cc
    |
    +--- MathFunctions.cc
    |
    +--- MathFunctions.h
复制代码

这个时候,CMakeLists.txt 可以改成如下的形式:

# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo2)
# 指定生成目标
add_executable(Demo main.cc MathFunctions.cc)
复制代码

唯一的改动只是在 add_executable 命令中增加了一个 MathFunctions.cc 源文件。这样写当然没什么问题,但是如果源文件很多,把所有源文件的名字都加进去将是一件烦人的工作。更省事的方法是使用 aux_source_directory 命令,该命令会查找指定目录下的所有源文件,然后将结果存进指定变量名。其语法如下:

aux_source_directory(<dir> <variable>)
复制代码

因此,可以修改 CMakeLists.txt 如下:

# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo2)
# 查找当前目录下的所有源文件
# 并将名称保存到 DIR_SRCS 变量
aux_source_directory(. DIR_SRCS)
# 指定生成目标
add_executable(Demo ${DIR_SRCS})
复制代码

这样,CMake 会将当前目录所有源文件的文件名赋值给变量 DIR_SRCS ,再指示变量 DIR_SRCS 中的源文件需要编译成一个名称为 Demo 的可执行文件。

多个目录,多个源文件

现在进一步将 MathFunctions.h 和 MathFunctions.cc 文件移动到 math 目录下。

./Demo3
    |
    +--- main.cc
    |
    +--- math/
          |
          +--- MathFunctions.cc
          |
          +--- MathFunctions.h
复制代码

对于这种情况,需要分别在项目根目录 Demo3 和 math 目录里各编写一个 CMakeLists.txt 文件。为了方便,我们可以先将 math 目录里的文件编译成静态库再由 main 函数调用。

根目录中的 CMakeLists.txt :

# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo3)
# 查找当前目录下的所有源文件
# 并将名称保存到 DIR_SRCS 变量
aux_source_directory(. DIR_SRCS)
# 添加 math 子目录
add_subdirectory(math)
# 指定生成目标 
add_executable(Demo main.cc)
# 添加链接库
target_link_libraries(Demo MathFunctions)
复制代码

该文件添加了下面的内容: 第3行,使用命令 add_subdirectory 指明本项目包含一个子目录 math,这样 math 目录下的 CMakeLists.txt 文件和源代码也会被处理 。使用命令 target_link_libraries 指明可执行文件 main 需要连接一个名为 MathFunctions 的链接库 。

子目录中的 CMakeLists.txt:

# 查找当前目录下的所有源文件
# 并将名称保存到 DIR_LIB_SRCS 变量
aux_source_directory(. DIR_LIB_SRCS)
# 生成链接库
add_library (MathFunctions ${DIR_LIB_SRCS})
复制代码

在该文件中使用命令 add_library 将 src 目录中的源文件编译为静态链接库。

自定义编译选项

CMake 允许为项目增加编译选项,从而可以根据用户的环境和需求选择最合适的编译方案。

例如,可以将 MathFunctions 库设为一个可选的库,如果该选项为 ON ,就使用该库定义的数学函数来进行运算。否则就调用标准库中的数学函数库。

修改 CMakeLists 文件 我们要做的第一步是在顶层的 CMakeLists.txt 文件中添加该选项:

# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo4)
# 加入一个配置头文件,用于处理 CMake 对源码的设置
configure_file (
  "${PROJECT_SOURCE_DIR}/config.h.in"
  "${PROJECT_BINARY_DIR}/config.h"
  )
# 是否使用自己的 MathFunctions 库
option (USE_MYMATH
       "Use provided math implementation" ON)
# 是否加入 MathFunctions 库
if (USE_MYMATH)
  include_directories ("${PROJECT_SOURCE_DIR}/math")
  add_subdirectory (math)  
  set (EXTRA_LIBS ${EXTRA_LIBS} MathFunctions)
endif (USE_MYMATH)
# 查找当前目录下的所有源文件
# 并将名称保存到 DIR_SRCS 变量
aux_source_directory(. DIR_SRCS)
# 指定生成目标
add_executable(Demo ${DIR_SRCS})
target_link_libraries (Demo  ${EXTRA_LIBS})
复制代码

其中:

  • 第7行的 configure_file 命令用于加入一个配置头文件 config.h ,这个文件由 CMake 从 config.h.in 生成,通过这样的机制,将可以通过预定义一些参数和变量来控制代码的生成。
  • 第13行的 option 命令添加了一个 USE_MYMATH 选项,并且默认值为 ON 。
  • 第17行根据 USE_MYMATH 变量的值来决定是否使用我们自己编写的 MathFunctions 库。

修改 main.cc 文件 之后修改 main.cc 文件,让其根据 USE_MYMATH 的预定义值来决定是否调用标准库还是 MathFunctions 库:

#include 
#include 
#include "config.h"
#ifdef USE_MYMATH
  #include "math/MathFunctions.h"
#else
  #include 
#endif
int main(int argc, char *argv[])
{
    if (argc < 3){
        printf("Usage: %s base exponent \n", argv[0]);
        return 1;
    }
    double base = atof(argv[1]);
    int exponent = atoi(argv[2]);
    
#ifdef USE_MYMATH
    printf("Now we use our own Math library. \n");
    double result = power(base, exponent);
#else
    printf("Now we use the standard library. \n");
    double result = pow(base, exponent);
#endif
    printf("%g ^ %d is %g\n", base, exponent, result);
    return 0;
}
复制代码

编写 config.h.in 文件 上面的程序值得注意的是第2行,这里引用了一个 config.h 文件,这个文件预定义了 USE_MYMATH 的值。但我们并不直接编写这个文件,为了方便从 CMakeLists.txt 中导入配置,我们编写一个 config.h.in 文件,内容如下:

#cmakedefine USE_MYMATH
复制代码

这样 CMake 会自动根据 CMakeLists 配置文件中的设置自动生成 config.h 文件。

我们可以试试分别将 USE_MYMATH 设为 ON 和 OFF 得到的结果:

USE_MYMATH 为 ON

运行结果:

[ehome@xman Demo4]$ ./Demo
Now we use our own MathFunctions library. 
 7 ^ 3 = 343.000000
 10 ^ 5 = 100000.000000
 2 ^ 10 = 1024.000000
复制代码

此时 config.h 的内容为:

#define USE_MYMATH
复制代码

USE_MYMATH 为 OFF

运行结果:

[ehome@xman Demo4]$ ./Demo
Now we use the standard library. 
 7 ^ 3 = 343.000000
 10 ^ 5 = 100000.000000
 2 ^ 10 = 1024.000000
复制代码

此时 config.h 的内容为:

/* #undef USE_MYMATH */
复制代码