目录
1.前言
Linux hook技术一直是linux技术爱好者们研究的一个热点问题,Intel的CPU将特权级别分为4个级别:RING0,RING1,RING2,RING3, ring0是指CPU的运行级别,ring0是最高级别,ring1次之,ring2更次之,以此类推。
拿Linux+x86来说, 操作系统(内核)的代码运行在最高运行级别ring0上,可以使用特权指令,控制中断、修改页表、访问设备等等。 应用程序的代码运行在最低运行级别上ring3上,不能做受控操作。如果要做,比如要访问磁盘,写文件,那就要通过执行系统调用(函数),执行系统调用的时候,CPU的运行级别会发生从ring3到ring0的切换,并跳转到系统调用对应的内核代码位置执行,这样内核就为你完成了设备访问,完成之后再从ring0返回ring3。这个过程也称作用户态和内核态的切换。
Linux的hook一般发生在ring0和ring3层,其中ring0层通常是hook Linux系统调用表中的系统调用,而ring3层则一般hook的动态链接库中的函数,接下来我们将会分类讨论。
2.ring3 hook
2.1inject
当我们需要hook某个应用程序时,我们需要往目标程序中添加自己的代码,这种添加代码的方式就被称之为inject,inject方式又一般分为两种:
- 动态注入
- 静态注入
所谓静态注入就是在程序还未运行时,去修改其so文件,以达到注入函数的目的,由于没具体研究过,所以此处仅提一下,有兴趣的同学可以看一些这个项目,而动态注入一般是使用ptrace来实现的,一般流程为:
- 1、attach上目标进程。
- 2、查找到目标进程的
dlopen
函数,调用dlopen加载到注入的so到目标进程空间。- 3、查找到目标进程的
dlsym
函数,调用dlsym查找到新so中的目标函数地址,并调用目标函数。- 4、在目标函数中实现自己的代码逻辑
2.2hook
2.2.1 got/plt介绍
在ring3 层的hook,一般是指 PLT/GOT hook,也就是对程序的got表进行替换,接下里将对程序的got表进行一些介绍,以便于读者理解。
PLT (Problogcedure Linkage Table) 和 GOT (Global Offset Table) 是 GCC 中生成shared library的重要元素。至于为何一定要这两个表?
众所周知Linux对外部函数的引用是采用动态链接的,也就是说,在用到某个函数时,才会具体的去定位其在内存中的位置,之所以这么做是为了程序能更快的启动,否则如果程序启动时,就去加载所有引用函数,会让程序启动的很慢。
为了更好的说明,我们首先编写一个简单的程序,这是一个简单的打印程序pid的函数
vim main.c
------------------------
#include <stdio.h>
#include <unistd>
int main(){
printf("the pid is %d\n",getpid());
return 0;
}
--------------------
gcc -o gotTest main.c
readelf -a gotTest
可得到如下结果
我们首先需要关注的是节头中的这几项
其中0x601000是got.plt表在程序中的偏移位置,另外两个表以此类推,我们再来看got.Plt表的具体内容如下
我们可以看到,getpid和printf函数都在这个表中,其中偏移量是他们在表中的地址,信息是他们实际的地址,由于程序未启动,地址还没加载,所以显示的并不是程序的实际地址。
那么这个got表和got.plt表到底是怎么运作的呢?
首先,当一个程序第一次调用一个外部函数时,就会跳转到.plt表(注意,不是.got.plt),而这个表中包含有一些代码,这些代码总共有两个作用:
(1)调用链接器来解析某个外部函数的地址, 并填充到.got.plt中, 然后跳转到该函数。
(2)在.got.plt中查找并跳转到对应外部函数(如果已经填充过)。
相对的,.got.plt也同样具有两个功能:
1)如果在之前查找过该符号,内容为外部函数的具体地址。
2)如果没查找过, 则内容为跳转回.plt的代码。
所以当你首次调用某个外部函数时,其流程为code → .plt → .got.plt → .plt→.got.plt→target function
结合上图可更好的理解整个过程。
接下来要hook函数就很简单了,只需要将运行中的got.plt表中对应的地址覆盖为我们自己的函数地址,当调用时,自然就调用到我们自己的函数了。
2.2.2 got/plt hook 实现
接下来我们来实现一下hook的过程
首先,将测试代码改造一下,改造后测试代码如下:
#include <stdio.h>
#include <unistd.h>
#include <stdbool>
int mygetpid(){
return 12306;
}
int main(){
while(true){
printf("the pid is %d\n",getpid());
sleep(1);
}
return 0;
}
改造后的代码,每隔一段时间就会打印一下pid,然后我们还新增了一个函数,用于到时候替换用,我们再用readelf -a 来查看一下编译成的执行文件的elf情况如下:
首先是.got.plt表
接下来是.symtab,.symtab是c程序的符号表,其中包含有各种程序的符号,其内容如下
我们可以看到,getpid函数和我们自己编写的mygetpid函数在这个表中都可以看到,由于getpid是外部引用函数,其地址是使用时动态加载,所以此时为0,接下来的内容就很明确了,我们只需要把.got.plt表中,位置为0X601018的值,覆写成我们自己的mygetpid函数的地址,就可以hook住getpid函数了。
那么我们应该怎么才能修改程序运行时候的内存地址呢,我们都知道,linux秉承的是万物皆文件的原则,程序在运行时候,其内存会映射为一个/proc/$pid/mem文件,修改这个文件,等于修改程序内存(其实这样说不够严谨,差不多是这个意思)。
于是我们可以编写个程序用来修改程序运行时候的内存,代码如下
vim inject.c
--------------------------------------------
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc,char* argv[]) {
int pid = atoi(argv[1]);
unsigned long offset = 0x601018;
unsigned long myfunctionaddr = 0x4005b6;
char filename[32];
snprintf(filename, sizeof(filename),"/proc/%d/mem",pid);
int fd = open(filename, O_RDWR|O_SYNC);
lseek(fd,offset,SEEK_SET);
write(fd,&myfunctionaddr, sizeof(unsigned long));
return 0;
}
-----------------------------------
gcc -o inect inject.c
现在先启动目标程序
然后启动我们的注入程序
再来看我们程序的输出
已经变成了我们预先设定的12306,那么如果我们还想调用原来的getpid函数该怎么做,其实也很简答,只需在目标程序中定义一个函数指针,然后将原pid函数的地址写入指针就可以了。
2.2.3 PRELOAD HOOK
Linux系统中,ELF格式的导入表只存储了符号(包括导出的全局对象、全局变量以及全局函数)名,因此在进程加载器初始化外部符号时,从模块链表头开始按模块搜索直到遇到该符号名。
这一特性对动态调用函数和静态调用函数的调用都会产生影响,也就是说,如果我们构造一个和目标函数一模一样的函数,并写上自己的代码,让它优先于目标函数加载,那不就可以对函数进行hook了,而Linux提供了一个环境变量LD_PRELOAD,这个环境变量可以让程序在启动时,优先加载我们指定的so,接下来,让我们测试一下,首先编写一个自己的so
vim preloadso.c
-------------------------------------------
#include <stdio.h>
int getpid(void){
printf("i hook the getpid function!\n");
return 12306;
}
--------------------------------------------
gcc -o preloadso.so -shared -fPIC preloadso.c
接下来使用LD_PRELOAD环境变量运行之前编写的gotTest,结果如下
可见我们已经成功的替换了getpid函数,那么如果我们想要调用原始的getpid函数该怎么做呢,我们可以用dlsym函数配合RTLD_NEXT变量获取原始getpid函数的地址,然后剩下的就类似于上面got表hook的操作了。