这篇文章上次修改于 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月

直到后面陆续有更多的新文章和相关项目出现,我又感觉行了,有关文章:

在断断续续的了解和学习后,进行了一些实践尝试

  1. android平台eBPF初探

在这篇文章中,在Android上实践eBPF是在AOSP环境下编译的,并且需要借助系统的bpfloader加载

对此我进行了简单的尝试,参见eBPF on Android之TRACEPOINT Hello World

  • 我使用Magisk将编译好的eBPF程序挂载到/system/etc/bpf
  • 单独编写用户态程序去读取结果

在这一次实践中,我遇到了API变化的坑,BpfMap的构造函数发生了变化

  1. Linux内核监控在Android攻防中的应用

在这篇文章中,我了解到其实是可以直接通过命令直接实现一些简单的trace

我的尝试是eBPF on Android之trace系统调用,这一次我非常直观体验到eBPF的强大,输出了详细的syscall调用信息,虽然没有具体的值

另外很快尝试了下uprobe,参见eBPF on Android之UPROBE Hello World,得到了一些输出

  • 测试目标是可执行程序
  • 手动通过命令开启uprobe监控

似乎...没感觉到具体用处?

  1. 在Android中使用eBPF:开篇

不过这时正好遇上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

  1. eCapture的几个好消息,支持Android…

eCapture在之前也有说耳闻了,但是其仍然需要在较高版本的内核上使用,不过这时已经用上了Pixel 6

于是上手测试了一下,效果还是相当不错的,eBPF on Android之编译ecapture

在测试和阅读eCapture代码的过程中,逐渐了解到更多的知识,比如编译eCapture需要有内核头文件,bcc编译的时候也需要

于是我想了很多办法,折腾了一些事情,比如:

  • 如何在不重新编译内核的情况下,在eadb环境中编译出bcc?

    • 我下载了手机的内核源码,根据编译报错,挨着挨着补充头文件,没想到真的成了
    • 在后续的测试中发现kernel_headers.py有梳理头文件的过程,但看得不太明白,遂放弃...
    • 后面再次阅读了脚本逻辑,发现有部分头文件是kernel_headers.py不会产生的,会在编译之后才有,所以放弃得对

之前并没有深究为何需要,使用之后这些头文件又在何处发挥作用,直到...

  1. 定制bcc/ebpf在android平台上实现基于dwarf的用户态栈回溯

这篇文章出来之后第一时间进行了阅读,OS:实在是太精彩了!文章中对bcc更底层的内容进行了分析梳理,并给出了实现方案和具体的效果

另外文章中提到的simpleperf让我眼前一亮,因为即使是4.1x系列内核也能使用,而且这效果看起来真不错,测试效果如图:

不过这时逼近国庆,没有立刻具体复现尝试,在国庆后反复阅读,最终复现出文章中的效果,于是便有了eBPF on Android之实现基于dwarf的用户态栈回溯,以及具体的实现

  1. stackplz 基于eBPF的堆栈追踪工具

是时候出一份力了!

纵观之前的尝试,无不是在走前人走过的路,我也做点什么吧~

个人认为目前阻碍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。

让我稍微解释一下这个图:

  1. Application是你编写的可执行程序
  2. Application里面携带有BPF Prog,也就是eBPF程序
  3. Application(通过系统调用)附加到内核,并告知内核加载eBPF程序
  4. eBPF程序经过内核的验证器检查后进行JIT编译和优化,然后在内核中执行
  5. Application(通过系统调用)创建Map
  6. 程序执行到某处,姑且称为hook点,而eBPF程序对此处hook,那么接着会进入eBPF的hook代码;此时你可以在此处进行信息收集,数据处理,然后将数据更新到Map中

  7. 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才是要求不高又有体验感的呢?先看个对比

序号编译环境加载器前端设备编程语言
1AOSP构建环境bpfloader用户态程序内核4.x起C
2真机 + eadbbccbcc内核头文件 + 内核4.x起python + C
3x86的cuttlefish模拟器 + eadbbccbccLinux跑起来就行python + C
4eadb或者直接在宿主机跑?bccbccarm64服务器 + ReDroidpython + C
5arm64环境 + cilium/ebpf程序本身程序本身内核头文件 + 内核4.x起golang + C
6arm64 avd + eadbbccbccMAC with M1python + C
7eadbbccbcc红米note 10pro kvm + arm64 linux + ReDroidpython + 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用户,赢!
  • 方案七,这是我得知天玑1100+可以搞kvm后yy出来的,不必当真哈哈哈

这些方案或多或少存在一些限制,比如:

  • M1太贵
  • ReDroid要arm64的服务器
  • eCapture方案仍然需要内核头文件
  • x86 cuttlefish不适合实际生产

那么stackplz的答案是?

stackplz的解决方案

最终stackplz的解决方案是:

  • libbpf + aosp混合头文件
  • 基于cilium/ebpfehids/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 \

也就是说,我们只需要把下面三个的头文件按特定结构放一起就可以了

文件夹结构:

  • 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文件内容,和设定几个选项即可

以uprobe hook为例,先做hook点信息准备

  • uprobe/stack 对应c代码中的SEC("uprobe/stack")
  • probe_stack 对应c代码中的SEC后紧跟着的函数的函数名
  • AttachToFuncName 要hook的符号名
  • BinaryPath 完整可执行文件路径或者库文件路径
  • UprobeOffsetehids/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