0x00 引言
Hook即是函数调用劫持(用自己写的函数替换目标库中的函数),在Hook成功后就可以在自己的函数去实现监控程序执行、参数检查、过滤、拦截、修改、放行等操作。
目前Linux上实现用户态Hook的主流方法主要有两种: 库打桩、注入与热更新。
0x01 基础知识
1 | 1 RING0/RING3 |
1、RING0/RING3
Intel的CPU将特权级别分为四个级别[1]: RING0,RING1,RING2,RING3。Linux系统包含上述所有级别,Windows系统只使用RING0和RING3。
内核的代码运行在最高运行级别RING0(内核态)上,可以使用特权指令,控制中断、修改页表、访问设备等等。应用程序的代码运行在最低运行级别上RING3(用户态)上,不能做受控操作,如果要访问磁盘,写文件,那必须通过执行系统调用。
2、LD_PRELOAD环境变量
LD_PRELOAD是Linux系统的一个环境变量,它可以影响程序的运行时的链接,它允许你定义在程序运行前优先加载的动态链接库。其优先级甚至高于LD_LIBRARY_PATH环境变量(指定动态库搜索路径)。
3、ELF文件结构
ELF(Executableand Linking Format)即为可执行和链接格式,在Unix系统中,ELF文件包括.o .so以及可执行程序。ELF格式文件的基本结构如下:
ELF Header - ELF头: 存储着一些机器和该ELF文件的基本信息。指出了段头表与节头表在文件中的位置;
ELF Program Header Table - 段头表(程序头): 保存了所有段描述信息;
ELF Section Header Table - 节头表: 保存了所有节描述信息;
可执行程序的内容是由段(Segment)组成的(程序执行必须要包含段),.o文件的内容是由节(Section)组成的。段由一个或多个具有相同属性的节组成,节头表中保存了在链接阶段(从.o文件生成可执行程序)具体需要将节连接到哪个段的信息。可执行文件载入内存时,会分为由段组织的内存块,这些内存块信息都会保存在段头表中。
在Linux中,如果向看ELF文件的详细信息,可以使用readelf工具:
1 | $ readelf -e 'ELF文件路径' |
4、PLT与GOT
PLT(Procedure Linkage Table)即过程链接表在链接阶段被链接到数据段,GOT(Global Offset Table)即全局偏移量表在链接阶段被链接到代码段。前者在ELF中主要包括.plt节、.rela.plt节,后者在ELF中主要包括.got节、.got.plt节,二者共同作用于将动态库函数链接到程序中。
4.1 动态库函数寻址流程
现在思考一个问题: ELF是怎么找到函数地址的呢?
要理解这个问题,首先我们得明确这个需要执行的函数究竟在哪里实现,基本可以分为以下三种情况:
- 函数定义在其它.o中。链接阶段可直接重定位
- 函数定义在动态库内,动态库动态链接。链接阶段无法直接重定位,这种情况我们在热更新中会解释
- 函数定义在动态库内,动态库动态加载。链接阶段无法直接重定位
为了解决上述第三种情况,我们需要使用到PLT与GOT,下图是动态库函数寻址的过程:
1.直接重定位: 链接阶段,链接器直接在其他.o文件找到函数地址并替换原任意地址;
2.链接时重定位: 链接阶段,链接器生成一小段额外的stub代码片段(这段代码能够在运行阶段获取函数真是地址),并将stub函数地址替换原任意地址,并将此地址记录到PLT相关节中;
3.运行时重定位: 运行阶段,将库加载到内存中,并获取到目标函数在库中的位置,并将stub函数实现内的目标函数地址替换为内存中的真实地址,并最终执行目标函数。
4.2 相关节
节名 | 作用 | |
---|---|---|
字符信息 | .dynsym | 外部调用符号的基本信息 |
.dynstr | 外部调用符号的字符名,与.dynsym序号对应,与.rela.plt节中的序号成映射关系 | |
PLT | .rela.plt | PLT新生成桩(stub)函数的偏移地址,其指向位置存在于.got.plt节中 |
.rela.dyn | 已保存的重定位信息 | |
GOT | .rela.got | GOT最终函数地址的偏移地址 |
注意: 外部调用分为动态链接、动态加载
1.动态链接: 在编译时声明动态库的存在,在运行期间链接动态库,意味着动态库在编译时对编译器可见。动态链接的库函数字符名与源代码中不一致(比如print_str函数会变为_Z9print_stri),查询字符可使用 readelf -a ‘ELF路径’ |grep ‘函数名’;
2.动态加载: 在运行期间动态加载库,是由用户自己链接而不是由链接器(动态库都用extern “C”修饰,并在进程中用dlopen动态加载),在编译期间可以不需要知道动态库的存在。动态加载的库函数字符名即为源代码中的函数名
0x02 库打桩
Linux系统下的库打桩(Library Interpositioning)功能允许你拦截对系统标准库中某个目标函数的调用,取而代之执行自己的包装函数。
1 | 1 单进程库打桩 |
1、单进程库打桩
单进程库打桩只替换某进程中调用的系统函数,但是前提是必须获得其源码或.o文件。
execve函数在父进程中fork一个子进程,在子进程中调用exec函数启动新的程序。exec函数族一共有六个,其中execve为内核级系统调用,所有其他函数最终都会调用到它。在这里我们直接拦截execve函数:
1 | /* hook.c */ |
假设有如下调用execve函数执行’ls -ah’的源码文件:
1 | /* main.cpp */ |
在编译时,只需要包含hook.c即可:
1 | # 编译 |
2、全局库打桩
全局库打桩[2]实际上实现方式与单进程库打桩一致,只是其利用了在Linux系统下LD_PRELOAD环境变量能够允许所有进程优先加载指定函数的特性,实现了libc(系统)函数调用劫持。其执行流程如下:
代码无需改动,只是在编译时,修改LD_PRELOAD环境变量即可:
1 | # 编译 |
0x03 注入与热更新
注入与热更新[3]实现Hook的方法,是为了将目标进程外部调用的动态库中的某个函数替换为其他动态库中的某个函数。其Hook思路,可以简单地理解为首先将hook.so注入到目标进程内存空间中,然后使用hook.so中的函数地址热更新替代target.so中的函数地址。
1 | 1 注入 |
1、注入
目标进程Test在最开始时并不能直接运行hook.so中的函数,因为hook.so并不在进程的内存空间,因此我们需要实现的第一步就是将hook.so注入到Test内存空间中。比较简单的实现方法是,假设Test依赖libc库,那么我们可以劫持进程执行系统调用dlopen,并加载我们的hook库。
至于如何使进程执行系统调用dlopen,只需要修改CPU寄存器即可:
rdi / rsi / rdx / r10 / r8 / r9:参数1~6
rax:系统调用号
rip:执行代码地址
2、热更新
在将hook.so注入目标进程Test后,我们需要使用hook.so中的函数替换掉动态库target.so中的某个函数。
为了实现这个目的,我们必须先理解ELF调用外部函数的关系图(注意foo1与foo2调用区别):
注意: 外部调用寻找函数地址也不一定用到PLT与GOT
1.foo1: 不需要PLT与GOT寻址。比如直接在target.so中实现的函数
2.foo2: 需要PLT与GOT寻址。比如在target.so中调用的libc库函数
根据上图,在调用foo1和foo2的真正实现中,其调用过程是:
1 | # foo1 |
在理解了ELF调用外部函数的过程之后,我们针对foo1与foo2两种情况,可采用如下方法对目标函数进行热更新: