plt hook 原理与实践

2,597 阅读5分钟

前言

在 Android 应用开中, 针对于 native 的 hook 方案主要分为两种:

  • PLT Hook: 基于地址无关代码(PIC, Position-independent Code)原理设计的 hook 方案, 可用于 hook 对外部函数的调用, 代表项目: bhook.
  • Inline Hook: 通过在代码段中插入新的指令实现 hook 的方案, 代表项目: android-inline-hook.

本文主要对 PLT Hook 的原理进行探索和实践.

基本原理

ELF 文件

ELF 全称: Executable and Linkable Format, 是 Unix 和类 Unix 系统的标准二进制文件格式. 常见的 ELF 文件有:

  • 可重定位文件(Relocatable File): 这类文件包含了代码和数据, 可以被用来链接成可执行文件或共享目标文件.
  • 可执行文件(Executable File): 这类文件包含了可以直接执行的程序, 它们一般都没有扩展名.
  • 共享目标文件(Shared Object File): 这种文件包含了代码和数据, 可以在以下两种情况下使用: 一种时链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接, 产生新的目标文件; 第二种是动态链接器可以将几个这种共享目标文件与可执行文件结合, 做为进程映像的一部分来运行.

ELF 文件主要是由各类 段 组成, 在不同的视图模型下 段 会有不同的含义.

链接视图

在 ELF 文件被加载到内存之前, 编译器主要是通过读取 Section header table 中的信息, 以 Section 为单元操作修改 ELF 文件.
下面是进行 PLT Hook 所需要涉及的 Section 类型, 具体用法在后续章节讲解.

  • .dynamic: 保存了动态链接器所需要的基本信息, 比如动态链接的符号表的位置, 动态链接重定位表的位置等.
  • .dynsym: 与动态链接相关的符号, 不包括模块内部的符号.
  • .dynstr: 动态链接时的符号字符串表.
  • .rel.dyn / .rela.dyn: 用于数据重定位, 重定位的位置位于 .got.
  • .rel.plt / .rela.plt: 用于函数重定位, 重定位的位置位于 .got.plt.
  • .got: 外部数据跳转表.
  • .got.plt: 外部函数跳转表.
  • .plt: 外部函数调用中间表, 用于实现函数延迟绑定.

执行视图

当 ELF 文件被加载时(无论是可执行文件还是动态链接库), 系统内核会读取 Program header table 中的信息, 以 Fragment 为单元使用页映射的方式将 ELF 文件加载到内存中.
执行视图相较于链接视图, 会将多个拥有相同内存权限的 Section 合并到一个的 Fragment 中.

动态库加载

PLT Hook 主要涉及的是共享目标文件, 下面将介绍动态链接库加载时会使用到的两个关键技术.

地址无关代码(PIC)

动态链接相较于静态链接最大的区别在于符号的地址信息在被加载时才知道, 于是编译器会把符号的处理分成两类:

  • 模块内部符号: 在编译期可以知道相对地址, 在运行期使用相对地址进行访问.
  • 模块外部符号: 编译期在 .got 中预留地址空间, 在动态链接库被加载时由动态链接器负责填入正确的地址.

于是动态链接库中 .data 和 .text 的内存数据可以在多个进程中共享, 每个进程又会有自己的独享的 .got 内存数据. 当动态链接库调用外部函数时流程:

graph LR

caller_text(.text)
caller_got(.got)
callee_text(.text)

subgraph caller.so
caller_text --> caller_got
end
subgraph callee.so
caller_got --> callee_text
end

延迟绑定(PLT)

PIC 有一个缺点, 就是在动态链接库被加载时需要完成对所有符号的处理工作, 这会影响程序的启动速度. 于是在 PIC 的基础上衍生出了 PLT 技术. 当动态链接库调用外部函数时流程:

graph LR

caller_text(.text)
caller_plt(.plt)
caller_gotplt(.got.plt)
callee_text(.text)

subgraph caller.so
caller_text --> caller_plt
caller_plt --> caller_gotplt
end
subgraph callee.so
caller_gotplt --> callee_text
end

相较于 PIC, PLT 多出了 .plt 这个中间跳转表, .plt 中的大致逻辑:

  1. 判断函数符号在 .got.plt 中的地址是否已经初始化, 如已初始化跳转至步骤 3, 否则执行步骤 2;
  2. 使用 _dl_runtime_resolve 初始化 .got.plt 中的函数符号地址;
  3. 读取 .got.plt 中的对应函数符号地址, 跳转至该地址完成函数执行.

原理总结

动态链接库使用到外部符号时, 一定会通过 .got 或 .got.plt 确定符号运行时的地址, 所以只要在运行时修改 .got 和 .got.plt 中的符号地址信息, 就能实现 hook 操作.

代码实践

在知晓 PLT Hook 的基本原理后, 下面将在 Linux 操作系统中动手进行实践:

  1. 编写一个动态库, 动态库中会使用到 printf 函数;
  2. 通过 dl_iterate_phdr 在程序运行时获取动态库的信息, 并完成对 printf 的 hook 操作.

编写动态库

文件: lib.h

void hello_world();

文件: lib.c

#include "lib.h"
#include <stdio.h>

void hello_world(){
  printf("hello world\n");
}

通过执行 gcc -fPIC --shared lib.c -o lib.so 命令得到可用于动态链接的 lib.so 文件.

编写主程序

文件: main.c

#define _GNU_SOURCE
#include "lib.h"
#include <link.h>
#include <stdio.h>
#include <string.h>
#include <stdarg.h>

void my_printf(const char *str) {
  printf("before printf: %s\n", str);
  printf("%s\n", str);
  printf("after printf: %s\n", str);
}

int main() {
  hook();
  hello_world();
  return 0;
}

void hook() { 
  // 见: "编写 hook 操作" 章节
}

通过执行 gcc main.c lib.so -Wl,-rpath ./ -o main 命令得到可用于执行的 main 文件, main 在运行时会动态链接 lib.so 文件.
-Wl,-rpath ./ 指定了运行时共享目标文件查找目录, 所以运行时要保证 main 和 lib.so 文件在同一个目录下.

确认基本信息

符号名

$ objdump -d lib.so

...

0000000000001050 <puts@plt>:
    1050:       f3 0f 1e fa             endbr64 
    1054:       f2 ff 25 bd 2f 00 00    bnd jmpq *0x2fbd(%rip)        # 4018 <puts@GLIBC_2.2.5>
    105b:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

...

0000000000001119 <hello_world>:
    1119:       f3 0f 1e fa             endbr64 
    111d:       55                      push   %rbp
    111e:       48 89 e5                mov    %rsp,%rbp
    1121:       48 8d 3d d8 0e 00 00    lea    0xed8(%rip),%rdi        # 2000 <_fini+0xed0>
    1128:       e8 23 ff ff ff          callq  1050 <puts@plt>
    112d:       90                      nop
    112e:       5d                      pop    %rbp
    112f:       c3                      retq   

通过 objdump 命令可以看到, hello_world 中的 printf 函数调用被替换成了 puts@plt 函数, 而 puts@plt 函数最终会调用 puts@GLIBC_2.2.5 函数. 因此接下来将需要修改 .got.plt 中 puts 函数的地址.

重定位表结构

$ readelf -d lib.so

Dynamic section at offset 0x2e20 contains 24 entries:
  Tag        Type                         Name/Value
 0x0000000000000014 (PLTREL)             RELA

...

在 ELF 文件中, 有两种格式的重定位表: rel 和 rela, 为了简化下面的 hook 逻辑这里先通过 readelf 读取 .dynamic 段中 PLTREL 的信息得知 lib.so 中使用的是 rela 格式, 因此在下面进行 hook 操作时将进行一些简化, 不再对重定位表的格式进行判断.

内存权限

$ readelf -l lib.so

Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000530 0x0000000000000530  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x000000000000013d 0x000000000000013d  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000000cc 0x00000000000000cc  R      0x1000
  LOAD           0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x0000000000000218 0x0000000000000220  RW     0x1000
  DYNAMIC        0x0000000000002e20 0x0000000000003e20 0x0000000000003e20
                 0x00000000000001c0 0x00000000000001c0  RW     0x8
  NOTE           0x00000000000002a8 0x00000000000002a8 0x00000000000002a8
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x00000000000002c8 0x00000000000002c8 0x00000000000002c8
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_PROPERTY   0x00000000000002a8 0x00000000000002a8 0x00000000000002a8
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x000000000000200c 0x000000000000200c 0x000000000000200c
                 0x000000000000002c 0x000000000000002c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x00000000000001f0 0x00000000000001f0  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.property .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   01     .init .plt .plt.got .plt.sec .text .fini 
   02     .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.gnu.property 
   06     .note.gnu.build-id 
   07     .note.gnu.property 
   08     .eh_frame_hdr 
   09     
   10     .init_array .fini_array .dynamic .got 

在 Section to Segment mapping 中可以找到 hook 时需要修改的 .got.plt 位于 03 段中, 而在 Program Headers 中找到 03 段的信息可以看到 Flags 是 RW, 因此可以在运行时直接对 .got.plt 中的信息进行修改.

编写 hook 操作

基于 PLT Hook 的基本原理, hook 函数的实现主要分为以下几个步骤:

  1. 通过 dl_iterate_phdr 遍历当前进程中所有 .so 的信息, 找到 lib.so 的地址;
  2. 遍历 .dynamic 段中的信息, 找到 .rela.plt 的地址;
  3. 遍历 .rela.plt段中的信息, 找到 .got.plt 段中 puts 函数的地址;
  4. 将 .got.plt 段中 puts 函数的地址改为 my_printf 函数的地址.
int callback(struct dl_phdr_info *info, size_t size, void *data) {
  // 判断当前动态库是否是 `./lib.so` 的动态库的信息, 不如不是则跳过
  if (strcmp("./lib.so", info->dlpi_name) != 0) {
    return 0;
  }

  // 待从 .dynamic 段中查找出的信息
  int pltrelsz = 0; // .rela.plt 段大小
  char *dynstr = NULL; // .dynstr 段起始地址
  ElfW(Rela) *rela_plt = NULL; // .rela.plt 段起始地址
  ElfW(Sym) *dynsym = NULL; // .dynsym 段起始地址

  // 遍历当前动态库中所有段的信息
  for (size_t i = 0; i < info->dlpi_phnum; i++) {
    const ElfW(Phdr) *phdr = &info->dlpi_phdr[i];

    // 如果不是 .dynamic 段则跳过
    if (phdr->p_type != PT_DYNAMIC) {
      continue;
    }

    int dynEntryCount = phdr->p_memsz / sizeof(ElfW(Dyn));
    ElfW(Dyn) *dyn = (ElfW(Dyn) *)(phdr->p_offset + info->dlpi_addr);

    // 遍历获取 .dynamic 段中的信息
    for (int j = 0; j < dynEntryCount; j++) {
      ElfW(Dyn) *entry = &dyn[j];
      switch (dyn->d_tag) {
      case DT_PLTRELSZ: {
        pltrelsz = dyn->d_un.d_val;
        break;
      }
      case DT_JMPREL: {
        rela_plt = (ElfW(Rela) *)(info->dlpi_addr + dyn->d_un.d_ptr);
        break;
      }
      case DT_STRTAB: {
        dynstr = (char *)(info->dlpi_addr + dyn->d_un.d_ptr);
        break;
      }
      case DT_SYMTAB: {
        dynsym = (ElfW(Sym) *)(info->dlpi_addr + dyn->d_un.d_ptr);
        break;
      }
      }
      dyn++;
    }
  }

  // 遍历 .rela.plt 段中的信息
  int relaEntryCount = pltrelsz / sizeof(ElfW(Rela));
  for (int i = 0; i < relaEntryCount; i++) {
    ElfW(Rela) *entry = &rela_plt[i];

    // 获取 .dynsym 中的索引
    int r_sym = ELF64_R_SYM(entry->r_info);

    // 获取 .dynstr 中的索引
    int st_name = dynsym[r_sym].st_name;

    // 获取当前的符号名称
    char *name = &dynstr[st_name];

    // 如果不是 `puts` 符号, 则跳过
    if (strcmp("puts", name) != 0) {
      continue;
    }

    // 计算 `puts` 符号在 .got.plt 表中的位置
    uintptr_t hook_point = (uintptr_t)(info->dlpi_addr + entry->r_offset);

    // 修改 `puts` 符号的跳转地址为: my_printf
    *(void **)hook_point = my_printf;
  }

  return 0;
}


void hook() { dl_iterate_phdr(callback, NULL); }

最终效果

$ ./main
before printf: hello world
hello world
after printf: hello world

通过 ./main 命令运行主程序, 将会得到下面的输出, 可以看到在 hello world 前后多了两行输出, 说明 lib.so 中的 printf 函数调用已经被替换成了 my_printf.

参考资料