背景

业界对Android的性能监控、hook、系统分析、抓包、逆向等领域的关注和投入在不断提升,作为Linux Kernel中新兴的优美的一套技术框架,bpf逐渐在Android中被用于监控、分析、优化和逆向,产出了众多的工具。此外,Google原生系统的上层服务(如网络流量统计、网络黑白名单)也转向使用ebpf来实现。

bpf可以理解成Kernel的虚拟机字节码,它借助C语言编译成特定的ELF,通过专用的系统调用注册到Kernel,挂载到指定的tracepoint,进行代码流程的hook,与App的插桩、性能优化的检查点非常类似。

本文以Android源码AOSP为基础,讲解一个bpf程序应该如何被编译、集成到系统、在系统中启动/运行,并使用C语言编写了一个可以实际运行的bpf程序(https://github.com/NasdaqGodzilla/cpuprobe)。

编译

常规的C语言bpf程序需要编译为Kernel BPF子系统能够识别的专用字节码,Android采用了一套独特的编译系统(Android.bp)来包装包含bpf程序在内的编译流程。

一个最简单的bpf编译描述如下:

1
2
3
4
5
6
7
8
bpf {
name: "cpu_stats.o",
srcs: ["cpu_stats.c"],
cflags: [
"-Wall",
"-Werror",
],
}

这段写在Android.bp内的bpf代码声明了需要编译一个名称为cpu_stats.o的bpf程序,srcs指定了其源文件,cflags指定了向编译器传递的编译参数。

前面说过,一个bpf程序,从结构上说是一个elf,从逻辑上说是一个被Kernel BPF虚拟机加载后才能执行的字节码(类似JS解释器加载JS),因此它编译期间不会发生链接,不会产生可执行bin文件,也不能直接执行。

通过AOSP的模块编译指令即可编译:

1
m cpu_stats.o

集成

常规的bpf程序有多种集成方式,包括调用bpf()、通过python、bpftrace等(本质也是bpf())调用。但出于安全和效率的原因,Android屏蔽了bpf(),仅提供了bpf loader在开机阶段加载预置的bpf程序,在开机后,系统就不能再加载任何bpf程序了。

bpf loader会加载存放在/system/etc/bpf目录下的所有bpf程序,因此将我们编译出来的bpf程序放入该路径(或通过Android.bp、Android.mk等预置)即可让系统自动为我们加载我们的bpf程序:

1
2
adb push cpu_stats.o /system/etc/bpf
adb push cpu_stats_client /system/bin/

这里我们还push了一个cpu_stats_client,稍后解释它的作用。
push后,重启设备,让Android自动加载这个bpf程序,加载成功的话日志大概长这样:

1
2
3
4
5
6
7
8
9
LibBpfLoader: Loading ELF object /system/etc/bpf/cpu_stats.o with license GPL
LibBpfLoader: Loaded code section 3 (tracepoint_sched_sched_switch)
LibBpfLoader: Loaded relo section 3 (.reltracepoint/sched/sched_switch)
LibBpfLoader: Adding section 3 to cs list
LibBpfLoader: bpf_create_map name cpu_stats_map, ret: 27
LibBpfLoader: map_fd found at 0 is 27 in /system/etc/bpf/cpu_stats.o
LibBpfLoader: applying relo to instruction at byte offset: 144, insn offset 18 , insn 118
LibBpfLoader: New bpf core prog_load for /system/etc/bpf/cpu_stats.o (tracepoint_sched_sched_switch) returned: 28
bpfloader: Attempted load object: /system/etc/bpf/cpu_stats.o, ret: Success

启动

系统启动时,会自动把上述目录的bpf程序交给内核进行加载。内核为了安全,会进行字节码分析,检测待加载的bpf程序是否带有Bug如越界、空指针、死循环等,以此来排除崩溃风险和恶意攻击。如果程序没有什么问题,就会成功加载。

加载成功的一个标志是,bpf程序定义的map(bpf的数据结构,稍后解释)会出现在指定目录下:

1
2
local@device:/ # ls /sys/fs/bpf/map_cpu_stats_cpu_stats_map
/sys/fs/bpf/map_cpu_stats_cpu_stats_map

这个map由bpf程序在代码里面通过函数定义得到,当bpf程序hook命中时,Kernel会回调bpf程序,bpf程序通常将其业务逻辑中产生的需要存储的数据保存在这个map里面。这个map的两大主要作用,一是让bpf程序能够保存数据(因为bpf程序即不能使用内核的栈、堆,也不能使用被hook命中的应用程序的栈、堆),二是这个map可以让应用程序读取到,是一种bpf程序向应用程序进行通信的方式,bpf程序可以往这个map写东西,向用户态应用程序通信。

程序解释

从bpf程序本身的结构来看,bpf程序主要有两大部分组成:1. 指定的hook点以及这个hook点的处理函数,用于hook;2. 定义的bpf map,用于存储数据。

执行hook并保存数据

  1. 通过DEFINE_BPF_MAP来创建bpf map
  2. 通过SEC()来指定hook点,当kernel对应事件发生时,会回调SEC指定的函数
  3. SEC指定的回调函数内执行hook逻辑,可以向bpf map读写数据,用于存储数据,或用于向用户态应用程序传递信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
DEFINE_BPF_MAP(cpu_stats_map, LRU_HASH, CPU_STATS_BPFMAP_KEY, CPU_STATS_BPFMAP_VALUE, CPU_STATS_MAXENTRIES);

// DEFINE_BPF_PROG("tracepoint/sched/sched_switch", AID_ROOT, AID_NET_ADMIN, cpu_stats_tp_sched_switch) (struct switch_args* args) {
SEC("tracepoint/sched/sched_switch")
int cpu_stats_tp_sched_switch(struct switch_args* args) {
CPU_STATS_BPFMAP_KEY ktime_ns = bpf_ktime_get_ns();
CPU_STATS_BPFMAP_VALUE record = {
.ktime_ns = ktime_ns,
.smp_id = bpf_get_smp_processor_id(),
.uid_gid = bpf_get_current_uid_gid(),
.pid_tgid = bpf_get_current_pid_tgid(),
};
memset(record.comm, '\0', sizeof(record.comm));
memcpy(record.comm, args->next_comm, sizeof(record.comm));

bpf_cpu_stats_map_update_elem(&ktime_ns, &record, BPF_ANY);

return 0;
}

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

读取数据

前面“集成”小节里面示例命令有将一个cpu_stats_client push到/system/bin,实际上它就是用来读取数据的工具。

  1. 通过bpf_obj_get()来取得bpf程序和bpf map的句柄(不是使用常规的fd open)
  2. 通过android::bpf::BpfMap来遍历bpf map里面的所有数据(Key-Value对)

可以访问文章开头的Github链接查看完整代码

总结

bpf提供了强大的功能,凭借数量众多的tracepoint和kprobe,绝大多数位置都能提供hook,在不修改内核的情况下能实现非常精彩的业务逻辑,在Android上必然能发挥重要作用。