前言
课程来源:极客时间《eBPF 核心技术与实战》
为什么突然开始学习 ebpf 了
因为基于 ebpf 的 rootkit 这类后门目前能很大程度上的 bypass 检测,同时也非常的隐蔽不好清除。在很多 apt 中也有进行使用,比如之前的 A国的那个顶级方程式的 case。
之前一直没开始动是因为 ebpf 需要涉及 Linux 内核相关内容所以迟迟没有开始,但是现在发现有现成的框架 bcc 所以我不需要太关注 Linux 内核中的一些事项。
ebpf 简介
ebpf 是什么?
在我的理解中 ebpf 是 bpf 的 plus ,bpf 是自 Linux 2.1.75 之后引入的技术。简单的说就是在 Linux 内核态中引入一个虚拟机,并且用户态的程序可以将编译好的 bpf 字节码传递给内核中的这个虚拟机来进行解释执行。
ebpf 在 bpf 的基础上做了一些演进,使得不再仅仅局限于 Linux 网络栈层面。
ebpf 可以在不需要重新编译内核的情况下来为内核提供一些拓展的能力,例如一些网络、进程侧日志采集的能力。
在运行过程中借助 LLVM 将 ebpf 程序编译成 BPF 字节码,然后通过 bpf 的系统调用来将字节码传递给内核进行执行 ,在通过一系列的检查和校验之后才会进行执行。
那么现在就有一个问题,由于 bpf 程序是运行在内核中的,那么如何来获取 bpf 采集到的一些信息,以及 bpf 如何将采集到的数据进行存储
bpf 通过引入 map 映射来解决这类问题,bpf 映射将键-值保存在内核中,但是可被 bpf 程序访问,bpf程序可以将收集到的数据存储到 map(映射)中,然后用户态通过 map (映射) 中将数据读取出来
ps:比较好奇这块映射是如何实现的,加到 todolist 中....
运行 ebpf 程序
环境安装
sudo apt-get install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)
这里利用 bcc 框架来实现编写(bcc 是 bpf 编译器的集合)
开发主要有两个part:
- ebpf 程序的开发 (运行在内核中的代码)
- 编写用户态程序 (主要借助 bcc 来将字节码送到内核执行同时绑定对应的事件)
- 执行
新建 hello.c
输出 Hello,World,由于是在内核中进行运行所以结果输出到内核调试文件 /sys/kernel/debug/tracing/trace_pipe
int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
开发用户态程序
#!/usr/bin/env python3
from bcc import BPF
# 加载 bpf 程序
b = BPF(src_file="hello.c")
# 将程序挂载到系统调用中
# do_sys_openat2 是 openat() 的实现,openat() 就是打开文件的系统调用
# 这里的逻辑就是监控打开文件这个系统调用
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 读取内核调试文件
b.trace_print()
运行 python 文件大致结果是这样的
可以看到运行结果中正常输出了 hello,world ,但是有几个问题
- 既然我们是通过 ebpf 监控系统打开文件的操作,那么正常情况输出结果应该有打开的文件名
- 现在读取结果是直接读 /sys/kernel/debug/tracing/trace_pipe 这个内核调试文件的,这就有一个问题,当存在多个 ebpf 程序的时候输出结果都是到这个文件,这样的话就使得输出结果很不清晰,并且这样性能也很差
针对上述两个问题,需要利用在上文提到的映射来结果,通过映射来读取 ebpf 执行的结果并适当进行一些数据处理,因为上述截图中很多输出字段都是我们不需要的
BCC 定义了一系列的库函数和辅助宏定义。这里使用 BPF_PERF_OUTPUT 来定义一个 Perf 事件类型的 BPF 映射,这里先定义一个数据结构
// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>
// 定义数据结构
struct data_t {
u32 pid; // 进程pid
u64 ts; // 时间
char comm[TASK_COMM_LEN]; // 进程名
char fname[NAME_MAX]; // 文件名
};
// 定义性能事件映射
BPF_PERF_OUTPUT(events);
然后将数据填充到我们的数据结构里面
// 定义kprobe处理函数
int hello_world(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
struct data_t data = { };
// 获取PID和时间
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
// 获取进程名
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
}
// 提交性能事件
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
合起来就是下面这个文件,该文件最终在内核中进行运行,将采集到的数据输出到映射中
// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>
// 定义数据结构
struct data_t {
u32 pid; // 进程pid
u64 ts; // 时间
char comm[TASK_COMM_LEN]; // 进程名
char fname[NAME_MAX]; // 文件名
};
// 定义性能事件映射
BPF_PERF_OUTPUT(events);
// 定义kprobe处理函数
int hello_world(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
struct data_t data = { };
// 获取PID和时间
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
// 获取进程名
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0) // 获取进程名并且存储到缓冲区中
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename); // 读取进程打开的文件名
}
// 提交性能事件
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
然后我们需要编写一个用户态的调用程序
from bcc import BPF
# 加载bpf代码
b = BPF(src_file="trace-open.c")
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 格式化输出
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))
# 这里是一个回调函数来进行数据处理
start = 0
def print_event(cpu, data, size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))
# 定义名为 “events” 的 Perf 事件映射,然后循环调用读取
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
大致运行效果如下:
结尾
其实文中还是有很多东西我没去仔细研究,奈何明天要打工今天先这样,明天来进行后续的补充
还有一些其他的思考,上面是针对文件追踪编写了小脚本,在安全的场景下我是不是可以通过这种方式来进行本机流量的窃取,是否可以bypass 检测,并且窃取到的数据如何隐蔽的传给我们的 c2
今天就写到这里吧....