PLT hook 的实现机制

一、从案例说起

文件:a.h

1
2
3
4
5
6
7
8
9
#ifdef __cplusplus
extern "C" {
#endif

void say_hello();

#ifdef __cplusplus
}
#endif

文件:a.c

1
2
3
4
5
#include <stdio.h>

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

文件:main.c

1
2
3
4
5
6
#include "a.h"

int main() {
say_hello();
return 0;
}

我们通过如下编译,生成二进制执行,会输出 hello world! 字符串。增加 -fno-builtin 是为了防止编译器将 printf 优化成 puts 调用。

1
2
gcc a.c -shared -fPIC -fno-builtin -o liba.so
gcc main.c liba.so -o main

我们现在要 hook printf,使其完成一些业务逻辑。

二、跟踪分析

在 Linux 下,我们的动态库、二进制文件都是 ELF 格式的。并且一般的动态库都是 PIC 方法编译的,产生的是地址无关代码。有了这个前提,我们来看下我们的动态库 liba.so 是如何引用 printf 的。

我们先来静态分析下 liba.so 这个文件。通过 objdump -D liba.so -M intel > o_d.txt 反汇编代码

1
2
3
4
5
6
7
8
9
000000000000112a <say_hello>:
112a: 55 push rbp
112b: 48 89 e5 mov rbp,rsp
112e: 48 8d 3d cb 0e 00 00 lea rdi,[rip+0xecb] # 2000 <_fini+0xebc>
1135: b8 00 00 00 00 mov eax,0x0
113a: e8 f1 fe ff ff call 1030 <printf@plt>
113f: 90 nop
1140: 5d pop rbp
1141: c3 ret

我们的 say_hello 函数中调用了 printf 函数,是通过 call 1030 指令实现的,也就是跳转到 1030 位置执行。我们再来看看此位置

1
2
3
4
5
6
Disassembly of section .plt:
...
0000000000001030 <printf@plt>:
1030: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2] # 4018 <printf@GLIBC_2.2.5>
1036: 68 00 00 00 00 push 0x0
103b: e9 e0 ff ff ff jmp 1020 <_init+0x20>

其中 <printf@plt> 位于 .plt 节,而他的实现中第一行便是跳转到 rip+0x2fe2 处。rip 是指令指针寄存器,用来存储 CPU 即将要执行的指令地址,也就是下一条指令地址。在这里 rip 的值为 1036。相当于跳到 0x1036 + 0x2fe2 = 0x4018 位置处。我们再来跟踪。

1
2
3
4
5
6
7
8
9
10
Disassembly of section .got.plt:

0000000000004000 <_GLOBAL_OFFSET_TABLE_>:
4000: 20 3e and BYTE PTR [rsi],bh
...
4016: 00 00 add BYTE PTR [rax],al
4018: 36 10 00 ss adc BYTE PTR [rax],al
401b: 00 00 add BYTE PTR [rax],al
401d: 00 00 add BYTE PTR [rax],al
...

地址 4018 处是在 “.got.plt” 节中。不用看他的反汇编,这个节中存储的是数据,而不是指令。

“.got.plt” 节用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了此节中了。另外 “.got.plt” 节,他的前三项分别保存的是:”.dynamic” 段的地址、本模块的ID、_dl_runtime_resolve() 的地址。从第四项开始才是外部函数的引用。在 64 位系统下,一项是 8 字节。

那么地址 4018 处的存储的是 0x1036 这个值(注意是小端)。那也就是说在 printf@plt 中的 jmp 会跳到这个地址去执行代码。我们一看,0x1036 不就是 printf@plt 的第二行指令嘛。链接器的确是这样做的,我们继续往下看,printf@plt 紧接着,向栈中压入一个 0 值,然后跳到了 1020 的位置。

1
2
3
4
5
Disassembly of section .plt:
0000000000001020 <printf@plt-0x10>:
1020: ff 35 e2 2f 00 00 push QWORD PTR [rip+0x2fe2] # 4008 <_GLOBAL_OFFSET_TABLE_+0x8>
1026: ff 25 e4 2f 00 00 jmp QWORD PTR [rip+0x2fe4] # 4010 <_GLOBAL_OFFSET_TABLE_+0x10>
102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]

我们来分析这个函数,他先是向栈中压入 0x4008 地址,此地址就是 “.got.plt” 节中的第二项,计算过程是一样的。然后跳到 0x4010 地址处,这个地址是 “.got.plt” 节中第三项。而第三项存储的是 _dl_runtime_resolve() 的地址,也就是去执行 _dl_runtime_resolve() 这个函数了。这个函数会在装载期间完成符号解析和重定位工作,最终会修改 “.got.plt” 节中的函数引用的地址。

好了,到这里我们发现只要我们将 “.got.plt” 中的第 4 项,也就是 0x4018 地址处的值给修改了,那么也就完成了 hook。

三、实践

在修改前,需要注意三个问题:

  • 0x4018 是个相对地址,我们如果直接修改此地址的话,要注意换算成绝对地址
  • 第一步得到的地址,很可能没有写入权限,直接对这个地址赋值会引入段错误
  • 上两步即使成功了,可能 CPU 有指令缓存,我们在内存中的修改不能被 CPU 运行到

1. 换算地址

在进程的内存空间,各种共享库的加载地址是随机的,只有在运行时才能拿到其加载的地址,也就是基地址。我们可以通过解析进程的 /proc/xxx/maps 来拿到进程的内存空间中 mmap 的映射信息,包括各种动态库、可执行文件(如:动态链接器)、栈空间、堆空间等等。

1
2
3
4
5
6
# cat /proc/34982/maps
address perms offset dev inode pathname
7faa5444d000-7faa5444e000 r--p 00000000 fd:00 395457 /data/tmp/liba.so
7faa5444e000-7faa5444f000 r-xp 00001000 fd:00 395457 /data/tmp/liba.so
7faa5444f000-7faa54450000 r--p 00002000 fd:00 395457 /data/tmp/liba.so
..

我们的 liba.so 在 maps 中有多行记录,一般 offset 为 0 的第一行的起始地址 7faa5444d000 在绝大多数情况下就是共享库的基地址。

2. 内存访问权限

maps 返回的信息中,我们也能发现没有写权限。可以使用 mprotect 来完成,注意只能以 “页” 为单位修改。

1
2
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);

3. 指令缓存

处理器可能会对数据或者代码进行缓存。修改内存地址后,我们需要清除处理器的指令缓存,让处理器重新从内存中读取这部分指令。

1
void __builtin___clear_cache (char *begin, char *end);

清除缓存时只能以 “页” 为单位。

4. 验证

于是我们进行修改代码,进行验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <inttypes.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>
#include <errno.h>
#include <string.h>
#include "a.h"

#define PAGE_SHIFT 12
#define PAGE_SIZE (1UL << PAGE_SHIFT)
#define PAGE_MASK (~(PAGE_SIZE-1))

#define PAGE_START(addr) ((addr) & PAGE_MASK)
#define PAGE_END(addr) (PAGE_START(addr) + PAGE_SIZE)

int my_printf(const char *format, ...) {
write(1, "hook printf\n", 12);
return 0;
}

int64_t get_base_addr() {
FILE* fp = fopen("/proc/self/maps", "r");
if (fp == NULL) {
fprintf(stdout, "fopen maps failed, err: %s\n", strerror(errno));
return -1;
}
char line[512] = {0};
int64_t base_addr = 0;
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "liba.so") != NULL) {
if (sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1) {
break;
}
}
}
fclose(fp);
if (base_addr == 0) {
fprintf(stdout, "no found liba.so\n");
return -2;
}
return base_addr;
}

void hook() {
// 获取 liba.so 的基地址
int64_t base_addr = get_base_addr();
// 获取到需要修改的地址
int64_t addr = base_addr + 0x4018;
// 添加写权限
mprotect((void*)PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
// 替换函数地址
*(void**)addr = my_printf;
// 清理处理器缓存
__builtin___clear_cache((void *)PAGE_START(addr), (void *)PAGE_END(addr));
}

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

通过编译后执行:

1
2
3
# gcc main.c liba.so -g -o main
# ./main
hook printf

我们成功的 hook 了 printf 函数。我们并没有修改 liba.so 的代码,也没有重新编译他,我们仅仅修改了 main 程序。

四、理论详解

我们已经实践成功了,那么我们再来剖析一下他的理论知识。总结一下 PLT hook 的流程:

  • 读取进程的 maps 信息,获取到 ELF 文件在进程的 maps 中的内存基地址
  • 验证 ELF 文件的头信息
  • 从 PHT(program header table) 中找到类型为 PT_LOAD 且 offset 为 0 的 segment。计算 ELF 基地址。
  • 从 PHT 中找到类型为 PT_DYNAMIC 的 segment,从中获取到 .dynamic section,从 .dynamic section中获取其他各项 section 对应的内存地址。
  • .dynstr section 中找到需要 hook 的 symbol 对应的 index 值。
  • 遍历所有的 .relxxx section(重定位 section),查找 symbol index 和 symbol type 都匹配的项,对于这项重定位项,执行 hook 操作。
  • hook 的流程如下:
    • 读取 maps,确认当前 hook 地址的内存访问权限
    • 如果权限不是可读可写,则使用 mprotect 修改访问权限为可读可写
    • 将 hook 地址的值替换为新的值
    • 如果之前用 mprotect 修改过内存访问权限,现在还原到之前的权限
    • 清除 hook 地址所在内存页的处理器指令缓存

名词解析:

  1. PHT:program header table,ELF 被加载到内存时,是以 segment 为单位的。一个 segment 包含了一个或多个 section。ELF 通过 PHT 来记录所有 segment 的基本信息。主要包括:segment 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等等
  2. 所有类型为 PT_LOAD 的 segment 都会被动态链接器(linker)映射(mmap)到内存中
  3. .dynamic 节包括了 ELF 中各个节的内存位置等信息。在装载时,总是会有一个类型为 PT_DYNAMIC 的 segment,这个 segment 就包含了 .dynamic 节的内容。
  4. .dynstr 节中保存了所有的字符串常量信息
  5. “.rel.dyn” 是对数据引用的修正,他所修正的位置位于 “.got” 以及数据段。 “.rel.plt” 是对函数引用的修正,他所修正的位置位于 “.got.plt”。