【Android】Native Hook 学习笔记:PLT Hook

链接

链接主要指将代码和数据片段收集并组合为单一文件的过程。它有如下三种执行时机:

  • 编译时:源代码翻译为机器代码时
  • 加载时:被加载器加载到内存并执行时
  • 运行时:由应用程序执行

它可以使得分离编译成为可能。早期链接需要手动执行,如今则由链接器(linker)自动执行。

例如如下的例子:

main.c:

int sum(int* a, int n);

int array[2] = {1, 2};

int main() {
    int val = sum(array, 2);
    return val;
}

sum.c:

int sum(int* a, int n) {
    int i, s = 0;
    for (i = 0; i < n; i++) {
        s += a[i];
    }
    return s;
}

在大多数编译系统都提供了编译驱动程序,用户在需要的时候调用语言的预处理器、编译器、汇编器、链接器。

例如上面的例子在编译过程就是经过了如下的步骤:

静态链接

上面 ld 这种静态链接器用一组可重定位目标文件作为命令行参数输入,生产一个完全链接,可以加载和运行的可执行文件。可重定向目标文件由不同的代码和数据节组成,每一节都是一个连续的字节序列。初始化了的全局变量在一节中,未初始化的变量在另一节中。

链接器必须完成两个主要工作:

  • 符号解析:目标文件定义和引用符号,每个符号对应了一个函数、一个全局变量、一个静态变量。符号解析阶段将每个符号引用刚好与一个符号定义关联起来。
  • 重定位:编译器和汇编器生成从 0 开始的代码和数据节,链接器则通过将每个符号定义与一个内存位置关联起来,从而重定位这些节。之后修改所有对这些符号的引用,指向这个内存位置。

目标文件

目标文件有三种形式:

  • 可重定位目标文件:包含二进制代码和数据,形式可以在编译时与其他可重定位目标文件进行合并,从而创建可执行目标文件。
  • 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并执行。
  • 共享目标文件:特殊类型的可重定位目标文件,可以在加载或运行时被动态加载进内存并链接。

可重定位目标文件

如图是一个典型的 ELF 可重定位文件的格式:

ELF 头以一个 16 字节的序列开始,它描述了生成这个文件的系统的字大小以及字节顺序。剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,包括了 ELF 头的大小、目标文件的类型(如可重定位目标文件)、机器类型(如 x86-64)、节头部表的偏移,以及节头部表中条目的大小和数量。

不同节的位置和大小是由节头部表描述的,目标文件中每个节都有一个固定大小的条目(entry)位于节头部表中。

ELF 头和节头部表之间的都是节,其中典型的 ELF 可重定位目标文件包含下列几个节:

  • .text:已编译程序的机器代码
  • .rodata:只读数据,例如 printf 中的格式化串或 switch 语句的跳转表
  • .data:已初始化的全局和静态 C 变量,局部 C 变量运行时保存在栈中,不出现在 .data 或 .bss 任何一个节中。
  • .bss:未初始化的静态变量,或初始化为 0 的全局或静态变量。目标文件中这个节不占用实际的空间,仅仅是一个占位符。区分已初始化和未初始化变量是为了空间效率,因为未初始化变量不需要占据任何实际磁盘空间。
  • .symtab:符号表,存放了程序中定义和引用的函数和全局变量的信息。(不包含局部变量的条目)
  • .rel.text:.text 节中位置的列表,当链接器将这个目标文件和其他文件进行组合时,需要修改这些位置。一般来说任何调用外部函数或引用全局变量的指令都需要修改。(可执行目标文件中不需要重定位信息,因此一般省略)
  • .rel.data:被模块引用或定义的所有全局变量的重定位信息,一般来说任何已初始化的全局变量,如果其初始值为一个全局变量地址或外部定义函数的地址,都需要被修改。
  • .debug:调试符号表,里面存储了程序中定义的局部变量和类型定义。程序中定义和引用的全局变量,以及原始 C 源文件。只有以 -g 编译时才会存在。
  • .line:原始 C 源程序中的行号和 .text 机器指令的映射,只有 -g 编译时才存在
  • .strtab:一个字符串表,包括 .symtab 和 .debug 中的符号表,以及节头部中的节名字。

符号和符号表

每个可重定位目标模块 m 都有一个符号表,它包含了 m 定义和引用的符号的信息。链接器上下文中有三种不同的符号:

  • 由 m 定义并能被其他模块引用的全局符号,对应于非静态的 C 函数和全局变量。
  • 由其他模块定义并被 m 引用的全局符号,称为外部符号,对应于其他模块定义的非静态的 C 函数和全局变量。
  • 只被 m 定义和引用的局部符号,对应于带 static 属性的 C 函数和全局变量,它们不能被其他模块引用。(说明 static 时该模块私有的)

符号表中的符号的格式如下:

struct {
        int name;               // 字符串表偏移
        char type:4;        // 类型:函数/变量
        char binding:4; // 权限:全局/本地
        char reserved;  // 没有使用
        short section;  // 节头的 index
        long value;         // 节偏移或绝对地址
        long size;          // 占据字节数
} Elf64_Symbol;

每个符号都分配到目标文件的某个节,有三个特殊的伪节,他们在节头目表没有条目:

  • ABS:不该被重定位的符号
  • UNDEF:未定义的符号也就是本模块引用,但在其他地方定义。
  • COMMON:还未分配位置的未初始化数据目标

符号解析

链接器对符号引用的解析是将引用与它输入的可重定位目标文件的符号表中的符号定义关联起来。局部变量和静态局部变量都是比较好处理的,因为编译器只允许每个模块的每个局部符号只有一个定义。

但是对于全局符号的引用解析,当遇到不是在当前模块中定义的符号时,会假设这个符号是在其他模块中定义的,生成一个链接器的符号表条目。如果链接器在任何输入模块中都找不到这个符号表条目的定义,就会输出一条错误信息然后终止。

Linux 对重复定义符号的处理

并且有可能多个目标文件定义同名的全局符号,此时链接器要么输出错误,要么以某种方式选择出一个定义并丢弃其他。

C++ 中的符号时用了重整的方式,例如类 Foo 被编码成 3Foo,方法被编码为原始方法名,后面跟上_,再加上每个参数的单字母编码。

如:Foo::bar(int, long) 就变成了 bar__3Fooil。

Linux 中,对于多重定义的全局符号,是这样处理的:

  • 规则 1:不允许多个同名的强符号。
  • 规则 2:一强符号和多个弱符号同名,选择强符号。
  • 规则 3:多个弱符号同名,从中任选其一。

(例如初始化了的变量就是强符号,未初始化的变量就是弱符号)

与静态库链接

所有编译系统都提供了一种将所有相关的目标模块打包为一个单独的文件——静态库(static library)的机制。静态库以 .a 结尾,可以作为链接器的输入,在构造可执行文件时,只复制里面被应用程序引用的目标模块。这样的设计可以解决引入一些库带来的空间占用问题。

例如如果我们有一个 libvector.a 的静态库,其中包含了 addvec.c 和 multvec.c,我们在 vector.h 中对该静态库中的函数进行了声明,则如果我们只调用了 addvec.c 的 addvec 函数以及 printf 输出函数,则链接时如下:

可以发现没有用到的 multvec 是没有使用到的。

静态库的符号解析

假设我们有一个可重定位目标文件集合 E,一个未解析符号集合 U 和一个在前面输入文件中已定义的符号集合 D。则解析的过程如下:

  • 初始时 E、U、D 均为空,链接器会按命令行从左到右的顺序扫描输入的文件
  • 如果输入了文件 f,链接器会判断其是一个目标文件还是一个存档文件(静态库)
  • 若是目标文件,则会将 f 添加到 E,修改 U 和 D 反映 f 中的符号定义和引用,继续输入下一个文件
  • 若是存档文件,则会尝试匹配 U 中未解析的符号和存档文件定义的符号,若某个存档文件的成员 m 定义了这个符号,则会将 m 添加到 E,并修改 U 和 D 反映 m 中的符号定义和引用,直到 U 和 D 都不再发生变化,此时剩余成员目标文件都被丢弃,然后输入下一个文件
  • 当扫描输入文件完成后,U 为空的,则说明符号解析结束,否则输出一个错误。

这就造成命令行中的顺序很关键,如果定义符号的库出现在引用的目标文件之前,则该引用就无法解析,导致链接失败。

对于一些循环引用的库,可能需要多次出现在扫描列表中,或是将它们进行合并。

重定位

链接器完成符号解析这一步后,就会将每个符号引用和符号定义进行关联,此时链接器就可以知道目标模块中的代码节和数据节的确切大小,就可以开始重定位了,这个过程中就会合并输出模块。

重定位分为两步:

  1. 重定位节和符号定义。链接器将所有同类型的节合并为同一个新的聚合节,之后链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。完成时,每条指令和全局变量都有唯一的运行时内存地址了
  2. 重定位节中的符号引用。这一步中,链接器修改代码节和数据节中对每个符号的引用,让他们指向正确的运行时地址,这一步依赖于可重定位目标模块中的重定位条目数据结构。

重定位条目

汇编器生成一个目标模块时,其并不知道数据和代码最终放在内存中的哪个位置,也不知道模块引用的任何外部定义函数或全局变量的位置。因此无论何时汇编器遇到了对最终位置未知的目标引用,都会生成一个重定位条目,告诉链接器将目标文件合并为可执行文件时如何修改这个引用。

代码重定位条目位于 .rel.text,而数据的重定位条目位于 .rel.data 中。

ELF 的重定位条目格式如下:

struct {
    long offset;        // 需要被修改的引用的节偏移
    long type:32;       // 修改引用的方式
    long symbol:32; // 符号表的 index
    long addend;        // 有的类型的重定位需要用它对引用的值做偏移调整
} Elf64_Rela;

ELF 中有 32 种不同的重定位类型,常见的两种如下:

  • R_X86_64_PC32:重定位一个使用 32 位 PC 相对地址的引用。(PC 相对地址指距离 PC 当前运行值的偏移量),通过在 PC (程序计数器)当前运行地址进行偏移,即可得到有效地址。
  • R_X86_64_32:重定位一个使用 32 位绝对地址的引用,CPU 直接使用指令中的 32 位值作为有效地址,不需要进一步修改。

重定位符号引用

重定位符号引用的伪代码如下:

foreach section s {
    foreach relocation entry r {
        refptr = s + r.offset;

        if (r.type == R_X86_64_PC32) {
            refaddr = ADDR(s) + r.offset;
            *refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
        }

        if (r.type == R_X86_64_32) {
            *refptr = (unsigned) (ADDR(r.symbol) + r.addend);
        }
    }
}

可执行目标文件

链接器会将多个目标文件合并为一个可执行目标文件,如下是一个典型的 ELF 可执行文件结构:

它的格式类似可重定位目标文件的格式,它还包括了程序的入口点,也就是第一行指令的地方,.init 节定义了一个小函数 _init,程序初始化代码会调用它。

ELF 可执行文件的连续的文件节会被映射到连续的内存段,在段头部表中描述了这种映射关系。

动态链接共享库

静态库需要定期的维护和更新,每次更新后需要将现有的程序与新的库重新链接。

共享库(shared library)致力于解决静态库的缺陷,它在运行或加载时,可以加载到任意的内存地址,和一个在内存中的程序链接起来,这个过程就是动态链接。在 Linux 中它们以 .so 作为后缀,而微软的操作系统中则以 .dll 作为后缀。

引用 so 库的可执行目标文件共享这个 so 文件中的所有代码和数据,并且在内存中一个 so 库的 .text 节的副本可以被多个正在运行的进程共享。

动态链接器通过执行下面的重定位从而完成链接任务:

  • 重定位 libc.so 的文本和数据到某个内存段
  • 重定位 libvector.so 的文本和数据到另一个内存段
  • 重定位 program 中所有对 libc.so 和 libvector.so 定义的符号引用。

共享库 API

加载/链接共享库

可以通过 Linux 系统中提供的接口加载和链接共享库:

#include <dlfcn.h>

void* dlopen(const char* filename, int flag);

这个函数用于加载和链接动态库 filename,返回该共享库的句柄。

获取符号
#include <dlfcn.h>

void* dlsym(void* handle, char* symbol);

它的输入是一个指向前面的共享库的句柄和一个符号,如果存在,则返回该符号地址。

关闭共享库
#include <dlfcn.h>

int dlclose(void* handle);

如果没有别的共享库使用它,则卸载该共享库。

获取错误
#include <dlfcn.h>

const char* dlerror(void);

返回一个字符串,描述前面几个函数调用时发生的最近的错误,如果没有则返回 NULL。

位置无关代码

共享库要使得能被多个运行进程共享内存中的代码,需要编译共享模块的代码段时,使得它们能加载到内存的任何位置而无需链接器修改,这样无限个进程就可以共享这个共享代码段的单一副本。

这种可以加载而无需重定位的代码叫做位置无关代码(PIC),此时对共享库的全局变量的引用和函数的引用需要一些特殊的技巧。

PIC 数据引用

编译器保证了无论我们在内存的任何一个地方加载目标模块(包括共享目标模块),数据段和代码段的距离总是保持不变的。因此代码段中任何指令和数据段中的任何变量之间的距离都是一个运行时的常量。

要对全局变量 PIC 引用,它在数据段开始的地方维护了一个全局偏移量表(GOT),每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个 8 字节条目,同时为每一个条目生成了一个重定位记录。加载时,动态链接器会重定位 GOT 中的每个条目,使得它包含了目标正确的绝对地址,每个引用了全局数据目标的目标模块都有自己的 GOT。

例如假设在数据段中维护了这样一个 GOT 表:

GOT[0]: ...
GOT[1]: ...
GOT[2]: ...
GOT[3]: &addcnt

则下面的代码段:

addvec:
    mov 0x2008b9(%rip), % rax # %rax=*GOT[3]=&addcnt
    addl $0x1,(%rax)                    # addcnt++

运行时,GOT[3]addl 这条指令之间的固定距离是 0x2008b9,因此只需要这样计算偏移,即可引用 addcnt 这个变量。

PIC 函数调用

程序如果要调用一个共享库定义的函数,则编译器无法预测这个函数的运行时地址。如果编译器对每个引用生成一条重定位记录,加载的时候进行解析,则没有很大必要。比如像 libc.so 中定义的成百上千的函数,一个应用可能只会用到其中很少的一部分,因此可以把函数地址的解析延迟到它实际被调用的地方,避免动态链接器加载时生成成百上千个没必要的重定位。这种技术叫做延迟绑定技术。

延迟绑定依赖于两个数据结构:全局偏移量表(GOT)和过程链接表(PLT),如果一个目标模块调用了定义在共享库中的函数,则它会具有自己的 GOT 和 PLT。GOT 为数据段的一部分,PLT 为代码段的一部分。

PLT 是一个数组,每个条目占据 16 个字节的代码,每个条目都负责了调用一个具体的函数,PLT[1] 调用了系统的启动函数(__libc_start_main),它初始化执行环境,调用 main 并处理返回值。 PLT[2] 开始则是负责调用用户代码调用的函数。

GOT 也是一个数组,每个条目存放一个 8 字节的地址,与 PLT 联合使用的时候,GOT[0] 和 GOT[1] 包含了动态链接器在解析函数地址时会使用的信息,GOT[2] 是动态链接器在 ld-linux.so 模块的入口点,其余的条目对应了被调用的函数,需要在运行时被解析,每个条目有一个相匹配的 PLT 条目,每个 GOT 条目在初始时都指向了 PLT 中的下一条指令。

例如 GOT[4] 存放了 addvec 函数的地址,而 PLT[2] 则存放了跳转到 GOT[4] 的代码。

GOT[0]: addr of .dynamic
GOT[1]: addr of reloc entries
GOT[2]: addr of dynamic linker
GOT[3]: 0x4005b6 # sys startup
GOT[4]: 0x4005c6 # addvec()
GOT[5]: 0x4005d6 # println()

PLT[0]:
4005a0: pushq *GOT[1]
4005a6: jmpq *GOT[2]

PLT[2]:
4005c0: jmpq *GOT[4]
4005c6: pushq $0x1
4005cb: jmpq 4005a0

那么第一次调用 addvec 时,步骤如下:

  1. 不直接调用 addvec,进入 PLT[2](addvec 对应的条目)
  2. 第一条 PLT 指令通过 GOT[4] 间接跳转,由于每个 GOT 条目初始都指向了 PLT 的下一条指令,因此继续向下走
  3. 将 addvec 的 ID 压入栈中后,PLT[2] 跳转到 PLT[0]
  4. PLT[0] 通过 GOT[1] 将动态链接器的参数压入栈中,之后通过 GOT[2] 跳转到动态链接器中,从而确定 addvec 的运行时为止,并将其写入 GOT[4],然后将控制权交给 addvec

第二次再调用 addvec 时,步骤如下:

  1. 不直接调用 addvec,进入 PLT[2](addvec 对应的条目)
  2. 通过 GOT[4] 的简接跳转从而直接转移到 addvec 函数的代码。

PLT Hook

Linux 支持了库打桩机制,它允许截获共享库的调用,取而代之自己的代码,可以进行对输入输出值的追踪,甚至换成一个完全不同的实现。

它的思想:给定一个需要打桩的目标函数,和一个与目标函数原型一致的包装函数,通过欺骗系统调用包装函数而不是目标函数从而进行 PLT Hook。

这种 Hook 的核心就是利用前面提到的 GOT 表和 PLT 表,利用函数调用延迟绑定的特点,从而实现瞒天过海。它通过直接修改 GOT 表,使得在调用的时候调用的是用户自定义的 Hook 代码。

关于具体实现,有下面几个可以参考的开源库:

由于修改了 GOT 表,因此所有该函数的调用都会被 Hook 到,影响到的是这个 PLT 和 GOT 表所处的整个 so 库。并且由于 GOT 和 PLT 表只包含了当前共享库需要调用的共享库函数,因此不在 PLT 表中的函数无法 Hook。

因此 PLT Hook 有着如下的特点:

  1. 可以大量 Hook 系统 API,但是难以精准 Hook 某次函数调用。
  2. 对于一些 so 内部自定义的函数无法 Hook 到。
  3. 在回调原函数方面,PLT Hook 在 hook 目标函数时,如果需要回调原来的函数,那就在Hook后的功能函数中直接调用目标函数即可。因为在原目标函数所在 so 的 GOT 表中存放的是 Hook 的函数,而在 Hook 的函数所在 so 的 GOT 表中存放的是原目标函数。

参考资料

《深入理解计算机系统》(Computer Systems A Programmer's Perspective

Android Native Hook技术路线概述

点赞

发表评论

电子邮件地址不会被公开。必填项已用 * 标注

%d 博主赞过: