C++函数 - Linux系统调用Hook
参考资料
- Linux系统调用Hook姿势总结 (opens new window)
- LD_PRELOAD用法 (opens new window)
- 动态链接库dlopen的函数的使用 (opens new window)
系统调用Hook简介
系统调用属于一种软中断机制,它有操作系统提供的功能入口(sys_call)以及CPU提供的硬件支持(int 3 trap)共同完成。
Hook技术是一个相对较宽的话题,因为操作系统从ring3到ring0是分层次的结构,在每一个层次上都可以进行相应的Hook,它们使用的技术方法以及取得的效果也是不尽相同的。本文主要关注"系统调用的Hook学习",讲解从ring3到ring0中所涉及到的Hook技术,来实现系统调用的监控功能。
Hook原理
Hook技术无论对安全软件还是恶意软件都是十分关键的一项技术,其本质就是劫持函数调用。但是由于处于Linux用户态,每个进程都有自己独立的进程空间,所以必须先注入到所要Hook的进程空间,修改其内存中的进程代码,替换其过程表的符号地址。
操作系统RING3到RING0的分层结构
Intel的CPU将特权级别分为4个级别:RING0,RING1,RING2,RING3。
Windows只使用RING0和RING3,RING0只给操作系统用,RING3谁都能用。
如果普通应用程序企图执行RING0指令,则Windows会显示“非法指令”错误信息。因为有CPU的特权级别作保护。
ring0是指CPU的运行级别,ring0是最高级别,ring1次之,ring2更次之……
以Linux+x86为例 操作系统(内核)的代码运行在最高运行级别RING0上,可以使用特权指令,控制中断、修改页表、访问设备等。 应用程序的代码运行在最低运行级别上RING3上,不能做受控操作。如果要做,例如访问磁盘,写文件的操作,则必须通过系统调用(函数),执行系统调用的时候,CPU的运行级别会从RING3切换到RING0,并跳转到系统调用对应的内核代码位置执行,这样内核就替我们完成了设备访问的操作,完成之后再从RING0返回RING3。整个过程也称作用户态和内核态的切换。
Ring3中Hook技术
LD_PRELOAD动态连接.so函数劫持
在linux操作系统的动态链接库的世界中,LD_PRELOAD就是这样一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许用户在程序运行前优先加载的动态链接库。Loader在进行动态链接的时候,会将有相同符号名的符号覆盖成LD_PRELOAD指定的so文件中的符号。换句话说,可以用我们自己编写的so库中的函数替换原来库里有的函数,从而达到hook的目的。
LD_PRELOAD是个环境变量,用于动态库的加载,动态库加载的优先级最高。一般情况下,其加载顺序为LD_PRELOAD>LD_LIBRARY_PATH>/etc/ld.so.cache>/lib>/usr/lib。程序中我们经常要调用一些外部库的函数,以malloc为例,如果我们有个自定义的malloc函数,把它编译成动态库后,通过LD_PRELOAD加载,当程序中调用malloc函数时,调用的其实是我们自定义的函数,
Linux用的C语言库使用的是glibc,存在一个libc.so.6的文件。默认情况下,linux所编译的程序中对标准C函数的链接,都是通过动态链接方式来链接libc.so.6函数库的。这也意味着我们在通过注入的.so来实现函数覆盖劫持之后需要从libc.so.6中取得原本的正常函数,让程序能够继续正常执行。
正常程序main.c:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if( strcmp(argv[1], "test") )
{
printf("Compare inconsistent\n");
}
else
{
printf("Compare consistent\n");
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用于劫持函数的.so代码hook.c:
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
#include <stdlib.h>
/*
hook的目标是strcmp,所以typedef了一个STRCMP函数指针
hook的目的是要控制函数行为,从原库libc.so.6中拿到strcmp指针,保存成old_strcmp以备调用
*/
typedef int(*STRCMP)(const char*, const char*);
int strcmp(const char *s1, const char *s2)
{
static void *handle = NULL;
static STRCMP old_strcmp = NULL;
handle = dlopen ("libc.so.6", RTLD_LAZY);
if (!handle) {
fprintf (stderr, "%s ", dlerror());
exit(1);
}
else
{
old_strcmp = (STRCMP)dlsym(handle, "strcmp");
}
printf("oops!!! hack function invoked. s1=<%s> s2=<%s>\n", s1, s2);
dlclose(handle);
return old_strcmp(s1, s2);
}
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
使用过程:
[root@VM-0-10-centos code]# gcc -o test main.c
[root@VM-0-10-centos code]# gcc -fPIC -shared -o hook.so hook.c -ldl
[root@VM-0-10-centos code]# LD_PRELOAD=./hook.so ./test 123
oops!!! hack function invoked. s1=<123> s2=<test>
Compare inconsistent
[root@VM-0-10-centos code]# LD_PRELOAD=./hook.so ./test test
oops!!! hack function invoked. s1=<test> s2=<test>
Compare consistent
2
3
4
5
6
7
8
补充知识:
dlopen
基本定义
功能:打开一个动态链接库
包含头文件:
#include <dlfcn.h>
函数定义:
void * dlopen( const char * pathname, int mode );
函数描述:
在dlopen的()函数以指定模式打开指定的动态连接库文件,并返回一个句柄给调用进程。使用dlclose()来卸载打开的库。
mode:分为这两种
RTLD_LAZY 暂缓决定,等有需要时再解出符号
RTLD_NOW 立即决定,返回前解除所有未决定的符号。
RTLD_LOCAL
RTLD_GLOBAL 允许导出符号
RTLD_GROUP
RTLD_WORLD
返回值:
打开错误返回NULL
成功,返回库引用
编译时候要加入 -ldl (指定dl库)
例如
gcc test.c -o test -ldl
使用 dlopen
dlopen()是一个强大的库函数。该函数将打开一个新库,并把它装入内存。该函数主要用来加载库中的符号,这些符号在编译的时候是不知道的。比如 Apache Web 服务器利用这个函数在运行过程中加载模块,这为它提供了额外的能力。一个配置文件控制了加载模块的过程。这种机制使得在系统中添加或者删除一个模块时,都 不需要重新编译了。
可以在自己的程序中使用 dlopen()。dlopen() 在 dlfcn.h 中定义,并在 dl 库中实现。它需要两个参数:一个文件名和一个标志。文件名可以是我们学习过的库中的 soname。标志指明是否立刻计算库的依赖性。如果设置为 RTLD_NOW 的话,则立刻计算;如果设置的是 RTLD_LAZY,则在需要的时候才计算。另外,可以指定 RTLD_GLOBAL,它使得那些在以后才加载的库可以获得其中的符号。
当库被装入后,可以把 dlopen() 返回的句柄作为给 dlsym() 的第一个参数,以获得符号在库中的地址。使用这个地址,就可以获得库中特定函数的指针,并且调用装载库中的相应函数。
--------------------------------------------------------------------------------------------------------------------------
dlsym
dlsym()的函数原型是
void* dlsym(void* handle,const char* symbol)
该函数在<dlfcn.h>文件中。
handle是由dlopen打开动态链接库后返回的指针,symbol就是要求获取的函数的名称,函数返回值是void*,指向函数的地址,供调用使用
取动态对象地址:
#include <dlfcn.h>
void *dlsym(void *pHandle, char *symbol);
dlsym根据动态链接库操作句柄(pHandle)与符号(symbol),返回符号对应的地址。
使用这个函数不但可以获取函数地址,也可以获取变量地址。比如,假设在so中
定义了一个void mytest()函数,那在使用so时先声明一个函数指针:
void (*pMytest)(),然后使用dlsym函数将函数指针pMytest指向mytest函数,
pMytest = (void (*)())dlsym(pHandle, "mytest");
--------------------------------------------------------------------------------------------------------------------------
dlclose
dlclose()
包含头文件:
#include <dlfcn.h>
函数原型为:
int dlclose (void *handle);
函数描述:
dlclose用于关闭指定句柄的动态链接库,只有当此动态链接库的使用计数为0时,才会真正被系统卸载。
--------------------------------------------------------------------------------------------------------------------------
dlerror
dlerror()
包含头文件:
#include <dlfcn.h>
函数原型:
const char *dlerror(void);
函数描述:
当动态链接库操作函数执行失败时,dlerror可以返回出错信息,返回值为NULL时表示操作函数执行成功。
LINUX创建与使用动态链接库并不是一件难事。
编译函数源程序时选用-shared选项即可创建动态链接库,注意应以.so后缀命名,最好放到公用库目录(如/lib,/usr/lib等)下面,并要写好用户接口文件,以便其它用户共享。
使用动态链接库,源程序中要包含dlfcn.h头文件,写程序时注意dlopen等函数的正确调用,编译时要采用-rdynamic选项与-ldl选项 ,以产生可调用动态链接库的执行代码。
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
63
64
65
66
67
68
69
70
71
72
Ring0中Hook技术
Kernel Inline Hook
传统的kernel inline hook技术就是修改内核函数的opcode,通过写入jmp或push ret等指令跳转到新的内核函数中,从而达到劫持的目的。对于这类劫持攻击,目前常见的做法是"函数返回地址污点检测",通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令来进行防御
备注:OPCode 操作码(Operation Code,OPCode):描述机器语言指令中,指定要执行某种操作的机器码。
在系统调用函数中一定会递归的嵌套有很多的子函数,即它必定要调用它的下层函数。从汇编的角度看,对一个子函数的调用是采用"段内相对短跳转 jmp offset"的方式来实现的,即CPU根据offset来进行一个偏移量的跳转。
如果我们把下层函数在上层函数中的offset替换成我们"Hook函数"的offset,这样上层函数调用下层函数时,就会跳到我们的"Hook函数"中,我们就可以在"Hook函数"中做过滤和劫持内容的工作。
asmlinkage ssize_t sys_read(unsigned int fd, char __user * buf, size_t count)
{
struct file *file;
ssize_t ret = -EBADF;
int fput_needed;
file = fget_light(fd, &fput_needed);
if (file)
{
loff_t pos = file_pos_read(file);
ret = vfs_read(file, buf, count, &pos);
file_pos_write(file, pos);
fput_light(file, fput_needed);
}
return ret;
}
EXPORT_SYMBOL_GPL(sys_read);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在sys_read()中,调用了子函数vfs_read()来完成读取数据的操作,在sys_read()中调用子函数vfs_read()的汇编命令是:
call 0xc106d75c <vfs_read>
等同于:
jmp offset(相对于sys_read()的基址偏移)
所以,目标就是找到call 0xc106d75c <vfs_read>这条汇编,把其中的offset改成我们的Hook函数对应的offset,即可实现劫持目的。
1. 搜索sys_read的opcode
2. 如果发现是call指令,根据call后面的offset计算要跳转的地址是不是我们要hook的函数地址
1) 如果"不是"就重新计算Hook函数的offset,用Hook函数的offset替换原来的offset
2) 如果"已经是"Hook函数的offset,则说明函数已经处于被劫持状态了,我们的Hook引擎应该直接忽略跳过,避免重复劫持
2
3
4
利用0x80中断劫持system_call->sys_call_table进行系统调用Hook
要对系统调用(sys_call_table)进行替换,必须要获取该地址后才可以进行替换。但是Linux 2.6版的内核出于安全的考虑没有将系统调用列表基地址的符号sys_call_table导出,但是我们可以采取一些hacking的方式进行获取。
因为系统调用都是通过0x80中断来进行的,故可以通过查找0x80中断的处理程序来获得sys_call_table的地址。其基本步骤是
1. 获取中断描述符表(IDT)的地址(使用C ASM汇编)
2. 从中查找0x80中断(系统调用中断)的服务例程(8*0x80偏移)
3. 搜索该例程的内存空间,
4. 从其中获取sys_call_table(保存所有系统调用例程的入口地址)的地址
5. 获取到了sys_call_table的基地址之后,我们就可以修改指定offset对应的系统调用了,从而达到劫持系统调用的目的
2
3
4
5
总结
hook函数的实现方法:
- 编写自己的动态库,利用LD_PRELOAD优先加载同名的自定义函数 (自定义strcmp来覆盖C标准库的strcmp)
- 找到程序调用对应的汇编命令,修改对应的offset地址 (call 0xc106d75c <vfs_read>)
- 获取sys_call_table的基地址,修改指定函数offset对应的系统调用了