在介绍文章内容前,首先必须要提一下 HIDS
HIDS 全称是 Host-based Intrusion Detection System,即基于主机型入侵检测系统,专注于系统内部,监视系统全部或部分地动态的行为以及整个计算机系统的状态
HIDS 的一般架构是这样:
- Agent:安装在企业内每台主机,进行系统事件监控,基线采集
- 管理端:管理每台agent的配置下发,状态检测,版本管理
- 规则分析中心:接收各种 agent 上传的数据,进行分类重整化,关联分析
- 展示平台:展示规则分析的结果
这篇文章主要讨论的内容是 Agent 中非常重要的系统事件监控环节,分享一种基于 Kprobe 的事件监控方案。
Kprobe 介绍
Kprobes 是 Linux 的一种特性,它主要被用来调试内核,如探测一些内核函数是否被调用、何时被调用、函数的入参和返回值等,相比于在内核代码中用 printk 来 log 调试信息,Kprobes 的优势在于不用重新编译内核代码,并且可以跟踪内核几乎所有位置的代码,也正是因为这样,它也被用作于监控一些系统事件,就是我今天要分享的内容。
不知道细心的朋友有没有发现,上面讲的是 Kprobes 而不是 Kprobe,网上很多文章都混用这两个词,我觉得他们是有区别的。Kprobes 上面讲了,是 Linux 的一种特性,一种轻量级 Linux 内核调试技术。而 Kprobe 仅是该技术中的一种探测手段(探针类型),除了 Kprobe ,还有 Jprobe 和 Kretprobe。
如何利用 Kprobe 进行事件监控防护
HIDS 常常以 hook 一些 syscall 的方式来捕获异常行为。如 hook execve 来获取异常的执行命令,hook connect 来分析异常的网络行为等。
本文的目的就是通过具体的例子,用 Kprobe 来 hook 一个具体的系统调用,并输出信息以供分析。
具体实现
目前,使用 Kprobe 有两种方式:
- 编写内核模块 LKM,向内核注册探测点,探测函数可根据需要自行定制(本文采用的方式)
- 使用 kprobes on ftrace,这种方式是 Kprobe 和 ftrace 的结合使用(没研究过,感兴趣的朋友可以自行 google)
LKM(Loadable Kernel Module) 即动态可加载内核模块,由 Linux 内核进行加载,可以在不重新编译内核的情况下,动态扩充内核的功能。跟普通用户程序相比,LKM 运行在内核空间,相对更高效也更加隐秘,因此十分适合应用于 HIDS。
由于篇幅原因,不展开讲 LKM,具体可以查看 HOWTO,我们主要讲讲使用 LKM 来实现一个简单的 Kprobe 探测点。
1. 编写 Kprobe 探测点
内核提供了一个 Kprobe 结构体和一些 API 接口函数,而我们主要是实现结构体中的一些函数,然后通过接口将探测点注册到内核中。
1.1 Kprobe 结构体
Kprobe 结构体定义如下:
struct kprobe {
/* 被用于 kprobe 全局 hash ,索引值为被探测点的地址 */
struct hlist_node hlist;
/* list of kprobes for multi-handler support */
struct list_head list;
/* count the number of times this probe was temporarily disarmed */
unsigned long nmissed;
/* 被探测点的地址 */
kprobe_opcode_t *addr;
/* 被探测函数的名字 */
const char *symbol_name;
/* 被探测点在函数内部的偏移,用于探测函数内部的指令 */
unsigned int offset;
/* 在被探测点指令执行之前调用的回调函数 */
kprobe_pre_handler_t pre_handler;
/* 在被探测指令执行之后调用的回调函数 */
kprobe_post_handler_t post_handler;
/* 在执行 pre_handler、post_handler 出现内存异常则会调用该回调函数 */
kprobe_fault_handler_t fault_handler;
/*
* ... called if breakpoint trap occurs in probe handler.
* Return 1 if it handled break, otherwise kernel will see it.
*/
kprobe_break_handler_t break_handler;
/* 保存的被探测点原始指令 */
kprobe_opcode_t opcode;
/* 被复制的被探测点的原始指令 */
struct arch_specific_insn ainsn;
/* 状态标记 */
u32 flags;
};
今天就关注 pre_handler 。
1.2 Kprobe API 函数接口
/* 注册 kprobe 探测点 */
int register_kprobe(struct kprobe *p);
/* 卸载 kprobe 探测点 */
void unregister_kprobe(struct kprobe *p);
/* 注册多个 kprobe 探测点 */
int register_kprobes(struct kprobe **kps, int num);
/* 卸载多个 kprobe 探测点 */
void unregister_kprobes(struct kprobe **kps, int num);
/* 暂停指定定 kprobe 探测点 */
int disable_kprobe(struct kprobe *kp);
/* 恢复指定 kprobe 探测点 */
int enable_kprobe(struct kprobe *kp);
/* 打印指定 kprobe 探测点的名称、地址、偏移 */
void dump_kprobe(struct kprobe *kp);
主要关注 register_kprobe 、unregister_kprobe 。
1.3 具体代码 kprobe.c
下面的代码是我删减后的,只保留了 handler_pre ,内核代码中有较为详细的 Kprobe 例子代码:kprobe_example.c
/* file kprobe.c */
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>
/* For each probe you need to allocate a kprobe structure */
static struct kprobe kp = {
.symbol_name = "sys_kill",
};
/* kprobe pre_handler: called just before the probed instruction is executed */
static int __kprobes handler_pre(struct kprobe *p, struct pt_regs *regs)
{
int pid = (int)regs->di;
int sig = (int)regs->si;
pr_info("<%s> pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx, pid = %d,sig = %dn",
p->symbol_name, p->addr, regs->ip, regs->flags, pid, sig);
/* A dump_stack() here will give a stack backtrace */
return 0;
}
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
ret = register_kprobe(&kp);
if (ret < 0)
{
pr_err("register_kprobe failed, returned %dn", ret);
return ret;
}
pr_info("Planted kprobe at %pn", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp);
pr_info("kprobe at %p unregisteredn", kp.addr);
}
module_init(kprobe_init)
module_exit(kprobe_exit)
MODULE_LICENSE("GPL");
static struct kprobe kp = {
.symbol_name = "sys_kill",
};
这里我们创建了一个 Kprobe 对象,并用 symbol_name 指定了被探测函数的名字,即 sys_kill ,这个系统调用在执行 kill 命令时也会被触发。
static int __kprobes handler_pre(struct kprobe *p, struct pt_regs *regs)
{
int pid = (int)regs->di;
int sig = (int)regs->si;
pr_info("<%s> pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx, pid = %d,sig = %dn",
p->symbol_name, p->addr, regs->ip, regs->flags, pid, sig);
/* A dump_stack() here will give a stack backtrace */
return 0;
}
handler_pre 除了传入了一个 Kprobe 对象外,还传入了 pt_regs。pt_regs 结构体中存储了函数调用中的寄存器的值,所以我们需要明白参数的含义,来帮助我们更好的理解
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long bp;
unsigned long bx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;// 第 4 个参数
unsigned long r9; // 第 6 个参数
unsigned long r8; // 第 5 个参数
unsigned long ax;
unsigned long cx;
unsigned long dx; // 第 3 个参数
unsigned long si; // 第 2 个参数
unsigned long di; // 调用函数的第 1 个参数
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_ax;
/* Return frame for iretq */
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp; // 栈顶指针
unsigned long ss;
/* top of stack page */
};
上面给出的是 x86 架构下 pt_regs 的参数,其他架构下参数会不一样。
我们可以通过搜索内核源码,找到 sys_kill 在头文件中的定义如下:
asmlinkage long sys_kill(int pid, int sig);
asmlinkage long sys_tgkill(int tgid, int pid, int sig);
asmlinkage long sys_tkill(int pid, int sig);
从中可以看到第一个参数是 pid,第二个参数是 sig 因此我们在 handler_pre 中,对 pt_regs 的 di、si 寄存器分别进行读取,并打印出来。
int pid = (int)regs->di;
int sig = (int)regs->si;
pr_info(" pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx, pid = %d,sig = %dn",
p->symbol_name, p->addr, regs->ip, regs->flags, pid, sig);
这就是一个通过 LKM 方式实现的 hook 系统调用的简单代码,module_init 是 LKM 模块被内核加载时,会执行的函数,我们在这里将我们的 Kprobe 探测点进行注册,同理在 module_exit 中,将我们的探测点卸载。
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
// 注册
ret = register_kprobe(&kp);
if (ret < 0) {
pr_err("register_kprobe failed, returned %dn", ret);
return ret;
}
pr_info("Planted kprobe at %pn", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
// 卸载
unregister_kprobe(&kp);
pr_info("kprobe at %p unregisteredn", kp.addr);
}
module_init(kprobe_init)
module_exit(kprobe_exit)
然后,我们可以通过如下 Makefile 对 kprobe.c 进行编译,他会生成一个名为 kprobe.ko 的文件。
obj-m := kprobe.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
clean:
rm *.o *.ko *.mod.c modules.order Module.symvers
接着,我们执行 insmod kprobe.ko ,让内核加载该模块。通过 lsmod 命令可以看到已经被加载了:
[str8@str8 ~]$ insmod kprobe.ko
[str8@str8 ~]$ lsmod | grep kprobe
kprobe 16384 0
让我们来测试一下!
[str8 ~]# ps aux | grep vim
str8 6279 0.0 0.1 13716 8584 pts/6 S+ 18:12 0:00 vim 123.txt
root 6281 0.0 0.0 6140 804 pts/4 S+ 18:12 0:00 grep --colour=auto vim
[str8 ~]# kill -9 6279
[str8 ~]# dmesg | grep 6279
[70618.397258] pre_handler: p->addr = 0x00000000c2b52e21, ip = ffffffffa0ea2fc1, flags = 0x202, pid = 6279, sig = 9
[str8 ~]# cat /proc/kallsyms | grep sys_kill
ffffffffa0ea2fc0 T sys_kill
可以看到,当我们 kill 掉一个进程的时候,我们的行为就被记录下来了,在 dmesg 的输出中可以看到被我们 kill 掉的 pid —— 6279 和 signal 信号值 9。此外,对比内核符号表文件中 sys_kill 函数的内存地址和日志中的输出的 ip 寄存器值,可以确定,我们成功对其进行了 hook 。
cpu 上有一些寄存器,ip(Instruction Pointer) 是一个指针,总是指向内存的某一块区域 cs(code segment),cpu 即从 ip 指向的地址取一条指令进行执行,执行完之后 ip 自增 1 ,加到下一条指令(逻辑意义上的 1,因为有些指令系统是变长指令)
结语
这是一个通过 Kprobe hook sys_kill 系统调用的简单例子,几乎一切 linux 程序的执行均依赖多种不同的 syscall 组合来达成目的。因此对于关键 syscall 的数据采集可以支持安全人员了解和拼接出一台机器上的操作行为。我们可以借用这种方式,将操作信息高效地收集起来,然后通过规则进行威胁分析。
这就是基于内核 Kprobe 的服务器行为监控防护方案。这种方式主要通过内核态驱动来获取信息,十分高效,也因为由内核驱动,使得一些隐藏行为难以绕过,目前字节跳动的 HIDS 就是以这种方式收集重要数据的。