一、从案例说起
文件:a.h
1 | #ifdef __cplusplus |
文件:a.c
1 | #include <stdio.h> |
文件:main.c
1 | #include "a.h" |
我们通过如下编译,生成二进制执行,会输出 hello world!
字符串。增加 -fno-builtin
是为了防止编译器将 printf 优化成 puts 调用。
1 | gcc a.c -shared -fPIC -fno-builtin -o liba.so |
我们现在要 hook printf,使其完成一些业务逻辑。
二、跟踪分析
在 Linux 下,我们的动态库、二进制文件都是 ELF 格式的。并且一般的动态库都是 PIC 方法编译的,产生的是地址无关代码。有了这个前提,我们来看下我们的动态库 liba.so
是如何引用 printf 的。
我们先来静态分析下 liba.so 这个文件。通过 objdump -D liba.so -M intel > o_d.txt
反汇编代码
1 | 000000000000112a <say_hello>: |
我们的 say_hello 函数中调用了 printf 函数,是通过 call 1030
指令实现的,也就是跳转到 1030 位置执行。我们再来看看此位置
1 | Disassembly of section .plt: |
其中 <printf@plt>
位于 .plt
节,而他的实现中第一行便是跳转到 rip+0x2fe2
处。rip 是指令指针寄存器,用来存储 CPU 即将要执行的指令地址,也就是下一条指令地址。在这里 rip 的值为 1036。相当于跳到 0x1036 + 0x2fe2 = 0x4018
位置处。我们再来跟踪。
1 | Disassembly of section .got.plt: |
地址 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 | Disassembly of section .plt: |
我们来分析这个函数,他先是向栈中压入 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 | # cat /proc/34982/maps |
我们的 liba.so 在 maps 中有多行记录,一般 offset 为 0 的第一行的起始地址 7faa5444d000
在绝大多数情况下就是共享库的基地址。
2. 内存访问权限
maps 返回的信息中,我们也能发现没有写权限。可以使用 mprotect 来完成,注意只能以 “页” 为单位修改。
1 | #include <sys/mman.h> |
3. 指令缓存
处理器可能会对数据或者代码进行缓存。修改内存地址后,我们需要清除处理器的指令缓存,让处理器重新从内存中读取这部分指令。
1 | void __builtin___clear_cache (char *begin, char *end); |
清除缓存时只能以 “页” 为单位。
4. 验证
于是我们进行修改代码,进行验证
1 |
|
通过编译后执行:
1 | # gcc main.c liba.so -g -o main |
我们成功的 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 地址所在内存页的处理器指令缓存
名词解析:
- PHT:program header table,ELF 被加载到内存时,是以 segment 为单位的。一个 segment 包含了一个或多个 section。ELF 通过 PHT 来记录所有 segment 的基本信息。主要包括:segment 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等等
- 所有类型为
PT_LOAD
的 segment 都会被动态链接器(linker)映射(mmap)到内存中.dynamic
节包括了 ELF 中各个节的内存位置等信息。在装载时,总是会有一个类型为PT_DYNAMIC
的 segment,这个 segment 就包含了.dynamic
节的内容。.dynstr
节中保存了所有的字符串常量信息- “.rel.dyn” 是对数据引用的修正,他所修正的位置位于 “.got” 以及数据段。 “.rel.plt” 是对函数引用的修正,他所修正的位置位于 “.got.plt”。