基于内核 Kprobe 的服务器监控防护方案

Sep 2, 2021    #tech  

在介绍文章内容前,首先必须要提一下 HIDS

HIDS 全称是 Host-based Intrusion Detection System,即基于主机型入侵检测系统,专注于系统内部,监视系统全部或部分地动态的行为以及整个计算机系统的状态

HIDS 的一般架构是这样:

这篇文章主要讨论的内容是 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(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 就是以这种方式收集重要数据的。

相关阅读

An introduction to KProbes

字节开源 HIDS