Linux hook 机制
6 min read

Linux hook 机制

探索一下 Linux 实现勾子(hook)的方式和原理,为进一步理解 ebpf 的 hookpoint (LSM)底层原理打下基础。
Linux hook 机制
photo by https://www.anquanke.com/post/id/231078

Linux hook 有很多中实现方式,从 ring0 到 ring3 都有对应的实现方式。

ring3 hook

  • LD_PRELOAD劫持.so
  • ptrace API调试技术Hook
  • PLT劫持

编译时 hook

  • 函数封装,例如用 my_malloc 封装 malloc:
#define malloc my_malloc

void *my_malloc(size_t size);

void *my_malloc(size_t size) {
	printf("hook malloc\n");
	/* do some malloc hook task */
	void *ptr = malloc(size);

	return ptr;
}
  • 链接封装,使用 gcc --wrap 参数进行封装:
void *__wrap_malloc(size_t size);

void *__wrap_malloc(size_t size) {
	printf("hook malloc\n");
	/* do some malloc hook task */
	void *ptr = malloc(size);

	return ptr;
}

// gcc -Wl,--wrap,malloc -o a.out main.c

运行时 hook

  • 动态链接,LD_PRELOAD 是一个 Linux 下的动态链接的程序的环境变量,通过 LD_PRELOAD 变量优先于相关配置指定链接到指定的 .so 文件。如果 LD_PRELOAD 环境变量被设置成为共享库路径名的列表,当执行和加载程序的时候,当需要解析未定义的引用时,动态链接器会先搜索 LD_PRELOAD 库,然后才搜索其他的库:
/* random.c */
int random() {
	return 42;
}

// gcc -shared -fPIC random.c -o random.so
// export LD_PRELOAD=./
  • ptrace API 调试,ptrace 是很多 Linux 平台下调试器实现的基础,包括 syscall 跟踪程序 strace,ptrace 可以实现调试程序、跟踪;但是一个进程只能被一个进程跟踪。所以无法在 gdb 或者其他程序调试的时候去 ptrace 一个程序,同样也无法在ptrace 一个进程的时候,再去 gdb 调试:
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>   /* For SYS_write etc */
int main()
{   pid_t child;
    long orig_eax, eax;
    long params[3];
    int status;
    int insyscall = 0;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
    }
    else {
       while(1) {
          wait(&status);
          if(WIFEXITED(status))
              break;
          orig_eax = ptrace(PTRACE_PEEKUSER,
                     child, 4 * ORIG_EAX, NULL);
          if(orig_eax == SYS_write) {
             if(insyscall == 0) {
                /* Syscall entry */
                insyscall = 1;
                params[0] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * EBX,
                                   NULL);
                params[1] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * ECX,
                                   NULL);
                params[2] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * EDX,
                                   NULL);
                printf("Write called with "
                       "%ld, %ld, %ld\n",
                       params[0], params[1],
                       params[2]);
                }
          else { /* Syscall exit */
                eax = ptrace(PTRACE_PEEKUSER,
                             child, 4 * EAX, NULL);
                    printf("Write returned "
                           "with %ld\n", eax);
                    insyscall = 0;
                }
            }
            ptrace(PTRACE_SYSCALL,
                   child, NULL, NULL);
        }
    }
    return 0;
}

/* execute and the output

[edony@edony.local /Users/edony/code/ptrace :81]
>> ./a.out
Write called with 1, 1075154944, 48
a.out        dummy.s      ptrace.txt
Write returned with 48
Write called with 1, 1075154944, 59
libgpm.html  registers.c  syscallparams.c
Write returned with 59
Write called with 1, 1075154944, 30
dummy        ptrace.html  simple.c
Write returned with 30

*/
  • PLT 表重定向
    在 Linux 下,ELF 文件 的 GOT 被拆分成.got.got.plt2个表。其中.got用来保存全局变量引用的地址,.got.plt用来保存函数引用的地址,GOT 表项还保留了3个公共表项,也即got的前3项,分别保存:
got[0]: 本ELF动态段(.dynamic段)的装载地址
got[1]:本ELF的link_map数据结构描述符地址
got[2]:_dl_runtime_resolv

Linux 设计了一段比较精巧的指令来实现延迟重定位,历史的版本是进程运行的时候,如果依赖动态库,那么运行之前,需要把程序依赖的动态库里面的每个变量和函数都初始化 GOT 表,这样的后果就是如果依赖比较多,加载缓慢; 后来通过 PLT 设计了延迟加载的功能, 主要思想是第一次运行的时候,通过一段跳转指令, 转去动态链接器中的 _dl_runtime_resolve 函数查找,查找后写入 GOT,第二次的时候便可以直接访问 GOT,直接地址访问。

全局符号表(GOT表)hook实际是通过解析SO文件,将待hook函数在got表的地址替换为自己函数的入口地址,这样目标进程每次调用待hook函数时,实际上是执行了我们自己的函数。导入表的hook有两种方法:
1. 方法一:
通过解析elf格式,分析Section header table找出静态的.got表的位置,并在内存中找到相应的.got表位置,这个时候内存中.got表保存着导入函数的地址,读取目标函数地址,与.got表每一项函数入口地址进行匹配,找到的话就直接替换新的函数地址,这样就完成了一次导入表的Hook操作了。
2. 方法二:
通过分析pr header table查找got表。导入表对应在动(DT_PLTGOT)指向处,但是每项的信息是和GOT表中的表项对应的,因此,在解析动态链接段时,需要解析DT_JMPREL、DT_SYMTAB,前者指向了每一个导入表表项的偏移地址和相关信息,包括在GOT表中偏移,后者为GOT表。

ring0 hook

  • 针对系统调用的hook
  • 利用sys函数的嵌套实现hook调用的子函数
  • 修改系统调用的前几个字节为jmp之类的指令(内联
💡
下面这一部分内容主要引用自:往linux内核函数挂钩子

在 x86 架构下 Linux 系统每个函数编译后地址的前5个字节都是callq function+0x5(及是默认指向下一条指令,注意图中的地址是一个相对地址概念,实际地址跟你运行的进程有关),如下图的反汇编代码所示可以看出函数的第一条指令就是callq

函数反汇编示例

勾子函数的原理就是利用了这个特性(函数编译之后的前5个自己是callq指令),如下图所示实现勾子函数调用的逻辑:

orig_ptr指向linux内核需要hook的函数,当内核调用 orig_ptr 指向函数时候,首先会执行第一条指令,在我们的函数中修改 callq orig_ptr +0x5jmp Hook_ptr-5,在我们的函数中执行一系列操作后,在通过 return ptr_tmp 调用中间辅助函数,将 ptr_tmp 函数的前5字节xxx修改成 jmp orig_ptr+0x5,这里必须跳过 orig_ptr 的前5字节,因为这5个字节函数已经被我们修改,不然就进入死循环。中间辅助函数存在的意义,如果在 hook_ptr 中直接返回调用 orig_ptr 函数,那么没有绕过前5个字节就会进入死循环。在 hook_ptr 函数末尾不能添加 jmp 跳转指令,因为你不知道那些字节是保留,以及堆栈平衡情况。所以需要添加中间辅助函数。
Linux 勾子函数实现逻辑

References

  1. Playing with ptrace, Part I | Linux Journal
  2. 往linux内核函数挂钩子

Public discussion

足迹