使用eBPF实现linux下无需驱动的文件保护

什么是eBPF/BPF

BPF 是 Linux 内核中一个非常灵活与高效的类虚拟机(virtual machine-like)组件, 能够在许多内核 hook 点安全地执行字节码(bytecode )。很多 内核子系统都已经使用了 BPF,例如常见的网络(networking)、跟踪( tracing)与安全(security ,例如沙盒)。

BPF 其实早在 1992 年就出现了,但本文介绍的是扩展的 BPF(extended Berkeley Packet Filter,eBPF)。eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为 “经典” BPF(classic BPF, cBPF),cBPF 现在基本已经过时了。很多人知道 cBPF 是因为它是 tcpdump 的包过滤语言。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码 透明地转换成 eBPF 再执行。如无特殊说明,本文中所说的 BPF 都是泛指 BPF 技术。

大白话说:eBPF是内核的一种技术,可以把特定代码编译成在eBPF虚拟机执行并且做一些对内核功能的修改,监听,捕获,分析
详细介绍请看以下几个链接,对于eBPF有详细介绍,非常适合入门学习
对eBPF常见的map,kretprobe,kprobe,tracepoint,fentry,tc,xdp不懂的都可以去下面几个链接/官方示例中找到答案

什么是Cilium/ebpf

cilium/ebpf库是 Cilium 项目的一个子项目。仅使用 Go 语言编写的库,提供了加载、编译和调试 eBPF 程序的功能。它具有最小的外部依赖性,适合在长期运行的进程中使用。库主要有由 Cloudflare 和 Cilium 两家公司维护,由于 Cilium 产品的火爆程度,该库的活跃度在社区层面还是会持续演进和发展。

cilium/ebpf 已经满足生产可用,但 API 现在显然是不稳定的,编写的程序升级时可能需要进行部分调整。cilium/ebpf 使用样例可以参考这里。该库提供的 cmd/bpf2go 工具允许在 Go 代码中编译和嵌入 eBPF 程序。

大白话说:Cilium/ebpf 是一个 go 的第三方库 用于加载eBPF代码进内核和自动编译eBPF模块,使用 go generate 自动生成eBPF文件并且自动绑定进go二进制文件,非常方便

注意: 一个完整的eBPF包含两部分(内核层和应用层),eBPF内核层代码和eBPF加载程序,此时go写的程序就是用来帮助把eBPF代码加载进内核的,如果编写的是xdp或者tc类型的eBPF程序,则不需要使用go加载,用linux自带的ip link命令即可加载模块
但一般工程实践都是两部分,因为应用层还兼顾着分析内核传回来的数据

和 Cilium/ebpf 相同类型的库还有 BCC 以及 libbpf

  • BCC是python编写的,不建议在工程上使用,因为他还用一些魔法处理了eBPF的c代码,无法直接使用clang编译,使用Cilium/ebpf可以把内核和应用分立,还可以用vscode进行智能代码提示,很方便
  • libbpf 是用c编写的,对于分析数据/处理数据还是有一定的短板,所以一般建议使用 Cilium/ebpf 来写 eBPF 类型的程序

eBPF的优势和劣势

  • 优势:
    • 方便随时更新维护,对内核进行无感热插拔,实际上eBPF程序就是c代码直接编译出汇编,所以可以做到类似于shellcode远程更新与加载
    • 可以做到 CO-RE 即一次编译 到处运行, 因为eBPF是基于字节码(汇编)和虚拟机的,可以兼容各种内核,不过实际上使用还是稍微有点麻烦
    • 非常适合进行流量侧的分析与调整,Cilium就是一个优秀的例子
    • 加载前自动检查,不会导致内核崩溃或异常,是优势也是劣势,牺牲了一部分骚操作和灵活性
    • 非常强大,对内核层/应用层的任意调用进行分析/patch
  • 劣势:
    • 只能使用eBPF提供的API,无法直接调用内核函数/syscall
    • 除了xdp和tc类型的程序,普通程序无法直接持久化,依赖用户层将eBPF程序加载进内核,程序退出后eBPF程序也跟随退出
    • 限制比较多,天生适合做防守方,对于内核流程无法直接进行控制,如果把eBPF用于恶意程序,还是比较麻烦的

用eBPF实现linux下文件保护

我之前的文章已经说过文件保护的实现流程,这里就不再赘述了,详情可以点我 主要讲一下如何实现eBPF下的文件保护

bpf-helpers.h 里实现了所有eBPF程序可用的api,其中有一个函数bpf_override_return可以直接覆盖返回值
原文介绍如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
long bpf_override_return(struct pt_regs *regs, u64 rc)

Description
Used for error injection, this helper uses kprobes
to override the return value of the probed
function, and to set it to rc. The first argument
is the context regs on which the kprobe works.

This helper works by setting the PC (program
counter) to an override function which is run in
place of the original probed function. This means
the probed function is not run at all. The
replacement function just returns with the required
value.

This helper has security implications, and thus is
subject to restrictions. It is only available if
the kernel was compiled with the
CONFIG_BPF_KPROBE_OVERRIDE configuration option,
and in this case it only works on functions tagged
with ALLOW_ERROR_INJECTION in the kernel code.

Also, the helper is only available for the
architectures having the
CONFIG_FUNCTION_ERROR_INJECTION option. As of this
writing, x86 architecture is the only one to
support this feature.

Return 0

可以看到,虽然这个函数可以覆盖kprobes函数的返回值,但是他是基于linux下的error injection实现的,即SYSCALL_DEFINEx宏实现的函数

并且内核编译选项需要开启CONFIG_BPF_KPROBE_OVERRIDE且目标函数必须有ALLOW_ERROR_INJECTION声明,相关源码链接可以点我,系统调用(syscall)都实现了这个声明,并且这个函数截止到目前,只能在x86架构的CPU上使用,ARM等CPU并不支持这个函数

我们都知道do_sys_openat2 函数处理了所有open调用,但我们无法直接使用bpf_override_return控制do_sys_openat2的返回值,因为它并没有实现ALLOW_ERROR_INJECTION,所以这一条路走不通,那怎么办呢?
既然do_sys_openat2走不通,那我们只能拿sys_openat这个系统调用开刀了,函数完整定义请点我,这个函数的执行流程大概如下:

1
2
call sys_openat()
return do_sys_openat2()

可以看到,如果我们处理了sys_openatreturn,我们就可以控制内核返回值,此时kretprobe就可以粉墨登场了,用kretprobe可以在return前插桩,做一些我们想要的操作.
有人可能会问了,为什么不直接用kprobe去处理sys_openat呢,因为sys_openat无法直接拿到文件名,需要在do_sys_openat2才能拿到完整文件名,所以流程就变成了

1
2
3
4
call sys_openat() // 不处理kprobe
ret = hook(do_sys_openat2()) //先处理do_sys_openat2,拿到文件名等我们需要的参数
hook(ret) // 开始处理返回值
return ret // 完成文件保护

开始编码

eBPF代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// +build ignore
#define __TARGET_ARCH_x86 // 定义目标架构,必须
#include "common.h"

#include "bpf_core_read.h"
#include "bpf_helpers.h"
#include "bpf_tracing.h"

char __license[] SEC("license") = "Dual MIT/GPL";
#define TASK_COMM_LEN 80
#define PATH_MAX 256
#define EFAULT 14

struct event
{
u32 pid; // pid
u8 comm[TASK_COMM_LEN]; //进程名
u8 filename[PATH_MAX]; //文件名
};

struct
{
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps"); //定义 BPF map 用于和用户层交互

struct
{
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, sizeof(u64));
__uint(value_size, sizeof(u8) * PATH_MAX);
__uint(max_entries, 4096);
} check_file SEC(".maps"); // 检测文件列表

const struct event *unused __attribute__((unused)); //必须,否则编译器会优化掉event导致BPF验证失败

#define CHECK_STR(src, dst, code) \
const char __chkstr[] __attribute__((unused)) = dst; \
u8 __checkflag = 1; \
for (int i = 0; i < sizeof(dst); i++) \
{ \
if (dst[i] != src[i]) \
{ \
__checkflag = 0; \
break; \
} \
} \
if (__checkflag) \
{ \
code \
}

SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(sys_openat, int dfd, const char *filename) // BPF_KPROBE 宏依赖__TARGET_ARCH_xxx 定义目标机器
{
u64 tgid = bpf_get_current_pid_tgid();
u32 pid = tgid >> 32; // 获取进程id
struct event *fileinfo = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!fileinfo)
return 0;

fileinfo->pid = pid;
bpf_probe_read_user_str(fileinfo->filename, PATH_MAX, filename); //获取文件名
bpf_get_current_comm(&fileinfo->comm, TASK_COMM_LEN); // 获取读取进程名
CHECK_STR(fileinfo->filename, "test.txt", {
bpf_ringbuf_submit(fileinfo, 0); // 把event提交给用户层读取
const char msg[] = "dfd:%d file:%x str:%s ";
bpf_trace_printk(msg, sizeof(msg), dfd, filename, fileinfo->filename);
char save[PATH_MAX] = {0};
bpf_probe_read_user_str(save, PATH_MAX, filename);
bpf_map_update_elem(&check_file, &tgid, &save, BPF_ANY); //更新内部map供下面使用,注意,不能直接使用ringbuf申请的空间,需要自己开辟栈空间存储
return 0;
});
bpf_ringbuf_discard(fileinfo, 0);

return 0;
}

SEC("kretprobe/sys_openat")
int sys_ret_openat(struct pt_regs *ctx)
{
u64 tgid = bpf_get_current_pid_tgid();
u32 pid = tgid >> 32; // 获取进程id
char comm[TASK_COMM_LEN] = {0};
const char *filename = bpf_map_lookup_elem(&check_file, &tgid);
bpf_get_current_comm(comm, TASK_COMM_LEN); // 获取读取进程名
if (filename != NULL)
{
bpf_map_delete_elem(&check_file, &tgid);
const char msg[] = "hidden file:%s by %s(%d)";
bpf_trace_printk(msg, sizeof(msg), filename, comm, pid);
bpf_override_return(ctx,-EFAULT); // 修改返回值
}

return 0;
}

用户层代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package main

import (
"bytes"
"encoding/binary"
"errors"
"log"
"os"
"os/signal"
"syscall"

"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
)

// $BPF_CLANG and $BPF_CFLAGS are set by the Makefile.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags $BPF_CFLAGS -type event bpf ../bpf_kern/src/bpf.c -- -I../bpf_kern/include
//注意填写go generate 的生成参数
func main() {
// Name of the kernel function to trace.

// Subscribe to signals for terminating the program.
stopper := make(chan os.Signal, 1)
signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}

// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{} //由bpf2go 生成 在运行前需要先执行generate
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()

// Open a Kprobe at the entry point of the kernel function and attach the
// pre-compiled program. Each time the kernel function enters, the program
// will emit an event containing pid and command of the execved task.
//kp, err := link.AttachTracing(link.TracingOptions{Program: objs.bpfPrograms.SysOpenat})
kp, err := link.Kprobe("do_sys_openat2", objs.SysOpenat, nil)
if err != nil {
log.Fatalf("opening do_sys_openat2 kprobe: %s", err)
}
_, err = link.Kretprobe("sys_openat", objs.SysRetOpenat, nil)
//kp, err := link.Tracepoint("syscalls", "sys_enter_openat", objs.SysOpenat, nil)
if err != nil {
log.Fatalf("opening sys_openat kprobe: %s", err)
}
defer kp.Close()

// Open a ringbuf reader from userspace RINGBUF map described in the
// eBPF C program.
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("opening ringbuf reader: %s", err)
}
defer rd.Close()

// Close the reader when the process receives a signal, which will exit
// the read loop.
go func() {
<-stopper

if err := rd.Close(); err != nil {
log.Fatalf("closing ringbuf reader: %s", err)
}
}()

log.Println("Waiting for events..")

// bpfEvent is generated by bpf2go.
var event bpfEvent
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
log.Println("Received signal, exiting..")
return
}
log.Printf("reading from reader: %s", err)
continue
}

// Parse the ringbuf event entry into a bpfEvent structure.
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
log.Printf("parsing ringbuf event: %s", err)
continue
}

log.Printf("pid: %d\tcomm: %s\n file:%s\n",
event.Pid,
unix.ByteSliceToString(event.Comm[:]),
unix.ByteSliceToString(event.Filename[:]),
)
}
}

演示

测试环境 Ubuntu 22.04 5.15.0-46-generic

正常查看文件:

运行go生成的程序:

再次查看test.txt,此时已经完成在用户层下的文件隐藏,其实起作用的还是eBPF内核层,比起驱动,我们完全不用担心内核会挂掉

Tips

  1. bpf_trace_printk 函数只能打印3个参数,并且特别慢,请只用于调试,查看print的内容请执行cat /sys/kernel/debug/tracing/trace_pipe
  2. 善用clang -S 输出 bpf 汇编,方便调试bpf程序,eBPF的错误提示只能说是聊胜于无
  3. 在线看源码点我,方便本地没有内核源码的人查看内核定义
  4. 碰上不理解的多去看bccebpf的示例,基本上已经把常见的eBPF类型写的很清楚了
  5. bpf_helpers一定要去看使用手册,搞清楚什么情况下才能用,在线浏览点我,例如在源码里有的bpf_sys_close 手册上实际并没有,一定要按手册来
  6. 全部软件架构请参考libbpf-bootstrapcilium/ebpf

总结

eBPF作为一个新的内核扩展技术,的确可以称得上是超能力了,尤其是CO-RE,以后可以更方便的支持不同架构的机器去做内核层的数据分析,对于云原生架构也非常友好,很适合现在docker和k8s遍地开花的时代,对于安全方面, 也非常适合hidsedr/ndr 层面,期待以后eBPF技术对内核扩展更多功能吧