eBPF 学习及简单应用
eBPF 学习及简单应用
【第拾壹期 REVERSE 分享会】驱动逆向 & Ebpf & 某实战逆向 & Angr & 自定义ROM & Frida_哔哩哔哩_bilibili
学习分享会的相关知识
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 检测可能就是调 libc 的 open,去扫 /system/xbin/su、/system/bin/su、/sbin/su,或读一些系统属性、mount 信息这时如果你用 Frida、inline hook、PLT hook,去拦open,fopen就能把结果改掉。
后来检测方发现这太好骗,于是开始不走公开 API,不走容易被 hook 的导出函数,做内联 syscall stub,自己拼寄存器参数,直接 svc #0 发系统调用,甚至不复用统一封装,而是在不同代码块里分散实现
在 ARM/ARM64 上,
svc是 Supervisor Call 指令。它的作用就是从用户态陷入内核,请求内核帮你做事。类比x86上的syscall,sysenter,更早的int 0x80
在这种场景下,ebpf 成为了一个完美的方案,ebpf 在所有 syscall 的入口点 sys_enter 默认就有一个监测点,可以监视绝大多数常用 syscall 触发时的参数和上下文(寄存器,pc,sp,任意读内存),甚至可以在触发的时候发送信号,更关键的是在监视系统调用这一场景下,ebpf 对用户态完全无痕,因为所有的数据采集完全在内核中
eBPF 组成结构
由于其在内核中运行的特殊性, ebpf 软件实际上可以认为由两部分组成,分别是用户态和内核态程序
用户程序负责通过加载器将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 | spec, _ := ebpf.LoadCollectionSpec("xxx.o") |
以 eBPFDexDumper 为例:
1 | bpf.c |
eBPF 特性
主要参考知乎的那篇文章,文章链接放在顶部了
Hook Overview
eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。
tracepoints可以理解为 Linux 内核源码中提前埋好的观测点。当内核运行到这些固定位置时,会触发一个事件。eBPF 程序可以挂到这些事件上,于是每当事件发生,eBPF 程序就会被执行。
如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。
后面出现了类似的情况
kprobe、uprobe和tracepoint都是 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_HASHBPF_MAP_TYPE_ARRAYBPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAYBPF_MAP_TYPE_LRU_HASHBPF_MAP_TYPE_LRU_PERCPU_HASHBPF_MAP_TYPE_LPM_TRIE
以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不同的后端,这些后端各有不同的语义和性能特点。随着多 CPU 架构的成熟发展,BPF Map 也引入了 per-cpu 类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的 BPF Map 时,每个 CPU 都会存储并看到它自己的 Map 数据,从属于不同 CPU 之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的 BPF 程序主要是在做收集时间序列型数据,如流量数据或指标等。
当前内核中的 非通用 map 有:
BPF_MAP_TYPE_PROG_ARRAY:一个数组 map,用于 hold 其他的 BPF 程序BPF_MAP_TYPE_PERF_EVENT_ARRAYBPF_MAP_TYPE_CGROUP_ARRAY:用于检查 skb 中的 cgroup2 成员信息BPF_MAP_TYPE_STACK_TRACE:用于存储栈跟踪的 MAPBPF_MAP_TYPE_ARRAY_OF_MAPS:持有其他 map 的指针,这样整个 map 就可以在运行时实现原子替换BPF_MAP_TYPE_HASH_OF_MAPS:持有其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
Helper Calls
eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functions。Helper functions 使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加。当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加。
比如它不能直接:
1 | 随便读写内核内存 |
否则会带来两个问题:
1 | 1. 安全问题:可能导致内核崩溃或越权访问 |
所以内核提供了 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 | read(37, buf, 4096); |
核心目标是监控目标 App 有没有访问反调试相关接口和路径,比如 /proc/self/maps、/proc/self/status
1 | 整体流程 |
monitor.bpf.c这里定义了 Android arm64 的 syscall 编号:
1 | SYS_OPENAT 56 |
还定义了 config_t 和 event_t:
1 | config_t -> 用户态传进来的过滤条件,比如 target_uid / target_pid |
这里定义 eBPF maps:
1 | config_map 保存 uid/pid 过滤条件 |
1 | struct { |
为什么要 pending_open_map?因为 openat 分两步:
1 | sys_enter_openat 能看到参数,比如 path |
所以需要先暂存,等 exit 时合并成path=/proc/self/maps fd=76 ret=76
raw_syscalls/sys_enter:
1 | SEC("tracepoint/raw_syscalls/sys_enter") |
负责在 syscall 进入时记录参数:
1 | openat/openat2 -> 暂存 path/flags/mode |
1 | SEC("kprobe/do_filp_open") |
我的Android 内核没有普通 syscall tracepoint,而且在 raw_syscalls/sys_enter 里读用户态字符串会失败,所以我们额外挂了内核函数 do_filp_open,从内核侧拿已经解析好的文件路径。所以路径是靠这里补出来的
1 | do_filp_open -> /proc/self/maps |
内核有 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 | SEC("tracepoint/raw_syscalls/sys_exit") |
它负责拿返回值:
1 | openat exit -> ret 就是 fd |
所以我们能得到:
1 | openat path=/proc/self/maps fd=76 |
kprobe/do_filp_open,它的用途就是在内核已经拿到路径之后,偷看一下路径内容。
完整串起来是这样
bpf2go 先把 monitor.bpf.c:1 编译成 bpf_bpfel.o,然后嵌进 bpf_bpfel.go:188。所以 Go 程序运行时已经带着 eBPF 字节码。
用户态 Go 调用 loadBpfObjects(&objs, nil) 加载 eBPF ,
1
2
3
4var 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
用户态把过滤条件写进内核 map
1
2
3
4cfg := bpfConfigT{
TargetUid: uint32(uidFlag),
TargetPid: uint32(pidFlag),
}这一步之后,不是 Go 主动调用 trace_sys_enter。而是目标 App 调 syscall 时,内核自动执行对应 eBPF 函数。
1
2
3link.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
81SEC("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;
}然后内核执行打开文件流程,中间会调用 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
32SEC("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。
最后 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
39SEC("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;
}用户态 Go 一直阻塞读取这个 perf event:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22for {
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 | //go:build ignore |
用户态代码:
1 | package main |
编译,makefile
1 | EBPF_VERSION := v0.21.0 |
make generate
make android
1 | adb shell su -c '/data/local/tmp/anti-debug-monitor -uid 10299' |
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 | Java/Kotlin 源码 |
而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 | App 加载/解密 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 | art::interpreter::Execute |
原因是 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 | 普通解释器 Execute |
目的就是尽量覆盖:
1 | 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 对象里暴露出来。





