简介

eBPF 是 Linux 内核提供的一种动态 Hook 机制,可以将自定义代码逻辑 Hook 到指定内核事件上,当内核触发该事件时就会调用相应的自定义代码逻辑。

Linux上常见的使用 eBPF 开发的程序:

  • topps:允许您查看进程及其在 CPU 和 RAM 方面占用的内容
  • tcpdump:用于监视服务器上的网络流
  • strace:它允许您查看进程的系统调用

除此之外 Linux 的 systemd(初始化系统)也是使用 eBPF 开发的。

eBPF 原理

在传统程序中,我们有一个源代码。从这个源代码中,我们创建程序,如果它是编译语言,则对其进行编译:

在 eBPF 中,不是一个程序,而是两个程序:

  • 将在用户空间中编译的 eBPF 代码,但只能在内核空间中运行;
  • 一个用户空间代码,主要加载刚刚编译到内核中的 eBPF 二进制文件。

我们稍后会看到,可以在内核空间和用户空间之间进行通信(通过 eBPF 映射),也可以在内核空间 eBPF 程序之间进行通信(通过尾部调用)。

从上面可以看出,一个完整的 eBPF 程序通常分为用户态和内核态两部分:用户态负责 eBPF 程序的加载、事件绑定以及 eBPF 程序运行结果的汇总输出;内核态运行在 eBPF 虚拟机中,负责定制和控制系统的运行状态;内核态的 eBPF 程序通过 BPF 映射(Map)用户态程序交互

bpf 系统调用:

#include <linux/bpf.h>  
  
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
  • cmd: 操作命令,比如 BPF_PROG_LOAD 就是加载 eBPF 程序。
  • attr: 操作命令对应的属性。
  • size: 属性的大小。

eBPF 程序开发

内核态程序

内核态的 eBPF 程序在内核视角就是 eBPF 字节码,可以用 C 或 Rust 编译而来,分以下几种方案:

  • • libbpf:

    • • 开发语言:C
    • • 编译依赖:libbpf (用到为编译器提供的宏,如 SEC)。
    • • 运行依赖:无
    • • 内核要求:内核开启 BTF 特性,需要非常较新的发行版才会默认开启(如 RHEL 8.2+ 和 Ubuntu 20.10+ 等)。
  • • bcc:

    • • 开发语言:C
    • • 编译/运行依赖:bcc、LLVM、内核头文件(bcc 方案是目标机器上进行编译并运行的)
  • • Aya:

    • • 开发语言:Rust
    • • 编译依赖:Rust 环境、Aya
    • • 运行依赖:无

eBPF 框架有两种类型:

  • 那些使用两种不同编程语言的语言:一种用于内核空间,另一种用于用户空间;如 ebpf go;
  • 那些在内核和用户空间中使用同一语言的语言,如 Aya。

常用程序类型:

类型挂载点用途
kprobe/kretprobe内核函数入口/返回内核动态追踪
uprobe/uretprobe用户函数入口/返回用户态程序追踪
tracepoint内核预定义追踪点稳定的内核追踪
xdp网络驱动层高性能包处理
socket_filter网络套接字包过滤
cgroup_*cgroup 钩子容器资源控制

程序类型选择:

// 内核探针
SEC("kprobe/do_nanosleep")
SEC("kretprobe/do_nanosleep")

// 用户态探针
SEC("uprobe//lib/x86_64-linux-gnu/libc.so.6:malloc")
SEC("uretprobe//lib/x86_64-linux-gnu/libc.so.6:malloc")

// 追踪点
SEC("tracepoint/syscalls/sys_enter_openat")

// XDP
SEC("xdp")

用户态程序

用户态程序主要负责将内核态 eBPF 程序加载到内核并运行,然后通过 BPF 映射读取内核态 eBPF 程序输出的数据,最后做相应的业务逻辑处理,它可以用任何语言编写,下面列举了一些常用的方案:

  • ebpf-go:
    • 开发语言:go
    • 编译依赖:go、cilium/ebpf
    • 运行依赖:无
  • Libbpf:
    • 开发语言:C/C++
    • 编译依赖:需要 clang / LLVM(运行时编译 eBPF 程序)
    • 运行依赖:支持 eBPF 的 Linux 内核 + libbpf(用户态库)+ 内核 BTF(CO-RE 场景下)
  • Aya:
    • 开发语言:Rust
    • 编译依赖:Rust 环境、Aya
    • 运行依赖:无
image

Maps

  • 映射类型
// 哈希表 - 通用键值存储
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, u64);
} hash_map SEC(".maps");

// 数组 - 索引存储
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 256);
    __type(key, u32);
    __type(value, u64);
} array_map SEC(".maps");

// 环形缓冲区 - 事件传输(推荐)
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24); // 16MB
} rb_map SEC(".maps");

// Per-CPU 映射 - 减少锁竞争
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, u64);
} percpu_map SEC(".maps");

// LRU 哈希 - 自动淘汰
struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, u64);
} lru_map SEC(".maps");
  • 常用映射操作
// 查找
void *val = bpf_map_lookup_elem(&map, &key);
if (val) {
    // 找到时的处理
}

// 更新
u64 new_val = 100;
bpf_map_update_elem(&map, &key, &new_val, BPF_ANY);
// flags: BPF_ANY(存在则更新), BPF_NOEXIST(不存在则创建), BPF_EXIST(必须存在)

// 删除
bpf_map_delete_elem(&map, &key);

// 遍历(用户态使用 bpftool)
// bpftool map dump id <map_id>

辅助函数

// 获取上下文信息
u32 pid = bpf_get_current_pid_tgid() >> 32;  // 获取 PID
u32 uid = bpf_get_current_uid_gid();          // 获取 UID
bpf_get_current_comm(&comm, sizeof(comm));    // 获取进程名

// 时间函数
u64 ts = bpf_ktime_get_ns();      // 单调时钟(纳秒)
u64 ts_real = bpf_ktime_get_real_ns(); // 挂钟时间

// 内存操作
bpf_probe_read_user(&buf, sizeof(buf), user_ptr);   // 读用户内存
bpf_probe_read_kernel(&buf, sizeof(buf), kernel_ptr); // 读内核内存
bpf_probe_read_user_str(str, sizeof(str), user_str);  // 读用户字符串
bpf_probe_read_kernel_str(str, sizeof(str), kernel_str);

// 映射操作
bpf_map_lookup_elem()
bpf_map_update_elem()
bpf_map_delete_elem()

// 事件输出
bpf_ringbuf_output()   // 向 ring buffer 输出
bpf_ringbuf_submit()   // 提交 ring buffer 条目
bpf_perf_event_output() // 向 perf buffer 输出(传统方式)

// 调试
bpf_printk(fmt, ...)   // 输出到 /sys/kernel/debug/tracing/trace_pipe

开发实例

接下来我们开发一个简单的 demo,通过 APP 的 UID 进行过滤,跟踪指定 APP 调用了哪些 SO 库。实现思路呢就是在android_dlopen_extdlopen函数入口挂载 uprobe,在目标 APP 调用动态库加载接口时读取第一个参数中的 so 路径,并通过 eBPF ringbuf 将事件上报到用户态。用户态 Go 程序按指定 UID 过滤事件,最终输出进程名以及被加载的 so 路径。

ebpf-tools

环境配置

开发 eBPF 程序需要手机的内核版本高于 5.10。

go mod init hello
go get github.com/cilium/ebpf@v0.17.1

sudo apt update
sudo apt install clang llvm libelf-dev gcc make git
  • 下载 bpf-tools:https://github.com/libbpf/bpftool/releases
# 推送到设备
adb push bpftool /data/local/tmp/
adb shell chmod +x /data/local/tmp/bpftool
  • 获取内核符号
#进入 Android 设备
adb shell
su
cd /data/local/tmp
./bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
adb pull /data/local/tmp/vmlinux.h ./

内核态程序

内核态程序只能通过 C 语言编写,只能使用内核导出的函数或内核 bpf 库中的函数。

  • hooker.c
//go:build ignore

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

char _license[] SEC("license") = "GPL";

#define MAX_SO_PATH 256
#define ARM64_PT_REGS_X0_OFFSET 0

const volatile __u32 target_uid = 0;

struct event {
    __u64 ts_ns;
    __u32 pid;
    __u32 tid;
    __u32 uid;
    __s32 path_len;
    char comm[16];
    char so_path[MAX_SO_PATH];
};

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
} events SEC(".maps");

static __always_inline bool should_trace_uid(void)
{
    __u32 uid = (__u32)(bpf_get_current_uid_gid() & 0xffffffff);

    if (target_uid == 0)
        return true;

    return uid == target_uid;
}

static __always_inline const char *arm64_arg0_from_pt_regs(struct pt_regs *ctx)
{
    __u64 arg0 = 0;
    
    bpf_probe_read_kernel(&arg0, sizeof(arg0), (const void *)ctx + ARM64_PT_REGS_X0_OFFSET);
    return (const char *)arg0;
}

static __always_inline int submit_dlopen_event(struct pt_regs *ctx)
{
    const char *path;
    __u64 pid_tgid;
    struct event *evt;
    long len;

    if (!should_trace_uid())
        return 0;

    /* Android arm64 ABI: dlopen/android_dlopen_ext 第一个参数在 x0。 */
    path = arm64_arg0_from_pt_regs(ctx);
    if (!path)
        return 0;

    evt = bpf_ringbuf_reserve(&events, sizeof(*evt), 0);
    if (!evt)
        return 0;

    len = bpf_probe_read_user_str(evt->so_path, sizeof(evt->so_path), path);
    if (len <= 1) {
        bpf_ringbuf_discard(evt, 0);
        return 0;
    }

    pid_tgid = bpf_get_current_pid_tgid();
    evt->ts_ns = bpf_ktime_get_ns();
    evt->pid = pid_tgid >> 32;
    evt->tid = (__u32)pid_tgid;
    evt->uid = (__u32)(bpf_get_current_uid_gid() & 0xffffffff);
    evt->path_len = (__s32)len;
    bpf_get_current_comm(&evt->comm, sizeof(evt->comm));

    bpf_ringbuf_submit(evt, 0);
    return 0;
}

SEC("uprobe/trace_so_enter")
int trace_so_enter(struct pt_regs *ctx)
{
    return submit_dlopen_event(ctx);
}

用户态程序

  • main.go
package main

//go:generate sh -c "go run github.com/cilium/ebpf/cmd/bpf2go -target bpf hooker hooker.c && llvm-objcopy --remove-section=.BTF.ext --remove-section=.rel.BTF.ext hooker_bpf.o"

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"log"
	"os"
	"strconv"
	"strings"
	"time"

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

const maxSOPath = 256

var linkerPaths = []string{
	"/apex/com.android.runtime/bin/linker64",
	"/system/bin/linker64",
}

var dlopenSymbols = []string{
	"android_dlopen_ext",
	"dlopen",
	"__loader_android_dlopen_ext",
	"__loader_dlopen",
}

// event 必须和 hooker.c 里的 struct event 字段顺序、大小保持一致。
type event struct {
	TsNS    uint64
	Pid     uint32
	Tid     uint32
	UID     uint32
	PathLen int32
	Comm    [16]byte
	SOPath  [maxSOPath]byte
}

func main() {
	uid, err := parseUID(os.Args)
	if err != nil {
		log.Fatal(err)
	}

	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal("remove memlock: ", err)
	}

	spec, err := loadHooker()
	if err != nil {
		log.Fatal("load bpf spec: ", err)
	}
	patchKernelVersion(spec)

	if err := spec.RewriteConstants(map[string]interface{}{"target_uid": uid}); err != nil {
		log.Fatal("rewrite target_uid: ", err)
	}

	var objs hookerObjects
	if err := spec.LoadAndAssign(&objs, nil); err != nil {
		log.Fatal("load eBPF objects: ", err)
	}
	defer objs.Close()

	linkerPath, exe, err := openFirstLinker()
	if err != nil {
		log.Fatal("open linker: ", err)
	}

	var links []link.Link
	for _, symbol := range dlopenSymbols {
		up, err := exe.Uprobe(symbol, objs.TraceSoEnter, nil)
		if err != nil {
			log.Printf("skip %s: %v", symbol, err)
			continue
		}
		links = append(links, up)
	}
	defer closeLinks(links)
	if len(links) == 0 {
		log.Fatal("no dlopen symbol attached")
	}

	reader, err := ringbuf.NewReader(objs.Events)
	if err != nil {
		log.Fatal("open ringbuf: ", err)
	}
	defer reader.Close()

	log.Printf("trace uid=%d linker=%s", uid, linkerPath)
	for {
		record, err := reader.Read()
		if err != nil {
			log.Fatal("read ringbuf: ", err)
		}

		var evt event
		if err := binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &evt); err != nil {
			log.Printf("decode event: %v", err)
			continue
		}

		fmt.Printf(
			"ts=%s uid=%d pid=%d tid=%d comm=%s so=%s\n",
			time.Unix(0, int64(evt.TsNS)).Format(time.RFC3339Nano),
			evt.UID,
			evt.Pid,
			evt.Tid,
			cString(evt.Comm[:]),
			cString(evt.SOPath[:]),
		)
	}
}

func patchKernelVersion(spec *ebpf.CollectionSpec) {
	var uts unix.Utsname
	if err := unix.Uname(&uts); err != nil {
		return
	}

	var major, minor, patch uint32
	if n, _ := fmt.Sscanf(unix.ByteSliceToString(uts.Release[:]), "%d.%d.%d", &major, &minor, &patch); n < 2 {
		return
	}
	if patch > 255 {
		patch = 255
	}

	version := major<<16 | minor<<8 | patch
	for _, prog := range spec.Programs {
		if prog.Type == ebpf.Kprobe {
			prog.KernelVersion = version
		}
	}
}

func parseUID(args []string) (uint32, error) {
	if len(args) != 3 || args[1] != "-uid" {
		return 0, fmt.Errorf("usage: %s -uid <android_app_uid>", args[0])
	}

	uid, err := strconv.ParseUint(args[2], 10, 32)
	if err != nil || uid == 0 {
		return 0, fmt.Errorf("invalid uid: %s", args[2])
	}
	return uint32(uid), nil
}

func openFirstLinker() (string, *link.Executable, error) {
	var errs []string
	for _, path := range linkerPaths {
		exe, err := link.OpenExecutable(path)
		if err == nil {
			return path, exe, nil
		}
		errs = append(errs, path+": "+err.Error())
	}
	return "", nil, errors.New(strings.Join(errs, "; "))
}

func cString(raw []byte) string {
	if idx := bytes.IndexByte(raw, 0); idx >= 0 {
		raw = raw[:idx]
	}
	return string(raw)
}

func closeLinks(links []link.Link) {
	for _, l := range links {
		_ = l.Close()
	}
}

编译构建

go generate

GOOS=android \
GOARCH=arm64 \
go build

测试程序

推送到目标设备:

adb push hooker /data/local/tmp/hooker
adb shell chmod 755 /data/local/tmp/hooker

查看目标 APP UID,然后指定 UID 运行程序,比如我们分析 B 站。

❯ adb shell ps | grep bili
u0_a340      11532  1163   38003560 532988 0                   0 S tv.danmaku.bili
u0_a340      12014  1163   18727748 292360 0                   0 S tv.danmaku.bili:ijkservice
u0_a340      12849  1163   35741940 340092 0                   0 S tv.danmaku.bili:download
u0_a340      13027  1163  250286840 441840 0                   0 S tv.danmaku.bili:web

Android 系统都是从 10000 开始分配 UID,所以 B 站的 UID是 10340,然后我们运行程序。

adb shell su -c '/data/local/tmp/hooker -uid 10340'

然后打开目标程序:

image-20260726233221318

参考

eBPF - 简介、教程和社区资源 【第拾壹期 REVERSE 分享会】驱动逆向 & Ebpf & 某实战逆向 & Angr & 自定义ROM & Frida