目录
1. 系统调用Hook简介 2. Ring3中Hook技术 3. Ring0中Hook技术 4. 后记
1. 系统调用Hook简介
系统调用属于一种软中断机制(内中断陷阱),它有操作系统提供的功能入口(sys_call)以及CPU提供的硬件支持(int 3 trap)共同完成。
我们必须要明白,Hook技术是一个相对较宽的话题,因为操作系统从ring3到ring0是分层次的结构,在每一个层次上都可以进行相应的Hook,它们使用的技术方法以及取得的效果也是不尽相同的。本文的主题是"系统调用的Hook学习","系统调用的Hook"是我们的目的,而要实现这个目的可以有很多方法,本文试图尽量覆盖从ring3到ring0中所涉及到的Hook技术,来实现系统调用的监控功能。
2. Ring3中Hook技术
0x1: LD_PRELOAD动态连接.so函数劫持
在linux操作系统的动态链接库的世界中,LD_PRELOAD就是这样一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。loader在进行动态链接的时候,会将有相同符号名的符号覆盖成LD_PRELOAD指定的so文件中的符号。换句话说,可以用我们自己的so库中的函数替换原来库里有的函数,从而达到hook的目的。这和:
1. Windows下通过修改import table来hook API
2. PHP中修改functions_table来hook function
从原理上来讲很类似。
我们知道,Linux的用C库的都是glibc,有一个叫libc.so.6的文件,这是几乎所有Linux下命令的动态链接中,其中有标准C的各种函数,默认情况下,linux所编译的程序中对标准C函数的链接,都是通过动态链接方式来链接libc.so.6这个函数库的。这也意味着我们在通过我们注入的.so来实现函数覆盖劫持之后需要从libc.so.6中取得原本的正常函数,让程序继续正常执行
正常程序main.c:
#include#include <string.h> int main(int argc, char *argv[]) { if( strcmp(argv[1], "test") ) { printf("Incorrect password\n"); } else { printf("Correct password\n"); } return 0; }
用于劫持函数的.so代码hook.c
#include
#include <string.h>
#include
/*
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;
if( !handle )
{
handle = dlopen("libc.so.6", RTLD_LAZY);
old_strcmp = (STRCMP)dlsym(handle, "strcmp");
}
printf("oops!!! hack function invoked. s1=<%s> s2=<%s>\n", s1, s2);
return old_strcmp(s1, s2);
}
编译:
gcc -o test main.c gcc -fPIC -shared -o hook.so hook.c -ldl
运行:
LD_PRELOAD=./hook.so ./test 123
0x2: ...
3. Ring0中Hook技术
0x1: Kernel Inline Hook
传统的kernel inline hook技术就是修改内核函数的opcode,通过写入jmp或push ret等指令跳转到新的内核函数中,从何达到劫持的目的。对于这类劫持攻击,目前常见的做法是fireeye的"函数返回地址污点检测",通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令来进行防御
我们知道实现一个系统调用的函数中一定会递归的嵌套有很多的子函数,即它必定要调用它的下层函数。
而从汇编的角度来说,对一个子函数的调用是采用"段内相对短跳转 jmp offset"来实现的,即CPU根据offset来进行一个偏移量的跳转。
如果我们把下层函数在上层函数中的offset替换成我们"Hook函数"的offset,这样上层函数调用下层函数时,就会跳到我们的"Hook函数"中,我们就可以在"Hook函数"中做过滤和劫持内容的工作
以sys_read作为例子
\linux-2.6.32.63\fs\read_write.c
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);
在sys_read()中,调用了子函数vfs_read()来完成读取数据的操作,在sys_read()中调用子函数vfs_read()的汇编命令是:
call 0xc106d75c
等同于:
jmp offset(相对于sys_read()的基址偏移)
所以,我们的思路很明确,找到call 0xc106d75c
1. 搜索sys_read的opcode
2. 如果发现是call指令,根据call后面的offset计算要跳转的地址是不是我们要hook的函数地址
1) 如果"不是"就重新计算Hook函数的offset,用Hook函数的offset替换原来的offset
2) 如果"已经是"Hook函数的offset,则说明函数已经处于被劫持状态了,我们的Hook引擎应该直接忽略跳过,避免重复劫持
poc:
/* 参数: 1. handler是上层函数的地址,这里就是sys_read的地址 2. old_func是要替换的函数地址,这里就是vfs_read 3. new_func是新函数的地址,这里就是new_vfs_read的地址 */ unsigned int patch_kernel_func(unsigned int handler, unsigned int old_func, unsigned int new_func) { unsigned char *p = (unsigned char *)handler; unsigned char buf[4] = "\x00\x00\x00\x00"; unsigned int offset = 0; unsigned int orig = 0; int i = 0; DbgPrint("\n*** hook engine: start patch func at: 0x%08x\n", old_func); while (1) { if (i > 512) return 0; if (p[0] == 0xe8) { DbgPrint("*** hook engine: found opcode 0x%02x\n", p[0]); DbgPrint("*** hook engine: call addr: 0x%08x\n", (unsigned int)p); buf[0] = p[1]; buf[1] = p[2]; buf[2] = p[3]; buf[3] = p[4]; DbgPrint("*** hook engine: 0x%02x 0x%02x 0x%02x 0x%02x\n", p[1], p[2], p[3], p[4]); offset = *(unsigned int *)buf; DbgPrint("*** hook engine: offset: 0x%08x\n", offset); orig = offset + (unsigned int)p + 5; DbgPrint("*** hook engine: original func: 0x%08x\n", orig); if (orig == old_func) { DbgPrint("*** hook engine: found old func at" " 0x%08x\n", old_func); DbgPrint("%d\n", i); break; } } p++; i++; } offset = new_func - (unsigned int)p - 5; DbgPrint("*** hook engine: new func offset: 0x%08x\n", offset); p[1] = (offset & 0x000000ff); p[2] = (offset & 0x0000ff00) >> 8; p[3] = (offset & 0x00ff0000) >> 16; p[4] = (offset & 0xff000000) >> 24; DbgPrint("*** hook engine: pachted new func offset.\n"); return orig; }
0x2: 利用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(保存所有系统调用例程的入口地址)的地址
编程示例
find_sys_call_table.c
#include#include // 中断描述符表寄存器结构 struct { unsigned short limit; unsigned int base; } __attribute__((packed)) idtr; // 中断描述符表结构 struct { unsigned short off1; unsigned short sel; unsigned char none, flags; unsigned short off2; } __attribute__((packed)) idt; // 查找sys_call_table的地址 void disp_sys_call_table(void) { unsigned int sys_call_off; unsigned int sys_call_table; char* p; int i; // 获取中断描述符表寄存器的地址 asm("sidt %0":"=m"(idtr)); printk("addr of idtr: %x\n", &idtr); // 获取0x80中断处理程序的地址 memcpy(&idt, idtr.base+8*0x80, sizeof(idt)); sys_call_off=((idt.off2<<16)|idt.off1); printk("addr of idt 0x80: %x\n", sys_call_off); // 从0x80中断服务例程中搜索sys_call_table的地址 p=sys_call_off; for (i=0; i<100; i++) { if (p=='\xff' && p[i+1]=='\x14' && p[i+2]=='\x85') { sys_call_table=*(unsigned int*)(p+i+3); printk("addr of sys_call_table: %x\n", sys_call_table); return ; } } } // 模块载入时被调用 static int __init init_get_sys_call_table(void) { disp_sys_call_table(); return 0; } module_init(init_get_sys_call_table); // 模块卸载时被调用 static void __exit exit_get_sys_call_table(void) { } module_exit(exit_get_sys_call_table); // 模块信息 MODULE_LICENSE("GPL2.0"); MODULE_AUTHOR("LittleHann");
Makefile
obj-m := find_sys_call_table.o
编译
make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
测试效果
dmesg| tail
获取到了sys_call_table的基地址之后,我们就可以修改指定offset对应的系统调用了,从而达到劫持系统调用的目的
0x3: 获取sys_call_table的其他方法
1. 常用方法
模拟出一个call *sys_call_table(,%eax,4),然后看其机器码,然后在system_call的附近基于这个特征进行寻找
#includevoid fun1() { printf("fun1/n"); } void fun2() { printf("fun2/n"); } unsigned int sys_call_table[2] = {fun1, fun2}; int main(int argc, char **argv) { asm("call *sys_call_table(%eax,4"); } 编译 gcc test.c -o test objdump进行dump objdump -D ./test | grep sys_call_table
2. 通过/boot/System.map-2.6.32-358.el6.i686文件查找
cd /boot grep sys_call_table System.map-2.6.32-358.el6.i686
0x4: 利用Linux内核机制kprobe机制进行系统调用Hook
kprobe简介
kprobe是一个动态地收集调试和性能信息的工具,它从Dprobe项目派生而来,它几乎可以跟踪任何函数或被执行的指令以及一些异步事件。它的基本工作机制是:
1. 用户指定一个探测点,并把一个用户定义的处理函数关联到该探测点 2. 在注册探测点的时候,对被探测函数的指令码进行替换,替换为int 3的指令码 3. 在执行int 3的异常执行中,通过通知链的方式调用kprobe的异常处理函数 4. 在kprobe的异常出来函数中,判断是否存在pre_handler钩子,存在则执行 5. 执行完后,准备进入单步调试,通过设置EFLAGS中的TF标志位,并且把异常返回的地址修改为保存的原指令码 6. 代码返回,执行原有指令,执行结束后触发单步异常 7. 在单步异常的处理中,清除单步标志,执行post_handler流程,并最终返回
从原理上来说,kprobe的这种机制属于系统提供的"回调订阅",和netfilter是类似的,linux内核通过在某些代码执行流程中给出回调函数接口供程序员订阅,内核开发人员可以在这些回调点上注册(订阅)自定义的处理函数,同时还可以获取到相应的状态信息,方便进行过滤、分析 kprobe实现了三种类型的探测点:
1. kprobes kprobes是可以被插入到内核的任何指令位置的探测点,kprobe允许在同一地址注册多个kprobes,但是不能同时在该地址上有多个jprobes 2. jprobes jprobes则只能被插入到一个内核函数的入口 3. kretprobes(也叫返回探测点) 而kretprobes则是在指定的内核函数返回时才被执行
在本文中,我们可以使用kprobe的程序实现作一个内核模块,模块的初始化函数来负责安装探测点,退出函数卸载那些被安装的探测点。kprobe提供了接口函数(APIs)来安装或卸载探测点。目前kprobe支持如下架构:i386、x86_64、ppc64、ia64(不支持对slot1指令的探测)、sparc64 (返回探测还没有实现)
kprobe实现原理
1. kprobes
/* kprobes执行流程 */ 1. 当安装一个kprobes探测点时,kprobe首先备份被探测的指令 2. 使用断点指令(int 3指令)来取代被探测指令的头一个或几个字节(这点和OD很像) 3. CPU执行到探测点时,将因运行断点指令而执行trap操作,那将导致保存CPU的寄存器,调用相应的trap处理函数 4. trap处理函数将调用相应的notifier_call_chain(内核中一种异步工作机制)中注册的所有notifier函数 5. kprobe正是通过向trap对应的notifier_call_chain注册关联到探测点的处理函数来实现探测处理的 6. 当kprobe注册的notifier被执行时 6.1 它首先执行关联到探测点的pre_handler函数,并把相应的kprobe struct和保存的寄存器作为该函数的参数 6.2 然后,kprobe单步执行被探测指令的备份(原始函数) 6.3 最后,kprobe执行post_handler 7. 等所有这些运行完毕后,紧跟在被探测指令后的指令流将被正常执行
#include linux/kprobes.h
int register_kprobe(struct kprobe *kp);
int pre_handler(struct kprobe *p, struct pt_regs *regs);
void post_handler(struct kprobe *p, struct pt_regs *regs,
unsigned long flags);
fault_handler()
整个顺序为:
pre_handler->被Hook原函数->post_handler
2. jprobe
/* jprobe执行流程 */ 1. jprobe通过注册kprobes在被探测函数入口的来实现,它能无缝地访问被探测函数的参数 2. jprobe处理函数应当和被探测函数有同样的原型,而且该处理函数在函数末必须调用kprobe提供的函数jprobe_return() 3. 当执行到该探测点时,kprobe备份CPU寄存器和栈的一些部分,然后修改指令寄存器指向jprobe处理函数 4. 当执行该jprobe处理函数时,寄存器和栈内容与执行真正的被探测函数一模一样,因此它不需要任何特别的处理就能访问函数参数, 在该处理函数执行到最后 时,它调用jprobe_return(),那导致寄存器和栈恢复到执行探测点时的状态,因此被探测函数能被正常运行 5. 需要注意,被探测函数的参数可能通过栈传递,也可能通过寄存器传递,但是jprobe对于两种情况都能工作,因为它既备份了栈,又备份了寄存器,当然,前提 是jprobe处理函数原型必须与被探测函数完全一样
#include linux/kprobes.h
int register_jprobe(struct jprobe *jp);
3. kretprobe
/*
kretprobe执行流程
*/
1. kretprobe也使用了kprobes来实现2
2. 当用户调用register_kretprobe()时,kprobe在被探测函数的入口建立了一个探测点
3. 当执行到探测点时,kprobe保存了被探测函数的返回地址并取代返回地址为一个trampoline的地址,kprobe在初始化时定义了该trampoline并且为该
trampoline注册了一个kprobe
4. 当被探测函数执行它的返回指令时,控制传递到该trampoline,因此kprobe已经注册的对应于trampoline的处理函数将被执行,而该处理函数会调用用户
关联到该kretprobe上的处理函数
5. 处理完毕后,设置指令寄存器指向已经备份的函数返回地址,因而原来的函数返回被正常执行。
6. 被探测函数的返回地址保存在类型为kretprobe_instance的变量中,结构kretprobe的maxactive字段指定了被探测函数可以被同时探测的实例数
7. 函数register_kretprobe()将预分配指定数量的kretprobe_instance:
7.1 如果被探测函数是非递归的并且调用时已经保持了自旋锁(spinlock),那么maxactive为1就足够了
7.2 如果被探测函数是非递归的且运行时是抢占失效的,那么maxactive为NR_CPUS就可以了
7.3 如果maxactive被设置为小于等于0, 它被设置到缺省值(如果抢占使能, 即配置了 CONFIG_PREEMPT,缺省值为10和2*NR_CPUS中的最大值,否则
缺省值为NR_CPUS)
7.4 如果maxactive被设置的太小了,一些探测点的执行可能被丢失,但是不影响系统的正常运行,在结构kretprobe中nmissed字段将记录被丢失的探测
点执行数,它在返回探测点被注册时设置为0,每次当执行探测函数而没有kretprobe_instance可用时,它就加1
#include linux/kprobes.h
int register_kretprobe(struct kretprobe *rp); int kretprobe_handler(struct kretprobe_instance *ri, struct pt_regs *regs);
对应于每一个注册函数,有相应的卸载函数
void unregister_kprobe(struct kprobe *kp); void unregister_jprobe(struct jprobe *jp); void unregister_kretprobe(struct kretprobe *rp);
了解了kprobe的基本原理之后,我们要回到我们本文的主题,系统调用的Hook上来,由于kprobe是linux提供的稳定的回调注册机制,linux天生就稳定地支持在我们指定的某个函数的执行流上进行注册回调,我们很方便地使用它来进行系统调用(例如sys_execv()、网络连接等)的执行Hook,从而劫持linux系统的系统调用流程,为下一步的恶意入侵行为分析作准备
kprobe编程示例
do_fork.c
/*
* * You will see the trace data in /var/log/messages and on the console
* * whenever do_fork() is invoked to create a new process.
* */
#include
#include
#include
//定义要Hook的函数,本例中do_fork
static struct kprobe kp =
{
.symbol_name = "do_fork",
};
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
struct thread_info *thread = current_thread_info();
printk(KERN_INFO "pre-handler thread info: flags = %x, status = %d, cpu = %d, task->pid = %d\n",
thread->flags, thread->status, thread->cpu, thread->task->pid);
return 0;
}
static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags)
{
struct thread_info *thread = current_thread_info();
printk(KERN_INFO "post-handler thread info: flags = %x, status = %d, cpu = %d, task->pid = %d\n",
thread->flags, thread->status, thread->cpu, thread->task->pid);
}
static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
printk(KERN_INFO "fault_handler: p->addr = 0x%p, trap #%dn",
p->addr, trapnr);
return 0;
}
/*
内核模块加载初始化,这个过程和windows下的内核驱动注册分发例程很类似
*/
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
kp.fault_handler = handler_fault;
ret = register_kprobe(&kp);
if (ret < 0)
{
printk(KERN_INFO "register_kprobe failed, returned %d\n", ret);
return ret;
}
printk(KERN_INFO "Planted kprobe at %p\n", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp);
printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr);
}
module_init(kprobe_init)
module_exit(kprobe_exit)
MODULE_LICENSE("GPL");
Makefile
obj-m := do_fork.o
编译:
make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
加载内核模块:
insmod do_fork.ko
测试效果:
dmesg| tail
cat /proc/kallsyms | grep do_fork
do_fork的地址与kprobe注册的地址一致,可见,在kprobe调试模块在内核停留期间,我们编写的内核监控模块劫持并记录了系统fork出了新的进程信息
4. 后记
Hook技术是进行主动防御、动态入侵检测的关键技术,从技术上来说,目前的很多Hook技术都属于"猥琐流",即:
1. 通过"劫持"在关键流程上的某些函数的执行地址,在Hook函数执行完之后,再跳回原始的函数继续执行(做好现场保护) 2. 或者通过dll、进程、线程注入比原始程序提前获得CPU执行权限
但是随着windows的PatchGuard的出现,这些出于"安全性"的内核patct将被一视同仁地看作内核完整性的威胁者
更加优美、稳定的方法应该是:
1. 注册标准的回调方法,包括: 1) 进程 2) 线程 3) 模块的创建 4) 卸载回调函数 5) 文件/网络等各种过滤驱动 2. 内核提供的标准处理流程Hook点 1) kprobe机制 2. 网络协议栈提供的标准Hook点 1) netfilter的链式处理流程
Copyright (c) 2014 LittleHann All rights reserved