eBPF 学习及简单应用

【第拾壹期 REVERSE 分享会】驱动逆向 & Ebpf & 某实战逆向 & Angr & 自定义ROM & Frida_哔哩哔哩_bilibili

eBPF 完全入门指南.pdf(万字长文) - 知乎

学习分享会的相关知识

eBPF,全称 extended Berkeley Packet Filter,中文常扩展伯克利包过滤器。但这个名字有很强的历史包袱——它今天早就不只是包过滤器了。更准确地说,eBPF 是一个运行在内核里的、受严格校验约束的、通用的可编程执行机制。它允许你把一小段程序动态加载到内核,在特定事件发生时执行,从而实现:

  • 网络包处理
  • 系统调用观测
  • 性能分析
  • 安全审计
  • 访问控制
  • 跟踪内核/用户态函数
  • 统计、监控、故障排查
  • 高性能数据路径处理

很多人把它称为,内核里的虚拟机,内核的可编程插桩框架,操作系统可观测性和网络数据面的基础设施

传统方式里,想扩展内核能力,往往要改内核源码,写内核模块,重新编译/加载,承担极高的崩溃风险

eBPF 的出现,就是为了实现不改内核主体,也能安全地在内核关键路径上执行自定义逻辑,ebpf是linux内核的⼀个组件,最初用于实现数据链路层的包过滤功能和解决如何在内核沙箱中安全运行⼀段程序这⼀问题,后续经过各种迭代添加了大量新的监视功能和辅助函数,目前已经是linux中内核监控的强大工具

它的核心作用是:不用修改内核源码、不用加载传统内核模块,也能在内核事件发生时执行自定义逻辑。

eBPF,全称是 extended Berkeley Packet Filter,是一种让用户在 Linux 内核中安全运行小程序的技术。

1
2
3
4
5
6
7
8
9
应用程序
|
| 加载 eBPF 程序
v
Linux 内核
|
| 网络包、系统调用、函数调用、调度事件等触发点
v
执行 eBPF 逻辑

eBPF 的历史背景

BPF 最早是 1990 年代提出的,最初用途很单纯提高网络抓包和过滤效率。

比如 tcpdump 抓包时,如果每个包都先拷到用户态再判断是不是想要的,就太浪费了。于是人们想:能不能在内核里先执行一个小过滤程序,只把需要的包交给用户态?

传统抓包链路里,判断逻辑本来就在用户态,数据包先到网卡,再进入内核网络栈。这时用户写的 tcpdump 进程并不能直接碰网卡 DMA 过来的那块内存

于是有了 BPF(Berkeley Packet Filter),它是一套很小的指令集,可以在内核里执行,专门用于对网络包做过滤判断,比纯解释条件快得多,最初 BPF 很像一个小型专用字节码过滤器。

经典 BPF(cBPF)的问题很明显,指令能力有限,主要面向网络过滤,扩展性差,难以支持更复杂逻辑,无法承担现代操作系统中的通用观测/安全/网络加速需求

随着 Linux 越来越复杂,人们需要一种机制,不只过滤网络包,还能感知内核事件,能统计延迟、丢包、锁竞争、上下文切换,能做安全策略检查,能在高性能场景替代部分内核硬编码逻辑。这时传统 BPF 已经不够。

于是 Linux 社区逐渐把原本的 BPF 扩展成了现在的 eBPF:

  • 扩展指令集
  • 扩展寄存器模型
  • 允许更多程序类型
  • 引入 verifier(验证器)
  • 引入 JIT 编译
  • 引入 map 作为内核/用户态共享数据结构
  • 让它能挂载到不同内核 hook 点上

BPF 从一个网络过滤器,变成了一个安全、通用、事件驱动、内核内执行的可编程平台。

在逆向中的作用

eBPF 在逆向中的核心价值是用动态行为观测去补足静态分析和用户态 hook 的盲区。看程序和系统、内核、网络、文件、内存、进程之间发生了什么

监控 mmap/mprotect看哪些内存区域被分配成可执行,从 RW 改成 RX,突然出现新的执行页等。这经常对应壳解包,JIT 代码生成,运行时代码解密,shellcode 落地监控 execve / clone看是否 fork 子进程反调试,或者 exec 新阶段 payload。

看雪上有位师傅说过,风控对抗要找边界值,也就是敏感数据的最初来源,在java层就是安卓 binder 通信,native层则是 syscall ,打个比方对root的检测,以前就是调用 libc 的 open 去各种目录下扫有没有 su ,后来用户态 inline hook 被 frida 之类的玩烂了,就搞内联 libc ,然后搞自实现 open 和 svc 直接调用 openat ,这里的 svc 指的是arm的 svc 指令,根据调用号直接向内核发起系统调用, libc 大量和系统交互的函数,最终也是调用 svc 发起的系统调用,当水位上升到这个高度的时候用户态hook显得有点不够用,毕竟要在安全sdk大量的代码里找到一小块 svc 调用几乎是不可能的,更别说很可能会漏,而且用户态hook大多都是侵入式的,为了防止检测又要各种找,而往往这些检测又通过 svc 发起,陷入一个死循环。

引自分享会

比如Java 层很多敏感信息最终要通过Binder 调系统服务所以 Java 层的边界常常是 Binder 通信,Native 层很多动作最终要落到mmap ,这些最后都得靠syscall进入内核。

举个链路:

1
Java API -> JNI -> libc open -> syscall openat -> 内核 -> 文件系统

如果你在最上层拦,Java 方法返回值可改,JNI 返回值可改,libc open 可 hook,但如果检测方把观测点下沉到直接发 syscall,甚至绕开 libc 封装。甚至多路径交叉验证那在上层做的伪装就开始失效。

以前 root 检测可能就是调 libcopen,去扫 /system/xbin/su/system/bin/su/sbin/su,或读一些系统属性、mount 信息这时如果你用 Frida、inline hook、PLT hook,去拦open,fopen就能把结果改掉。

后来检测方发现这太好骗,于是开始不走公开 API,不走容易被 hook 的导出函数,做内联 syscall stub,自己拼寄存器参数,直接 svc #0 发系统调用,甚至不复用统一封装,而是在不同代码块里分散实现

在 ARM/ARM64 上,svcSupervisor Call 指令。它的作用就是从用户态陷入内核,请求内核帮你做事。类比x86上的syscall,sysenter,更早的int 0x80

在这种场景下,ebpf 成为了一个完美的方案,ebpf 在所有 syscall 的入口点 sys_enter 默认就有一个监测点,可以监视绝大多数常用 syscall 触发时的参数和上下文(寄存器,pc,sp,任意读内存),甚至可以在触发的时候发送信号,更关键的是在监视系统调用这一场景下,ebpf 对用户态完全无痕,因为所有的数据采集完全在内核中

eBPF 组成结构

由于其在内核中运行的特殊性, ebpf 软件实际上可以认为由两部分组成,分别是用户态和内核态程序

image-20260325162112096

用户程序负责通过加载器将bpf二进制文件运行在内核中,并且处理bpf程序采集的信息,这里有相当多的加载器框架,完成的都是加载bpf程序和与bpf对象交互这两项工作。内核态程序可以认为是一个比较特殊的elf目标文件,其主要由探针程序和bpf对象组成,这些部分都要通过 SEC 宏将其指定在对应区段,这样内核才能正确加载这些对象以及用户态程序才能正确与这些对象交互。这个内核态elf文件在进入内核前还要经过内核的验证器的检查,确保其安全性。如是否有空指针等。

eBPF 之所以安全,是因为程序加载进内核前会经过 Verifier 校验器 检查,确保它不会死循环、非法访问内存或破坏内核。通过验证后,eBPF 字节码通常会被 JIT 编译成本机机器码执行。

一个典型 eBPF 体系里有几个核心概念:

  • eBPF Program:运行在内核中的小程序
  • Hook:程序挂载的位置
  • Map:内核态和用户态共享数据的存储结构
    • maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;
    • perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析
  • Verifier:安全检查器
  • Helper Function:内核提供给 eBPF 程序调用的受限函数
  • 用户态 Loader:负责加载、配置、读取结果的程序,例如用 libbpf、bcc、cilium/ebpf 等

eBPF 使用

libbpf(当前最主流、最基础)

libbpf 是 Linux 官方主推的 eBPF 用户态库,也是现代 eBPF 生态的核心基础。它提供了加载、验证、attach eBPF 程序以及操作 BPF maps 的能力。很多新项目(包括 Cilium、bcc 新版本等)都基于 libbpf。它支持 CO-RE(Compile Once Run Everywhere),可以让程序在不同内核版本上运行而无需重新编译。特点是性能好、依赖少、接近内核、适合生产环境

bcc(BPF Compiler Collection)

bcc 是早期最流行的 eBPF 开发框架之一,由 iovisor 社区推动。它通过 Python/Lua/C++ 封装,让开发者可以更快速写 eBPF 程序,同时依赖 LLVM/Clang 在运行时编译。优点是上手快、示例丰富、工具齐全(如 opensnoop、execsnoop),非常适合学习和调试。但缺点是依赖重、运行时编译、在生产环境不如 libbpf 轻量,现在逐渐被 libbpf 体系替代。

bpftrace(高级脚本语言)

bpftrace 是一个类似 awk 的高级 tracing 语言,可以用非常简洁的脚本快速写 eBPF 程序。例如几行代码就能统计 syscall 次数或函数耗时。它内部基于 LLVM 和 BPF,适合做临时分析和性能排查。特点是开发极快、语法简洁、适合一次性诊断,但不适合复杂逻辑或长期运行的系统,因为控制力和性能不如原生 libbpf。

Cilium / eBPF-based networking framework

Cilium 是基于 eBPF 的云原生网络和安全框架,是 Kubernetes 生态里非常重要的项目。它利用 eBPF 实现 L3/L4/L7 网络策略、负载均衡、可观测性等功能。虽然它本质是一个网络系统,但内部大量使用 eBPF,并构建了完整的开发框架。特点是面向生产级网络、安全、容器场景,适合云原生环境,而不是通用 eBPF 开发入门工具。

libbpf-bootstrap / skeleton(现代开发范式)
这是基于 libbpf 的开发模板和自动生成框架(skeleton)。通过 bpftool 或 libbpf 可以生成 skeleton 代码,大幅简化用户态加载逻辑。现代 eBPF 项目基本都采用这种方式。特点是结构清晰、代码规范、官方推荐路径,可以理解为 libbpf 的“工程化形态”。

Aya(Rust eBPF 框架)

Aya 是一个用 Rust 编写的 eBPF 框架,支持在 Rust 中开发 eBPF 程序(包括内核态和用户态部分),且不依赖 LLVM runtime。它提供更安全的内存模型和更现代的开发体验。特点是安全性高、Rust 生态、逐渐流行,但整体生态还不如 C/libbpf 成熟。

RedBPF(Rust 早期方案)

RedBPF 是较早的 Rust eBPF 框架,但现在热度和维护度不如 Aya,逐渐被 Aya 替代。了解即可。

Go eBPF(cilium/ebpf 库)

这是 Go 语言下常用的 eBPF 操作库,由 Cilium 社区维护。它允许用 Go 加载和控制 eBPF 程序(内核程序仍通常用 C 写)。特点是适合写 agent、云原生组件、监控系统,在云厂商和 Kubernetes 生态中很常见。

这里 libbpf 是对原生加载指令的封装,而其他框架则是对 libbpf 的再封装, bcc 框架采用动态编译的方案,在手机环境中配置 bcc 会非常困难(缺少包管理器),其余都是静态编译方案,这里 ebpf-go 保留了大部分 libbpf 的风格。

eBPF 是 Linux 内核提供的一套可编程机制子系统。

它包括内核里:

  • eBPF 虚拟机/指令集
  • Verifier 校验器
  • JIT 编译器
  • BPF maps
  • helper functions
  • kprobe/uprobe/tracepoint/XDP 等 hook 点
  • bpf() 系统调用

但 eBPF 程序不会自己跑起来。需要一个用户态程序去做这些事:

用户态程序:
1. 编译 eBPF C 代码成 BPF 字节码
2. 调用 bpf() syscall 把字节码加载进内核
3. 让内核 verifier 校验
4. 把 eBPF 程序 attach 到 hook 点
5. 配置 BPF map
6. 从 ringbuf/perfbuf/map 读取内核返回的数据
7. 退出时 detach/清理资源

所以才会有用户态 eBPF 框架,比如 cilium/ebpf 本质上不是内核里的 eBPF,而是一个 Go 用户态库,方便地调用内核 eBPF 接口:

1
2
3
spec, _ := ebpf.LoadCollectionSpec("xxx.o")
spec.LoadAndAssign(&objs, nil)
link.Kprobe("do_sys_openat2", objs.Program, nil)

以 eBPFDexDumper 为例:

1
2
3
4
5
6
7
bpf.c
↓ 编译
eBPF 字节码
↓ bpf2go 生成 Go 绑定
Go 用户态程序
↓ cilium/ebpf + ebpfmanager
加载到内核、挂 uprobe、读 ringbuf

eBPF 特性

主要参考知乎的那篇文章,文章链接放在顶部了

Hook Overview

eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。

tracepoints 可以理解为 Linux 内核源码中提前埋好的观测点

当内核运行到这些固定位置时,会触发一个事件。eBPF 程序可以挂到这些事件上,于是每当事件发生,eBPF 程序就会被执行。

如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。

后面出现了类似的情况

kprobeuprobetracepoint 都是 eBPF 可以挂载的 Hook 点类型,但它们的定位不同。

tracepoint:内核提前埋好的固定观测点

kprobe:动态插到内核函数上的探针

uprobe:动态插到用户态程序函数上的探针

kprobe 全称可以理解为 kernel probe,也就是内核探针。

例如内核里有一个函数do_sys_openat2()

可以用 kprobe 挂到这个函数入口:

1
2
3
4
5
6
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(handle_open)
{
// 每次内核执行 do_sys_openat2() 时,这里都会运行
return 0;
}

意思是:

1
2
3
4
5
6
7
8
9
用户程序调用 openat()

进入内核

内核执行 do_sys_openat2()

触发 kprobe

eBPF 程序运行

所以,kprobe 适合用来观察 内核函数的执行情况

除了函数入口,还可以挂函数返回点。kretprobe

例如:

1
2
3
4
5
6
SEC("kretprobe/do_sys_openat2")
int BPF_KRETPROBE(handle_open_ret)
{
// do_sys_openat2() 返回时运行
return 0;
}

uprobe全称可以理解为 user-space probe,是用户态探针。挂在用户态程序或动态库函数上

例如想监控某个程序调用 malloc()

1
/lib/x86_64-linux-gnu/libc.so.6:malloc

可以用 uprobe 挂到 libc 的 malloc 函数入口。大概效果是:

1
2
3
4
5
6
7
用户程序调用 malloc()

进入 libc.so.6 的 malloc 函数

触发 uprobe

eBPF 程序运行

如果想监控函数返回,可以用uretprobe

Verification

每一个 eBPF 程序加载到内核都要经过 Verification,用来保证 eBPF 程序的安全性,主要包括:

要保证加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged 特性,只有特权级的程序才能够加载 eBPF 程序

  • 内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来禁止非特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改
  • 比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核
  • 一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。Cilium 启动后也会将这个配置项设为 1:
1
$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
  • 要保证 eBPF 程序不会崩溃或者使得系统出故障
  • 要保证 eBPF 程序不能陷入死循环,能够 runs to completion
  • 要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核
  • 要保证 eBPF 程序的复杂度有限,Verifier 将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析

JIT Compilation

Just-In-Time(JIT) 编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行

eBPF字节码.o文件,同一份 eBPF 字节码,理论上可以运行在不同架构的机器上但是 CPU 本身并不直接认识 eBPF 指令。CPU 认识的是自己的机器指令,所以内核可以用 JIT 编译器,把 eBPF 字节码再翻译成当前 CPU 能直接执行的机器码。这样执行速度会更快。

Maps

BPF Map 是驻留在内核空间中的高效 Key/Value store,包含多种类型的 Map,由内核实现其功能

BPF Map 的交互场景有以下几种:

  • BPF 程序和用户态程序的交互:BPF 程序运行完,得到的结果存储到 map 中,供用户态程序通过文件描述符访问
  • BPF 程序和内核态程序的交互:和 BPF 程序以外的内核程序交互,也可以使用 map 作为中介
  • BPF 程序间交互:如果 BPF 程序内部需要用全局变量来交互,但是由于安全原因 BPF 程序不允许访问全局变量,可以使用 map 来充当全局变量
  • BPF Tail call:Tail call 是一个 BPF 程序跳转到另一 BPF 程序,BPF 程序首先通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个 BPF 程序的指针,然后调用 tail_call() 的 helper function 来执行 Tail call

共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map,单个 BPF 程序目前最多可直接访问 64 个不同 map

当前可用的 通用 map 有:

  • BPF_MAP_TYPE_HASH
  • BPF_MAP_TYPE_ARRAY
  • BPF_MAP_TYPE_PERCPU_HASH
  • BPF_MAP_TYPE_PERCPU_ARRAY
  • BPF_MAP_TYPE_LRU_HASH
  • BPF_MAP_TYPE_LRU_PERCPU_HASH
  • BPF_MAP_TYPE_LPM_TRIE

以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不同的后端,这些后端各有不同的语义和性能特点。随着多 CPU 架构的成熟发展,BPF Map 也引入了 per-cpu 类型,如BPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的 BPF Map 时,每个 CPU 都会存储并看到它自己的 Map 数据,从属于不同 CPU 之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的 BPF 程序主要是在做收集时间序列型数据,如流量数据或指标等。

当前内核中的 非通用 map 有:

  • BPF_MAP_TYPE_PROG_ARRAY:一个数组 map,用于 hold 其他的 BPF 程序
  • BPF_MAP_TYPE_PERF_EVENT_ARRAY
  • BPF_MAP_TYPE_CGROUP_ARRAY:用于检查 skb 中的 cgroup2 成员信息
  • BPF_MAP_TYPE_STACK_TRACE:用于存储栈跟踪的 MAP
  • BPF_MAP_TYPE_ARRAY_OF_MAPS:持有其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
  • BPF_MAP_TYPE_HASH_OF_MAPS:持有其他 map 的指针,这样整个 map 就可以在运行时实现原子替换

Helper Calls

eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functionsHelper functions 使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加

比如它不能直接:

1
2
3
4
5
随便读写内核内存
随便调用内核函数
随便分配内存
随便访问用户态数据
随便向用户态输出数据

否则会带来两个问题:

1
2
1. 安全问题:可能导致内核崩溃或越权访问
2. 兼容问题:不同内核版本内部函数可能变化

所以内核提供了 helper functions,让 eBPF 程序通过这些稳定接口做事情。

Tail Calls

尾调用的机制是指:一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。

  • 和普通函数调用相比,这种调用方式开销最小,因为它是用长跳转实现的,复用了原来的栈帧
  • BPF 程序都是独立验证的,因此要传递状态,要么使用 per-CPU map 作为 scratch 缓冲区 ,要么如果是 tc 程序的话,还可以使用 skb 的某些字段
  • 相同类型的程序才可以尾调用,而且它们还要与 JIT 编译器相匹配,因此要么是 JIT 编译执行,要么是解释器执行,但不能同时使用两种方式

直接跳转到另一个 BPF 程序,它复用了原来的栈帧,不需要像普通函数调用那样额外开一个新的调用上下文。

主要是为了 拆分复杂的 eBPF 程序。eBPF 程序有验证器限制,例如程序不能太复杂,指令数有限制,栈空间有限制,不能有不安全访问

简单Demo [Ebpf观察反调试]

2023腾讯游戏安全竞赛Android初赛 | Matriy’s blog

当时被反调试check了,尝试了一些简单方法才绕过是,大致能猜到做了什么反调试,学了Ebpf可以简单应用下

因为很多 Android 反调试不是直接调用 ptrace,而是先 读文件做环境检测。而读文件之前,通常会先走 openat, 在 Android arm64 上syscall 56 = openat

我们可以监控这个,为什么不看read

1
int fd = openat(AT_FDCWD, "/proc/self/status", O_RDONLY);

如果成功,内核可能返回fd = 37,之后程序读取这个文件时,不再传路径,而是传这个数字:

1
2
read(37, buf, 4096);
close(37);

核心目标是监控目标 App 有没有访问反调试相关接口和路径,比如 /proc/self/maps、/proc/self/status

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
整体流程

Android App 调 syscall
|
v
eBPF 挂 raw_syscalls / do_filp_open
|
v
把事件写入 perf event buffer
|
v
Go 程序读取事件
|
v
按 uid/pid 过滤并打印可疑行为

monitor.bpf.c这里定义了 Android arm64 的 syscall 编号:

1
2
3
4
5
SYS_OPENAT 56
SYS_READ 63
SYS_PTRACE 117
SYS_PRCTL 167
SYS_TGKILL 131

还定义了 config_t 和 event_t:

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
  config_t  -> 用户态传进来的过滤条件,比如 target_uid / target_pid
event_t -> eBPF 发给 Go 的事件结构

ts 时间戳
type 事件类型
syscall_id syscall 编号
tgid 进程 pid
tid 线程 id
uid App UID
fd 文件描述符
ret syscall 返回值
comm 线程名
path 文件路径
sample read 内容采样,当前设备大多读不到

typedef struct {
u32 target_uid;
u32 target_pid;
} config_t;

typedef struct {
u64 ts;
u32 type;
u32 syscall_id;
u32 tgid;
u32 tid;
u32 uid;
s32 fd;
s64 ret;
u64 arg0;
u64 arg1;
u64 arg2;
u64 arg3;
u32 flags;
u32 mode;
u32 count;
u32 sample_len;
char comm[TASK_COMM_LEN];
char path[MAX_PATH_LEN];
char sample[MAX_SAMPLE_LEN];
} event_t;

这里定义 eBPF maps:

1
2
3
4
5
config_map        保存 uid/pid 过滤条件
pending_open_map 暂存 openat 进入时的信息
pending_read_map 暂存 read 进入时的信息
events eBPF -> Go 的事件通道
event_heap 每 CPU 临时 event 缓冲
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
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, config_t);
} config_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, u32);
__type(value, struct pending_open_t);
} pending_open_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, u32);
__type(value, struct pending_read_t);
} pending_read_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__type(value, event_t);
} events SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, event_t);
} event_heap SEC(".maps");

为什么要 pending_open_map?因为 openat 分两步:

1
2
sys_enter_openat  能看到参数,比如 path
sys_exit_openat 才能看到返回 fd

所以需要先暂存,等 exit 时合并成path=/proc/self/maps fd=76 ret=76

raw_syscalls/sys_enter:

1
2
SEC("tracepoint/raw_syscalls/sys_enter")
int trace_sys_enter(...)

负责在 syscall 进入时记录参数:

1
2
3
4
openat/openat2  -> 暂存 path/flags/mode
read -> 暂存 fd/buf/count
close -> 发 close 事件
ptrace/prctl/tgkill/kill/clone -> 发参数事件
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
SEC("kprobe/do_filp_open")
int kprobe_do_filp_open(struct pt_regs *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 tgid = pid_tgid >> 32;
u32 tid = (u32)pid_tgid;
u32 uid = (u32)bpf_get_current_uid_gid();
void *pathname = (void *)ARM64_PARM2(ctx);
const char *name = 0;
struct pending_open_t p = {};
struct pending_open_t *old;

if (!target_allowed(tgid, uid))
return 0;

old = bpf_map_lookup_elem(&pending_open_map, &tid);
if (old) {
p.syscall_id = old->syscall_id;
p.flags = old->flags;
p.mode = old->mode;
} else {
p.syscall_id = SYS_OPENAT;
}

if (bpf_probe_read_kernel(&name, sizeof(name), pathname) != 0 || !name)
return 0;

p.path_ptr = (u64)name;
p.path_len = read_kernel_string(p.path, sizeof(p.path), name);
bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
return 0;
}

我的Android 内核没有普通 syscall tracepoint,而且在 raw_syscalls/sys_enter 里读用户态字符串会失败,所以我们额外挂了内核函数 do_filp_open,从内核侧拿已经解析好的文件路径。所以路径是靠这里补出来的

1
2
do_filp_open -> /proc/self/maps
do_filp_open -> /proc/self/status

内核有 tracepoint 总能力,也有很多 tracepoint。

1
2
3
4
5
6
/sys/kernel/tracing/events/raw_syscalls
/sys/kernel/tracing/events/sched
/sys/kernel/tracing/events/signal
/sys/kernel/tracing/events/kmem
/sys/kernel/tracing/events/filemap
/sys/kernel/tracing/events/android_fs

但没有:

1
2
/sys/kernel/tracing/events/syscalls/sys_enter_openat
/sys/kernel/tracing/events/syscalls/sys_exit_openat

原因是这个配置没开:CONFIG_FTRACE_SYSCALLS is not set

不是没有观测点而是没有细粒度 syscall 观测点,只剩粗粒度 raw_syscalls 观测点

这就是为什么我们代码里写的是:

1
2
SEC("tracepoint/raw_syscalls/sys_enter")
SEC("tracepoint/raw_syscalls/sys_exit")

而不是:

1
2
SEC("tracepoint/syscalls/sys_enter_openat")
SEC("tracepoint/syscalls/sys_exit_openat")

一开始只靠 raw_syscalls/sys_enter 读 openat 的路径时,日志是这样的:

1
openat ... path_len=-14 path_ptr=0x... path=

这里path_len=-14 -14 是 Linux errno EFAULT

意思是eBPF 试图从那个用户态地址读取字符串失败了

1
2
3
  const char *path = (const char *)ctx->args[1];
p.path_len = read_user_string(p.path, sizeof(p.path), path);
ctx->args[1] 是 openat(dirfd, pathname, flags, mode) 里的 pathname,它是用户态指针。

在我的 Android 内核上,从 raw_syscalls/sys_enter 这个位置直接读用户态 pathname 不可靠

然后加了

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
SEC("kprobe/do_filp_open")
int kprobe_do_filp_open(struct pt_regs *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 tgid = pid_tgid >> 32;
u32 tid = (u32)pid_tgid;
u32 uid = (u32)bpf_get_current_uid_gid();
void *pathname = (void *)ARM64_PARM2(ctx);
const char *name = 0;
struct pending_open_t p = {};
struct pending_open_t *old;

if (!target_allowed(tgid, uid))
return 0;

old = bpf_map_lookup_elem(&pending_open_map, &tid);
if (old) {
p.syscall_id = old->syscall_id;
p.flags = old->flags;
p.mode = old->mode;
} else {
p.syscall_id = SYS_OPENAT;
}

if (bpf_probe_read_kernel(&name, sizeof(name), pathname) != 0 || !name)
return 0;

p.path_ptr = (u64)name;
p.path_len = read_kernel_string(p.path, sizeof(p.path), name);
bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
return 0;
}

monitor.bpf.c:344 是 raw_syscalls/sys_exit:

1
2
SEC("tracepoint/raw_syscalls/sys_exit")
int trace_sys_exit(...)

它负责拿返回值:

1
2
openat exit -> ret 就是 fd
read exit -> ret 就是读了多少字节

所以我们能得到:

1
2
openat path=/proc/self/maps fd=76
read fd=76 path=/proc/self/maps ret=1024

kprobe/do_filp_open,它的用途就是在内核已经拿到路径之后,偷看一下路径内容。

完整串起来是这样

  1. bpf2go 先把 monitor.bpf.c:1 编译成 bpf_bpfel.o,然后嵌进 bpf_bpfel.go:188。所以 Go 程序运行时已经带着 eBPF 字节码。

  2. 用户态 Go 调用 loadBpfObjects(&objs, nil) 加载 eBPF ,

    1
    2
    3
    4
    var objs bpfObjects
    if err := loadBpfObjects(&objs, nil); err != nil {
    log.Fatalf("load bpf objects: %v", err)
    }

    这一步会通过 cilium/ebpf 调 Linux 的 bpf() 系统调用,把 eBPF 程序和 map 创建到内核里。objs.TraceSysEnter、objs.Events 这些名字来自自动生成的 bpf_bpfel.go:119

  3. 用户态把过滤条件写进内核 map

    1
    2
    3
    4
    cfg := bpfConfigT{
    TargetUid: uint32(uidFlag),
    TargetPid: uint32(pidFlag),
    }
  4. 这一步之后,不是 Go 主动调用 trace_sys_enter。而是目标 App 调 syscall 时,内核自动执行对应 eBPF 函数。

    1
    2
    3
    link.Tracepoint("raw_syscalls", "sys_enter", objs.TraceSysEnter, nil)
    link.Tracepoint("raw_syscalls", "sys_exit", objs.TraceSysExit, nil)
    link.Kprobe("do_filp_open", objs.KprobeDoFilpOpen, nil)

    比如 App 调了openat(AT_FDCWD, “/proc/self/maps”, flags, mode)

    内核先触发 raw_syscalls/sys_enter,进入monitor.bpf.c:214

    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
    SEC("tracepoint/raw_syscalls/sys_enter")
    // 把这个 eBPF 程序挂到 Linux 内核的这个 tracepoint 上,这个 tracepoint 会在任意系统调用刚进入内核时触发
    // raw_sys_enter_ctx 是系统调用入口事件的上下文
    // ctx->id 当前系统调用号 ctx->args[0] 到 ctx->args[5] 当前系统调用的最多 6 个参数
    /**
    例如 openat 系统调用大概是:
    openat(int dirfd, const char *pathname, int flags, mode_t mode)
    那么在 ctx->args 中一般对应:
    ctx->args[0] = dirfd
    ctx->args[1] = pathname
    ctx->args[2] = flags
    ctx->args[3] = mode
    **/
    int trace_sys_enter(struct raw_sys_enter_ctx *ctx)
    {
    /**
    获取当前进程、线程、用户 ID
    **/
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 tgid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u32 uid = (u32)bpf_get_current_uid_gid();
    long id = ctx->id;

    if (!target_allowed(tgid, uid))
    return 0;

    /**
    现在挂的是raw_syscalls:sys_enter这是系统调用刚进入的时候。此时只能知道要打开哪个路径
    flags 是什么 mode 是什么
    但还不知道打开是否成功,也不知道返回的文件描述符 fd 是多少。fd 要等系统调用结束时才能知道。
    所以这里先把信息暂存到 pending_open_map 里。
    **/
    if (id == SYS_OPENAT || id == SYS_OPENAT2) {
    struct pending_open_t p = {};
    const char *path = (const char *)ctx->args[1];

    p.syscall_id = (u32)id;
    p.path_ptr = (u64)path;
    if (id == SYS_OPENAT) {
    p.flags = (u32)ctx->args[2];
    p.mode = (u32)ctx->args[3];
    }
    p.path_len = read_user_string(p.path, sizeof(p.path), path);
    bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
    return 0;
    }
    /**
    如果是 close: 记录 fd 并提交事件
    如果是 readlinkat/faccessat/newfstatat:读取路径并提交事件
    如果是 getdents64:记录目录 fd 和 count
    如果是 ptrace/prctl/kill/clone:记录前几个参数
    **/
    if (id == SYS_CLOSE) {
    event_t *e = new_event(EVENT_CLOSE, (u32)id);
    if (!e)
    return 0;
    e->fd = (s32)ctx->args[0];
    submit_event(ctx, e);
    return 0;
    }

    if (id == SYS_READLINKAT || id == SYS_FACCESSAT || id == SYS_FACCESSAT2 || id == SYS_NEWFSTATAT)
    return emit_path_event(ctx, (u32)id, (const char *)ctx->args[1], ctx->args[0], ctx->args[2]);

    if (id == SYS_GETDENTS64) {
    event_t *e = new_event(EVENT_GETDENTS, (u32)id);
    if (!e)
    return 0;
    e->fd = (s32)ctx->args[0];
    e->count = (u32)ctx->args[2];
    submit_event(ctx, e);
    return 0;
    }

    if (id == SYS_PTRACE || id == SYS_PRCTL || id == SYS_TGKILL || id == SYS_KILL ||
    id == SYS_CLONE || id == SYS_CLONE3)
    return emit_args_event(ctx, (u32)id, ctx->args[0], ctx->args[1], ctx->args[2], ctx->args[3]);

    return 0;
    }
  5. 然后内核执行打开文件流程,中间会调用 do_filp_open,我们用 kprobe 挂了

    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
    SEC("kprobe/do_filp_open")
    int kprobe_do_filp_open(struct pt_regs *ctx)
    {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 tgid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u32 uid = (u32)bpf_get_current_uid_gid();
    void *pathname = (void *)ARM64_PARM2(ctx);
    const char *name = 0;
    struct pending_open_t p = {};
    struct pending_open_t *old;

    if (!target_allowed(tgid, uid))
    return 0;

    old = bpf_map_lookup_elem(&pending_open_map, &tid);
    if (old) {
    p.syscall_id = old->syscall_id;
    p.flags = old->flags;
    p.mode = old->mode;
    } else {
    p.syscall_id = SYS_OPENAT;
    }

    if (bpf_probe_read_kernel(&name, sizeof(name), pathname) != 0 || !name)
    return 0;

    p.path_ptr = (u64)name;
    p.path_len = read_kernel_string(p.path, sizeof(p.path), name);
    bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
    return 0;
    }

    这里的作用是补路径。因为我手机上 raw_syscalls/sys_enter 里读用户态 path 不稳定,所以我在 do_filp_open 里从内核侧拿已经解析过的 filename,再更新 pending_open_map。

  6. 最后 syscall 返回,触发 raw_syscalls/sys_exit:

    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
    SEC("tracepoint/raw_syscalls/sys_exit")
    int trace_sys_exit(struct raw_sys_exit_ctx *ctx)
    {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 tgid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u32 uid = (u32)bpf_get_current_uid_gid();
    long id = ctx->id;

    if (!target_allowed(tgid, uid))
    return 0;

    if (id == SYS_OPENAT || id == SYS_OPENAT2) {
    struct pending_open_t *p = bpf_map_lookup_elem(&pending_open_map, &tid);
    event_t *e;

    if (!p)
    return 0;

    e = new_event(EVENT_OPEN, (u32)id);
    if (!e)
    goto out_open;

    e->fd = ctx->ret >= 0 ? (s32)ctx->ret : -1;
    e->ret = ctx->ret;
    e->flags = p->flags;
    e->mode = p->mode;
    e->arg1 = p->path_ptr;
    e->count = p->path_len;
    __builtin_memcpy(e->path, p->path, sizeof(e->path));
    submit_event(ctx, e);

    out_open:
    bpf_map_delete_elem(&pending_open_map, &tid);
    return 0;
    }

    return 0;
    }
  7. 用户态 Go 一直阻塞读取这个 perf event:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    for {
    record, err := reader.Read()
    if err != nil {
    if errors.Is(err, perf.ErrClosed) {
    return
    }
    log.Printf("read perf event: %v", err)
    continue
    }
    if record.LostSamples > 0 {
    log.Printf("lost perf samples: %d", record.LostSamples)
    continue
    }

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

    handleEvent(e, fdPaths)
    }

monitor.bpf.c:跑在内核里的 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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//go:build ignore

#include "common.h"

struct user_pt_regs {
u64 regs[31];
u64 sp;
u64 pc;
u64 pstate;
};

struct pt_regs;
#define ARM64_PARM2(x) (((const volatile struct user_pt_regs *)(x))->regs[1])

#define TASK_COMM_LEN 16
#define MAX_PATH_LEN 256
#define MAX_SAMPLE_LEN 160
#define INVALID_TARGET 0xffffffffU

#define SYS_FACCESSAT 48
#define SYS_OPENAT 56
#define SYS_CLOSE 57
#define SYS_GETDENTS64 61
#define SYS_READ 63
#define SYS_READLINKAT 78
#define SYS_NEWFSTATAT 79
#define SYS_KILL 129
#define SYS_TGKILL 131
#define SYS_PTRACE 117
#define SYS_PRCTL 167
#define SYS_CLONE 220
#define SYS_CLONE3 435
#define SYS_OPENAT2 437
#define SYS_FACCESSAT2 439

enum event_type {
EVENT_OPEN = 1,
EVENT_READ = 2,
EVENT_CLOSE = 3,
EVENT_PATH = 4,
EVENT_ARGS = 5,
EVENT_GETDENTS = 6,
};

typedef struct {
u32 target_uid;
u32 target_pid;
} config_t;

typedef struct {
u64 ts;
u32 type;
u32 syscall_id;
u32 tgid;
u32 tid;
u32 uid;
s32 fd;
s64 ret;
u64 arg0;
u64 arg1;
u64 arg2;
u64 arg3;
u32 flags;
u32 mode;
u32 count;
u32 sample_len;
char comm[TASK_COMM_LEN];
char path[MAX_PATH_LEN];
char sample[MAX_SAMPLE_LEN];
} event_t;

const config_t *unused_config_t __attribute__((unused));
const event_t *unused_event_t __attribute__((unused));

struct pending_open_t {
u32 syscall_id;
u32 flags;
u32 mode;
s32 path_len;
u64 path_ptr;
char path[MAX_PATH_LEN];
};

struct pending_read_t {
s32 fd;
u32 count;
u64 buf;
};

struct raw_sys_enter_ctx {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
long id;
unsigned long args[6];
};

struct raw_sys_exit_ctx {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
long id;
long ret;
};

char __license[] SEC("license") = "Dual MIT/GPL";

struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, config_t);
} config_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, u32);
__type(value, struct pending_open_t);
} pending_open_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, u32);
__type(value, struct pending_read_t);
} pending_read_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__type(value, event_t);
} events SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, event_t);
} event_heap SEC(".maps");

static __always_inline int target_allowed(u32 tgid, u32 uid)
{
u32 zero = 0;
config_t *cfg = bpf_map_lookup_elem(&config_map, &zero);

if (!cfg)
return 1;
if (cfg->target_uid != INVALID_TARGET && cfg->target_uid != uid)
return 0;
if (cfg->target_pid != INVALID_TARGET && cfg->target_pid != tgid)
return 0;
return 1;
}

static __always_inline void fill_common(event_t *e, u32 type, u32 syscall_id)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();

e->ts = bpf_ktime_get_ns();
e->type = type;
e->syscall_id = syscall_id;
e->tgid = pid_tgid >> 32;
e->tid = (u32)pid_tgid;
e->uid = (u32)uid_gid;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
}

static __always_inline event_t *new_event(u32 type, u32 syscall_id)
{
u32 zero = 0;
event_t *e = bpf_map_lookup_elem(&event_heap, &zero);

if (!e)
return 0;

__builtin_memset(e, 0, sizeof(*e));
fill_common(e, type, syscall_id);
return e;
}

static __always_inline void submit_event(void *ctx, event_t *e)
{
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, e, sizeof(*e));
}

static __always_inline long read_user_string(char *dst, u32 size, const char *src)
{
long ret;

ret = bpf_probe_read_user_str(dst, size, src);
if (ret < 0)
ret = bpf_probe_read_str(dst, size, src);
return ret;
}

static __always_inline long read_kernel_string(char *dst, u32 size, const char *src)
{
return bpf_probe_read_kernel_str(dst, size, src);
}

static __always_inline long read_user_bytes(void *dst, u32 size, const void *src)
{
long ret;

ret = bpf_probe_read_user(dst, size, src);
if (ret < 0)
ret = bpf_probe_read(dst, size, src);
return ret;
}

static __always_inline int emit_path_event(void *ctx, u32 syscall_id, const char *path, u64 arg0, u64 arg2)
{
event_t *e = new_event(EVENT_PATH, syscall_id);
if (!e)
return 0;

e->arg0 = arg0;
e->arg2 = arg2;
e->count = read_user_string(e->path, sizeof(e->path), path);
submit_event(ctx, e);
return 0;
}

static __always_inline int emit_args_event(void *ctx, u32 syscall_id, u64 arg0, u64 arg1, u64 arg2, u64 arg3)
{
event_t *e = new_event(EVENT_ARGS, syscall_id);
if (!e)
return 0;

e->arg0 = arg0;
e->arg1 = arg1;
e->arg2 = arg2;
e->arg3 = arg3;
submit_event(ctx, e);
return 0;
}

SEC("tracepoint/raw_syscalls/sys_enter")
int trace_sys_enter(struct raw_sys_enter_ctx *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 tgid = pid_tgid >> 32;
u32 tid = (u32)pid_tgid;
u32 uid = (u32)bpf_get_current_uid_gid();
long id = ctx->id;

if (!target_allowed(tgid, uid))
return 0;

if (id == SYS_OPENAT || id == SYS_OPENAT2) {
struct pending_open_t p = {};
const char *path = (const char *)ctx->args[1];

p.syscall_id = (u32)id;
p.path_ptr = (u64)path;
if (id == SYS_OPENAT) {
p.flags = (u32)ctx->args[2];
p.mode = (u32)ctx->args[3];
}
p.path_len = read_user_string(p.path, sizeof(p.path), path);
bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
return 0;
}

if (id == SYS_READ) {
struct pending_read_t r = {};

r.fd = (s32)ctx->args[0];
r.buf = ctx->args[1];
r.count = (u32)ctx->args[2];
bpf_map_update_elem(&pending_read_map, &tid, &r, BPF_ANY);
return 0;
}

if (id == SYS_CLOSE) {
event_t *e = new_event(EVENT_CLOSE, (u32)id);
if (!e)
return 0;
e->fd = (s32)ctx->args[0];
submit_event(ctx, e);
return 0;
}

if (id == SYS_READLINKAT || id == SYS_FACCESSAT || id == SYS_FACCESSAT2 || id == SYS_NEWFSTATAT)
return emit_path_event(ctx, (u32)id, (const char *)ctx->args[1], ctx->args[0], ctx->args[2]);

if (id == SYS_GETDENTS64) {
event_t *e = new_event(EVENT_GETDENTS, (u32)id);
if (!e)
return 0;
e->fd = (s32)ctx->args[0];
e->count = (u32)ctx->args[2];
submit_event(ctx, e);
return 0;
}

if (id == SYS_PTRACE || id == SYS_PRCTL || id == SYS_TGKILL || id == SYS_KILL ||
id == SYS_CLONE || id == SYS_CLONE3) {
if (id == SYS_PRCTL && ctx->args[0] != 4 && ctx->args[0] != 22 &&
ctx->args[0] != 38 && ctx->args[0] != 39)
return 0;
return emit_args_event(ctx, (u32)id, ctx->args[0], ctx->args[1], ctx->args[2], ctx->args[3]);
}

return 0;
}

SEC("kprobe/do_filp_open")
int kprobe_do_filp_open(struct pt_regs *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 tgid = pid_tgid >> 32;
u32 tid = (u32)pid_tgid;
u32 uid = (u32)bpf_get_current_uid_gid();
void *pathname = (void *)ARM64_PARM2(ctx);
const char *name = 0;
struct pending_open_t p = {};
struct pending_open_t *old;

if (!target_allowed(tgid, uid))
return 0;

old = bpf_map_lookup_elem(&pending_open_map, &tid);
if (old) {
p.syscall_id = old->syscall_id;
p.flags = old->flags;
p.mode = old->mode;
} else {
p.syscall_id = SYS_OPENAT;
}

if (bpf_probe_read_kernel(&name, sizeof(name), pathname) != 0 || !name)
return 0;

p.path_ptr = (u64)name;
p.path_len = read_kernel_string(p.path, sizeof(p.path), name);
bpf_map_update_elem(&pending_open_map, &tid, &p, BPF_ANY);
return 0;
}

SEC("tracepoint/raw_syscalls/sys_exit")
int trace_sys_exit(struct raw_sys_exit_ctx *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 tgid = pid_tgid >> 32;
u32 tid = (u32)pid_tgid;
u32 uid = (u32)bpf_get_current_uid_gid();
long id = ctx->id;

if (!target_allowed(tgid, uid))
return 0;

if (id == SYS_OPENAT || id == SYS_OPENAT2) {
struct pending_open_t *p = bpf_map_lookup_elem(&pending_open_map, &tid);
event_t *e;

if (!p)
return 0;

e = new_event(EVENT_OPEN, (u32)id);
if (!e)
goto out_open;

e->fd = ctx->ret >= 0 ? (s32)ctx->ret : -1;
e->ret = ctx->ret;
e->flags = p->flags;
e->mode = p->mode;
e->arg1 = p->path_ptr;
e->count = p->path_len;
__builtin_memcpy(e->path, p->path, sizeof(e->path));
submit_event(ctx, e);

out_open:
bpf_map_delete_elem(&pending_open_map, &tid);
return 0;
}

if (id == SYS_READ) {
struct pending_read_t *r = bpf_map_lookup_elem(&pending_read_map, &tid);
event_t *e;

if (!r)
return 0;

e = new_event(EVENT_READ, (u32)id);
if (!e)
goto out_read;

e->fd = r->fd;
e->count = r->count;
e->ret = ctx->ret;

if (ctx->ret > 0) {
u32 n = (u32)ctx->ret;
long sample_ret;

if (n > MAX_SAMPLE_LEN)
n = MAX_SAMPLE_LEN;
e->sample_len = n;
sample_ret = read_user_bytes(e->sample, n, (void *)r->buf);
e->arg1 = sample_ret;
if (sample_ret < 0)
e->sample_len = 0;
}

submit_event(ctx, e);

out_read:
bpf_map_delete_elem(&pending_read_map, &tid);
return 0;
}

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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
package main

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

"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
)

const invalidTarget = ^uint32(0)

const (
eventOpen = 1
eventClose = 3
eventPath = 4
eventArgs = 5
eventGetdents = 6
)

const (
sysFaccessat = 48
sysOpenat = 56
sysClose = 57
sysGetdents64 = 61
sysReadlinkat = 78
sysNewfstatat = 79
sysKill = 129
sysTgkill = 131
sysPtrace = 117
sysPrctl = 167
sysClone = 220
sysClone3 = 435
sysOpenat2 = 437
sysFaccessat2 = 439
)

type fdKey struct {
tgid uint32
fd int32
}

func main() {
var uidFlag uint
var pidFlag uint

flag.UintVar(&uidFlag, "uid", uint(invalidTarget), "target Android app UID; default traces all UIDs")
flag.UintVar(&pidFlag, "pid", uint(invalidTarget), "target process PID/TGID; default traces all PIDs")
flag.Parse()

if err := rlimit.RemoveMemlock(); err != nil {
log.Printf("warning: remove memlock rlimit failed: %v", err)
}

var objs bpfObjects
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("load bpf objects: %v", err)
}
defer objs.Close()

cfg := bpfConfigT{
TargetUid: uint32(uidFlag),
TargetPid: uint32(pidFlag),
}
var zero uint32
if err := objs.ConfigMap.Update(zero, cfg, 0); err != nil {
log.Fatalf("update config map: %v", err)
}

enter, err := link.Tracepoint("raw_syscalls", "sys_enter", objs.TraceSysEnter, nil)
if err != nil {
log.Fatalf("attach raw_syscalls/sys_enter: %v", err)
}
defer enter.Close()

exit, err := link.Tracepoint("raw_syscalls", "sys_exit", objs.TraceSysExit, nil)
if err != nil {
log.Fatalf("attach raw_syscalls/sys_exit: %v", err)
}
defer exit.Close()

openPath, err := link.Kprobe("do_filp_open", objs.KprobeDoFilpOpen, nil)
if err != nil {
log.Printf("warning: attach kprobe/do_filp_open failed: %v; open paths may be empty", err)
} else {
defer openPath.Close()
}

reader, err := perf.NewReader(objs.Events, os.Getpagesize()*16)
if err != nil {
log.Fatalf("open perf reader: %v", err)
}
defer reader.Close()

sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
reader.Close()
}()

log.Printf("monitoring non-read syscalls uid=%s pid=%s", targetString(uint32(uidFlag)), targetString(uint32(pidFlag)))

fdPaths := make(map[fdKey]string)
for {
record, err := reader.Read()
if err != nil {
if errors.Is(err, perf.ErrClosed) {
return
}
log.Printf("read perf event: %v", err)
continue
}
if record.LostSamples > 0 {
log.Printf("lost perf samples: %d", record.LostSamples)
continue
}

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

handleEvent(e, fdPaths)
}
}

func handleEvent(e bpfEventT, fdPaths map[fdKey]string) {
comm := cString(e.Comm[:])
path := cString(e.Path[:])
key := fdKey{tgid: e.Tgid, fd: e.Fd}

switch e.Type {
case eventOpen:
if e.Ret >= 0 {
fdPaths[key] = path
}
fmt.Printf("%s %-10s pid=%d tid=%d uid=%d comm=%s fd=%d ret=%d path_len=%d path_ptr=%#x path=%s\n",
ts(e.Ts), syscallName(e.SyscallId), e.Tgid, e.Tid, e.Uid, comm, e.Fd, e.Ret, int32(e.Count), e.Arg1, path)
case eventClose:
delete(fdPaths, key)
case eventPath:
fmt.Printf("%s %-10s pid=%d tid=%d uid=%d comm=%s path=%s arg0=%#x arg2=%#x\n",
ts(e.Ts), syscallName(e.SyscallId), e.Tgid, e.Tid, e.Uid, comm, path, e.Arg0, e.Arg2)
case eventGetdents:
getdentsPath := fdPaths[key]
if getdentsPath == "" {
getdentsPath = "?"
}
fmt.Printf("%s getdents64 pid=%d tid=%d uid=%d comm=%s fd=%d path=%s count=%d\n",
ts(e.Ts), e.Tgid, e.Tid, e.Uid, comm, e.Fd, getdentsPath, e.Count)
case eventArgs:
fmt.Printf("%s %-10s pid=%d tid=%d uid=%d comm=%s arg0=%#x arg1=%#x arg2=%#x arg3=%#x\n",
ts(e.Ts), syscallName(e.SyscallId), e.Tgid, e.Tid, e.Uid, comm, e.Arg0, e.Arg1, e.Arg2, e.Arg3)
}
}

func targetString(v uint32) string {
if v == invalidTarget {
return "all"
}
return fmt.Sprint(v)
}

func cString(buf []int8) string {
out := make([]byte, 0, len(buf))
for _, c := range buf {
if c == 0 {
break
}
out = append(out, byte(c))
}
return string(out)
}

func ts(ns uint64) string {
return fmt.Sprintf("%12.6f", float64(ns)/1e9)
}

func syscallName(id uint32) string {
switch id {
case sysFaccessat:
return "faccessat"
case sysOpenat:
return "openat"
case sysClose:
return "close"
case sysGetdents64:
return "getdents64"
case sysReadlinkat:
return "readlinkat"
case sysNewfstatat:
return "newfstatat"
case sysPtrace:
return "ptrace"
case sysKill:
return "kill"
case sysTgkill:
return "tgkill"
case sysPrctl:
return "prctl"
case sysClone:
return "clone"
case sysClone3:
return "clone3"
case sysOpenat2:
return "openat2"
case sysFaccessat2:
return "faccessat2"
default:
return fmt.Sprintf("sys_%d", id)
}
}

编译,makefile

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
EBPF_VERSION := v0.21.0
EBPF_HEADERS := $(shell go env GOMODCACHE)/github.com/cilium/ebpf@$(EBPF_VERSION)/examples/headers
BPF2GO := bpf2go
CLANG := clang
LLVM_STRIP := llvm-strip

.PHONY: all generate build android push clean

all: generate build

generate:
go mod download
GOPACKAGE=main BPF2GO_CC=$(CLANG) BPF2GO_STRIP=$(LLVM_STRIP) \
$(BPF2GO) -go-package main -target bpfel \
-type config_t -type event_t \
bpf monitor.bpf.c -- -I$(EBPF_HEADERS) -O2 -g

build:
go build -trimpath -o anti-debug-monitor .

android:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o anti-debug-monitor-android-arm64 .

push: android
adb push anti-debug-monitor-android-arm64 /data/local/tmp/anti-debug-monitor
adb shell su -c 'chmod 755 /data/local/tmp/anti-debug-monitor'

clean:
rm -f anti-debug-monitor anti-debug-monitor-android-arm64 bpf_bpfel.go bpf_bpfel.o

make generate

make android

1
adb shell su -c '/data/local/tmp/anti-debug-monitor -uid 10299'

image-20260602190154521

eBPFDexDumper浅析

LLeavesG/eBPFDexDumper: eBPF-Based DexDumper for Android

libart.so是 Android Runtime(ART)的核心 native 动态库,可以理解为 Android Java/Kotlin 程序运行时的虚拟机核心实现

它不是一个普通 App 的库,而是 Android 系统里负责执行 .dex 字节码、管理 Java 对象、GC、JNI、类加载等功能的底层运行时库。

Android App 通常不是直接运行 Java/Kotlin 源码,而是:

1
2
3
4
5
6
7
Java/Kotlin 源码
↓ 编译
.class 字节码
↓ 转换
.dex 字节码

由 ART 运行

而eBPFDexDumper 的核心原理一句话就是用 eBPF 的 uprobe 挂 ART 虚拟机 libart.so 里的关键函数,在 App 执行/验证 DEX 时,从 ART 内部结构里找到内存中的 DexFile 起始地址和大小,再让用户态把这段内存 dump 出来。

它和我们现在写的 syscall monitor 不一样,我们现在的工具tracepoint/kprobe -> 看 syscall 行为,比如 openat、ptrace、tgkill

eBPFDexDumper:uprobe -> 挂用户态 libart.so 函数,看 ART 执行 DEX 的过程

大致流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
App 加载/解密 DEX

ART 开始解释执行或验证类

libart.so 的 Execute / ExecuteNterpImpl / VerifyClass 被调用

eBPF uprobe 被触发

从参数里拿 ArtMethod 或 DexFile 指针

顺着 ART 内部结构找到 DexFile->begin 和 size

把 begin/size/pid 发给 Go 用户态

Go 用户态读取目标进程内存

保存成 dex_xxx.dex

关键点是它不是去扫描 /proc/self/maps 找 DEX,而是等 ART 真正用到 DEX 的时候,从 ART 自己的对象里拿。

一些简单壳可以去/proc/self/maps找 DEX,可以通过 /proc//maps 找到可读内存段,然后再读 /proc//mem,在里面搜索:

1
2
3
dex\n035
dex\n037
cdex

但问题是:不稳定。原因是 DEX 可能在这些地方

1
2
3
4
5
/data/app/.../base.apk 里的 mmap
匿名 mmap
ashmem/memfd
Java heap/native heap
oat/vdex/cdex 相关区域

比如文章里说它会追踪几个关键函数:

1
2
3
art::interpreter::Execute
ExecuteNterpImpl
art::verifier::ClassVerifier::VerifyClass

原因是 Android ART 执行字节码时,可能走普通解释器,也可能走 Nterp 快速解释器;动态加载的 DEX 还可能在类验证阶段暴露出来。作者博客也明确说,设计上就是用 eBPF 的 uprobe 追踪 libart.so 三个关键函数,再从参数中提取 ArtMethod,进一步找到 DexFile 的起始地址和 size。

如果某个 Java 方法没有被编译成机器码,ART 可能用解释器执行它。类似:ART 拿着 DEX 里的 bytecode 一条条解释执行

这个路径会走类似art::interpreter::Execute

所以 hook 这里,可以抓到正在执行的 ArtMethod,再从 ArtMethod 找到它属于哪个 DexFile。

Nterp 快速解释器,Nterp 可以理解成 ART 的一种更快的解释器路径。

它仍然是在执行 DEX bytecode,但不是走普通 Execute 那条路,而是走另一个入口,比如ExecuteNterpImpl

DEX 里的类在真正使用前,ART 会验证它是否合法,比如方法、字段、类型引用是否正常。

类验证阶段

这个阶段可能走art::verifier::ClassVerifier::VerifyClass

重点是:类验证时,DEX 还没一定执行,但 ART 已经拿到了 DexFile 对象。

hook VerifyClass 的好处是,即使某个类还没真正执行,只要它被加载并验证,也可能提前拿到 DexFile 地址

所以 eBPFDexDumper 会挂多个点:

1
2
3
普通解释器 Execute
Nterp ExecuteNterpImpl
类验证 VerifyClass

目的就是尽量覆盖:

1
2
3
DEX 被执行时
DEX 被快速解释器执行时
DEX 被加载验证时

它是在内核侧挂 uprobe。目标 App 调用 libart.so 函数时,内核触发 eBPF 程序,eBPF 读取目标进程用户态内存。

libart.so 是用户态库。这里挂uprobe

为什么 App 调用 libart.so 函数时,内核会触发 eBPF?因为 uprobe 是内核实现的。

大概过程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户态注册 uprobe:我要监控 libart.so 某个地址

内核在那个用户态指令地址上设置探针

App 执行到这个 libart.so 地址

CPU 触发异常/断点,陷入内核

内核运行挂在这个 uprobe 上的 eBPF 程序

eBPF 读寄存器/参数/用户态内存

恢复 App 继续执行

但是它也有局限, 它是被动脱壳,另外它通常要删 OAT,/data/app/…/oat/

OAT 是 Android ART 的优化/编译产物。正常 APK 里有classes.dex

ART 为了加速运行,可能会把 DEX 编译或优化成:

1
2
/data/app/.../oat/arm64/base.odex
/data/app/.../oat/arm64/base.vdex

简单理解:

1
2
3
DEX  -> Java bytecode
OAT/ODEX -> ART 编译/优化后的产物,里面可能有机器码和优化信息
VDEX -> verified dex / 优化后的 dex 相关数据

为什么删 OAT?因为如果 OAT/ODEX/VDEX 已经存在,ART 可能直接走优化后的执行路径:

1
2
3
直接跑编译后的机器码
或者少走解释器
或者少走类验证

那hook 的这些点就可能触发得少,甚至抓不到某些 DEX。删掉/data/app/…/oat/,是为了逼 ART 重新走加载、验证、解释/JIT 等流程,让 DEX 更容易在 ART 对象里暴露出来。