这篇文章上次修改于 671 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
前言
本文将介绍stackplz从0到1的实践过程
主要目的是为大家提供一个可复现的案例,降低eBPF在Android实践eBPF的门槛
包含下面几个内容:
我与eBPF的碎碎念- eBPF基本认识
- eBPF on Android低门槛实践方案,以及现有可选路线
- 记录eBPF on Android需要避开的坑
你可以学习到一种门槛最低的、可落地实施的、可直接在Android上实际运行eBPF的解决方案
其实simpleperf就是在Android上实际运行eBPF实际案例,只是太复杂了
stackplz简介
如果你在此前已经体验了stackplz,那么应该能感觉到一定的便利性
对了,还没介绍stackplz,stackplz是在结合eCapture和定制bcc/ebpf在android平台上实现基于dwarf的用户态栈回溯的产物,但是单文件
主要作用就是对用户态程序/库进行hook,可以获取进程信息、堆栈信息和hook处的寄存器信息,效果图如下
当然也是支持批量hook的~
stackplz开发缘由
最早了解到eBPF是在去年7月份,当时无意间发现了一篇新鲜出炉的文章
原文对这项技术的说明让我非常兴奋,比如:
- 跨内核适用
- 工具现成
- 对应用程序超强监控
- 完全不care反调试
These features make eBPF a great candidate to develop a future generation of Android dynamic instrumentation workflow/tools that can:
- Be ported across multiple kernel versions (as long as the kernel has the required eBPF feature set, see Challenge 1 below)
- Access existing tools in bcc/bpftrace
- Trace system wide activities or app behaviors on multiple processes at the same time
- Hide from user space which makes eBPF a great tool to detect anti-debugging checks. For instance, apps can’t query a list of attached kprobes due to Android’s UID-based application sandbox.
在反复阅读,查找资料和尝试复现之后,我暂时放弃了;是的上面提到的特性是有的,只是没有在真机上合适的方案,要体验上文章的内容还得是模拟器,而我对内核自然是一无所知的
另外还有一个重要原因是:手机一经出厂就不会升级内核,后续更新只是打补丁,所以没有便宜且合适的真机
当时市面上设备有最新内核的应该是Pixel 5,内核是4.19,Pixel 6的发布时间是21年10月
直到后面陆续有更多的新文章和相关项目出现,我又感觉行了,有关文章:
- 2021/01/08 android平台eBPF初探
- 2021/11/26 理解Android eBPF
- 2022/01/03 Linux内核监控在Android攻防中的应用
- 2022/06/12 在Android中使用eBPF:开篇
- 2022/06/15 为Android平台编译eBPF程序
- 2022/06/16 在Android中使用eBPF:环境搭建
- 2022/06/22 当Xiaomi 12遇到eBPF
- 2022/06/22 eCapture的几个好消息,支持Android…
- 2022/09/26 定制bcc/ebpf在android平台上实现基于dwarf的用户态栈回溯
在断断续续的了解和学习后,进行了一些实践尝试
在这篇文章中,在Android上实践eBPF是在AOSP环境下编译的,并且需要借助系统的bpfloader加载
对此我进行了简单的尝试,参见eBPF on Android之TRACEPOINT Hello World
- 我使用Magisk将编译好的eBPF程序挂载到
/system/etc/bpf
- 单独编写用户态程序去读取结果
在这一次实践中,我遇到了API变化的坑,BpfMap
的构造函数发生了变化
在这篇文章中,我了解到其实是可以直接通过命令直接实现一些简单的trace
我的尝试是eBPF on Android之trace系统调用,这一次我非常直观体验到eBPF的强大,输出了详细的syscall调用信息,虽然没有具体的值
另外很快尝试了下uprobe,参见eBPF on Android之UPROBE Hello World,得到了一些输出
- 测试目标是可执行程序
- 手动通过命令开启uprobe监控
似乎...没感觉到具体用处?
不过这时正好遇上eadb发布,weishu在直播中演示了bcc的使用;心里想:eBPF本该如此
我决心体验一下,于是便有了eBPF on Android之bcc编译与体验,这一回我对eBPF有了进一步的理解,不过碍于内核限制,也就是体验了一把
这个时候bcc的堆栈打印还是不明所以...
PS,环境准备可参考eBPF on Android之bcc环境准备——eadb简版
当时已经了解到低版本内核上用bcc功能不全是因为一些内核函数的缺失导致,甚至根本就没有
很快啊,我开始搜寻了资料和bcc的issue,尝试给内核添加bpf_probe_read_user
,参见eBPF on Android之打补丁和编译内核修正版
PS,原版的教程步骤不够合理,可能会导致一些问题,所以后面发布了修正版
在加上bpf_probe_read_user
补丁之后,我成功测试了bcc的sslsniff.py
脚本(旧版文章),得到了不错的效果
以及写出了自己的第一个bcc demo,eBPF on Android之hook libc.so open
eCapture
在之前也有说耳闻了,但是其仍然需要在较高版本的内核上使用,不过这时已经用上了Pixel 6
于是上手测试了一下,效果还是相当不错的,eBPF on Android之编译ecapture
在测试和阅读eCapture代码的过程中,逐渐了解到更多的知识,比如编译eCapture需要有内核头文件,bcc编译的时候也需要
于是我想了很多办法,折腾了一些事情,比如:
如何在不重新编译内核的情况下,在eadb环境中编译出bcc?
- 我下载了手机的内核源码,根据编译报错,挨着挨着补充头文件,没想到真的成了
- 在后续的测试中发现
kernel_headers.py
有梳理头文件的过程,但看得不太明白,遂放弃... - 后面再次阅读了脚本逻辑,发现有部分头文件是
kernel_headers.py
不会产生的,会在编译之后才有,所以放弃得对
之前并没有深究为何需要,使用之后这些头文件又在何处发挥作用,直到...
这篇文章出来之后第一时间进行了阅读,OS:实在是太精彩了!文章中对bcc更底层的内容进行了分析梳理,并给出了实现方案和具体的效果
另外文章中提到的simpleperf
让我眼前一亮,因为即使是4.1x系列内核也能使用,而且这效果看起来真不错,测试效果如图:
不过这时逼近国庆,没有立刻具体复现尝试,在国庆后反复阅读,最终复现出文章中的效果,于是便有了eBPF on Android之实现基于dwarf的用户态栈回溯,以及具体的实现
- bcc补丁
- 解析堆栈的守护进程
是时候出一份力了!
纵观之前的尝试,无不是在走前人走过的路,我也做点什么吧~
个人认为目前阻碍eBPF在Android上更进一步的是缺少一个从开发到落地的简单且低门槛的方案
在回顾温习之前的各种尝试之后,终于有一个简单且低门槛的方案了,于是有了stackplz这个项目
eBPF基本认识
首先对eBPF有个大概认识,这里直接引用他人的原话,摘自eBPF介绍
eBPF是extend BPF的简称,扩展的BPF。我们刚了解BPF了,都知道BPF的功能比较单一只能够作用于网路的数据包的过滤上,但是扩展后的BPF的功能得到了很大的丰富,可以这样说基本上可以使用在Linux各个子系统中。除了功能上的扩展,BPF程序的指令集也变得相当复杂了,所以就出现了专门用于编译BPF程序的clang/llvm编译。在框架上BPF的框架也发生了变化,所以扩展后的BPF不再是早期的BPF的可以比拟的。因而,早期的BPF被称为cBPF,扩展后的BPF被称为eBPF。
让我稍微解释一下这个图:
Application
是你编写的可执行程序Application
里面携带有BPF Prog
,也就是eBPF程序Application
(通过系统调用)附加到内核,并告知内核加载eBPF程序- eBPF程序经过内核的验证器检查后进行JIT编译和优化,然后在内核中执行
Application
(通过系统调用)创建Map程序执行到某处,姑且称为hook点,而eBPF程序对此处hook,那么接着会进入eBPF的hook代码;此时你可以在此处进行信息收集,数据处理,然后将数据更新到Map中
- 这些hook点可以是uprobe/kprobe/tracepoint...
- 更详细说明请阅读Linux内核监控在Android攻防中的应用
- 还有
fentry/fexit
等等...
Application
(通过系统调用)读取Map中的数据,并进行展示
更详细的解释可以阅读eBPF 概述:第 3 部分:软件开发生态
这里引用我想说的重点部分,这有助于大家理解eBPF是怎么在运行,数据又是从何而来:
- 后端:这是在内核中加载和运行的 eBPF 字节码。它将数据写入内核 map 和环形缓冲区的数据结构中。
- 加载器:它将字节码后端加载到内核中。通常情况下,当加载器进程终止时,字节码会被内核自动卸载。
- 前端:从数据结构中读取数据(由后端写入)并将其显示给用户。
- 数据结构:这些是后端和前端之间的通信手段。它们是由内核管理的 map 和环形缓冲区,可以通过文件描述符访问,并需要在后端被加载之前创建。它们会持续存在,直到没有更多的后端或前端进行读写操作。
上面图里的Application
可以认为包含了加载器
和前端
而在eBPF on Android之TRACEPOINT Hello World这篇文章的尝试中,加载器
实际上是系统的bpfloader
,而前端则是我编写的用户态程序
也就是说加载器
和前端
是可以分离的
但是像eCapture
这样的程序,你会发现它是单个可执行文件,即同时有加载器
和前端
而bcc
也是类似的,它同时有加载器
和前端
,编写hook代码的时候只需要很少的工作
eBPF在Android上的实践方案总结
先看下安卓的内核发展情况,我的文章通常提及内核版本说明的时候,只是指代图中的这几个版本,不包括其他版本。另外eBPF是4.9引入安卓的,所以4.4也不包含
说了那么多,那么在Android上怎么玩eBPF才是要求不高又有体验感的呢?先看个对比
序号 | 编译环境 | 加载器 | 前端 | 设备 | 编程语言 |
---|---|---|---|---|---|
1 | AOSP构建环境 | bpfloader | 用户态程序 | 内核4.x起 | C |
2 | 真机 + eadb | bcc | bcc | 内核头文件 + 内核4.x起 | python + C |
3 | x86的cuttlefish模拟器 + eadb | bcc | bcc | Linux跑起来就行 | python + C |
4 | eadb或者直接在宿主机跑? | bcc | bcc | arm64服务器 + ReDroid | python + C |
5 | arm64环境 + cilium/ebpf | 程序本身 | 程序本身 | 内核头文件 + 内核4.x起 | golang + C |
6 | arm64 avd + eadb | bcc | bcc | MAC with M1 | python + C |
7 | eadb | bcc | bcc | 红米note 10pro kvm + arm64 linux + ReDroid | python + C |
个人点评,针对安卓现状:
方案一,复杂笨重,光是AOSP...
- 适合本身做安卓内核/framework开发的人,毕竟有编程实力,不差配置,但是也只有手机厂商用的上了
方案二,头文件包需要重新编译内核获取,5.4内核之前缺少bpf_probe_read_user
- 如果是使用Pixel 6那就非常推荐,比较直接就是5.10内核,该有的都有
- 如果是4.x系列的内核,那只能简单玩玩,比如stackplz,要更好的体验得添加
bpf_probe_read_user
补丁,但是自己补丁并不一定准确
方案三,可以用上最新内核,但是就是卡卡卡,而且很多软件在模拟器上水土不服,因为没有x86架构的so
- 这正是文章开头提到的文章的实践路线,拿来学习体验还是可以的
方案四,经hluwa测试可行,毕竟ReDroid本身是docker这样子,宿主机的内核特性自然也能享受到了
- 这个方案也不错,就是arm64服务器比较花钱(或者选择白嫖oracle)
方案五,这正是eCapture的路线,不过美中不足的是对内核要求较高
- 如果用Pixel 6那么直接上手即可
方案六,经过M1用户测试,似乎eadb在avd虚拟机中有点网络上的小问题,相信很快会解决
- M1上的avd内核也是很新的,
M1用户,赢!
- M1上的avd内核也是很新的,
方案七,这是我得知天玑1100+可以搞kvm后yy出来的,不必当真哈哈哈
这些方案或多或少存在一些限制,比如:
- M1太贵
- ReDroid要arm64的服务器
- eCapture方案仍然需要内核头文件
- x86 cuttlefish不适合实际生产
那么stackplz的答案是?
stackplz的解决方案
最终stackplz的解决方案是:
- libbpf + aosp混合头文件
- 基于
cilium/ebpf
和ehids/ebpfmanager
编写加载器
和前端
- NDK交叉编译go代码,生成可执行程序
- cgo调用预编译库解析堆栈信息(没有堆栈需求实际上不需要这部分)
Q: 混合头文件有什么弊端吗?
A: 我认为没有,因为libbpf
本身就是各种eBPF程序编译时的通用方案,安卓也是引入了这个库作为外部库的
头文件问题
在为Android平台编译eBPF程序 这篇帖子中,贴主将AOSP中编译bpf程序的关键提取了出来
如果阅读了原帖就会发现,里面用到的头文件都是aosp的,并且可以简化到三个部分:
- platform/bionic
- platform/system/core
- platform/system/bpf
更重要的是,我们可以把这些代码单独下载,然后使用NDK编译
然而我在测试的时候发现platform/system/bpf
没有起到特别重要的作用
而且本身提供的辅助函数太少,也就是bpf_helpers.h
,而且在不同版本之间名字有变动(参数类型倒是稳定的)
在尝试和eCapture的kern下的头文件结合之后,发现还是有些冲突,最终经过测试,发现按这样的匹配是可以满足编译的
-isystem external/bionic/libc/include \
-isystem external/bionic/libc/kernel/uapi \
-isystem external/bionic/libc/kernel/uapi/asm-arm64 \
-isystem external/bionic/libc/kernel/android/uapi \
-I external/system/core/libcutils/include \
-I external/libbpf/src \
也就是说,我们只需要把下面三个的头文件按特定结构放一起就可以了
- https://android.googlesource.com/platform/bionic
- https://android.googlesource.com/platform/system/core
- https://android.googlesource.com/platform/external/libbpf
文件夹结构:
- bionic/libc
- system/core
- libbpf
Q: 那为什么eCapture这样的项目会需要完整的内核头文件呢?
A: 可能有部分辅助函数会涉及不常见的内核信息
不过就目前简单实现一些eBPF程序,bionic + system/core + external/libbpf
完全可以!
如何编译eBPF程序
还是为Android平台编译eBPF程序 文章的内容
stackplz的Makefile写法如下,几个要点
--target=bpf
这个是用于指定.o文件格式的- 不同于
eCapture
使用llc处理得到.o文件,NDK的clang是没有llc的
- 不同于
-O2
这是按官方AOSP里的来的- 我尝试改成其他值,结果stackplz的二进制替换eBPF程序常量部分就失效了
.PHONY: ebpf
ebpf:
clang \
--target=bpf \
-c \
-nostdlibinc \
-no-canonical-prefixes \
-O2 \
-isystem external/bionic/libc/include \
-isystem external/bionic/libc/kernel/uapi \
-isystem external/bionic/libc/kernel/uapi/asm-arm64 \
-isystem external/bionic/libc/kernel/android/uapi \
-I external/system/core/libcutils/include \
-I external/libbpf/src \
-g \
-MD -MF user/bytecode/stack.d \
-o user/bytecode/stack.o \
src/stack.c
怎么用go加载eBPF程序
ehids/ebpfmanager
已经做好了上层封装,我们只需要提供编译好的.o文件内容,和设定几个选项即可
- https://github.com/SeeFlowerX/stackplz/blob/01050b4aeba62c96863df95c6b7ba84de46d9e09/user/module/probe_stack.go#L49
- https://github.com/SeeFlowerX/stackplz/blob/01050b4aeba62c96863df95c6b7ba84de46d9e09/src/stack.c#L21
以uprobe hook为例,先做hook点信息准备
uprobe/stack
对应c代码中的SEC("uprobe/stack")
probe_stack
对应c代码中的SEC后紧跟着的函数的函数名AttachToFuncName
要hook的符号名BinaryPath
完整可执行文件路径或者库文件路径UprobeOffset
在ehids/ebpfmanager
原版中是以符号为基础的偏移,但是stackplz做了修改,这里是和基于基址的偏移
this.bpfManager = &manager.Manager{
Probes: []*manager.Probe{
{
Section: "uprobe/stack",
EbpfFuncName: "probe_stack",
AttachToFuncName: this.probeConf.Symbol,
BinaryPath: this.probeConf.Library,
UprobeOffset: this.probeConf.Offset,
// 这样每个hook点都使用独立的程序
// UID: util.RandStringBytes(8),
},
},
Maps: []*manager.Map{
{
Name: "stack_events",
},
},
}
然后加载ebpf程序,其中byteBuf
就是ebpf程序的二进制数据
if err = this.bpfManager.InitWithOptions(bytes.NewReader(byteBuf), this.bpfManagerOptions); err != nil {
return fmt.Errorf("couldn't init manager %v", err)
}
// 启动 bpfManager
if err = this.bpfManager.Start(); err != nil {
return fmt.Errorf("couldn't start bootstrap manager %v .", err)
}
怎么读取hook得到的数据
首先在c代码对应函数中,一般通过bpf_perf_event_output
提交数据
然后使用go程序通过系统调用获取对应map的数据,关键函数perf.NewReader
拿到数据之后,我们就可以根据已知的结构信息读取数据,然后进行堆栈信息或者和数据展示/保存
调用预编译库
预编译库源码参见unwinddaemon/lib.cpp,编译环境是Android 13的AOSP
为了解决兼容性问题,我这里尽可能调用预编译出来的库,参见load_so.c
Q: 为啥不执行的时候加个LD_LIBRARY_PATH
呢
A: 这太麻烦了
NDK怎么交叉编译go程序
好说,参见Makefile,一句话的事情:
GOARCH=arm64 GOOS=android CGO_ENABLED=1 CC=aarch64-linux-android29-clang go build -ldflags "-w -s" -o bin/stackplz .
总结
stackplz的大致过程就是这样,由于涉及寄存器数据、栈数据的获取,实际上还修改了cilium/ebpf
部分代码
看起来整体还是有些过于复杂了,完全理解整个过程可能比较困难,这里有一个更简单清晰的案例estrace
简单总结一下:
bionic + system/core + external/libbpf
头文件组合满足绝大部分eBPF编译要求cilium/ebpf + ehids/ebpfmanager
加载eBPF程序、获取hook结果简单又便捷go语言上手快,类库丰富,交叉编译完全ok,还能完美打包出单个可执行文件,免去bcc的复杂环境要求
- 在eadb环境下当然也可以,直接用arm64 clang编译运行,甚至还能配合vscode调试
- 整个项目代码可以在linux下编写并通过NDK交叉编译,摆脱AOSP
只有一条评论 (QwQ)
牛逼,打卡记录一下!