最近为ceccomp创建了组织, 并且将原仓库迁移到了该组织🎉. https://github.com/ceccomp/ceccomp
前言
最近在完善ceccomp准备推出的新功能 capture. capture分为两种模式, global和pid, 两种模式的原理都是在内核加载ebpf程序, 从而更加轻松的获取到用户态无法获取的信息, 更加灵活自由的获取到filter信息.
本文的pid实际上指thread id. 这是ceccomp命名上的历史遗留问题, 最开始命名trace的pid模式我们没有关注pid/tid区分, 但后来注意到
trace pid包括capture pid的精度实际上一直是在tid. 所以我们决定就统一叫做pid好了, 如kthread这种习惯叫法我们则保留
ebpf介绍
事先声明一下本文对于ebpf描述未必准确, 大多是个人当前浅薄理解. 只是介绍capture功能前简单科普一下, 顺便供自己参考和回顾, 后续有新的理解也可能会继续更新.
ebpf基本性质
ebpf可以挂在内核的许多位置. 如某个内核函数的入口/出口处. 所以ebpf的挂载和触发是内核全局的. 而bpf只会在加载了该bpf的进程触发syscall的入口处被调用, 其影响范围仅限于加载了该bpf的进程本体
ebpf仍然是一种低级, 接近于汇编的虚拟机语言, 但许多语言都有工具对其提供封装. 例如想用c写ebpf可以使用libbpf, 使得我们并不需要真正去写裸的ebpf指令. 而bpf由于使用范围比较局限, 几乎没有工具对其提供封装. 我所知的只有ceccomp和seccomp-tools.
ebpf限制
由于ebpf直接挂在内核上运行, 内核对其有非常严格的安全或性能检查.
申请
ringbuf之后强制要求所有分支都要submit或discard. 以防在内核中造成内存泄漏甚至是安全隐患.ebpf中还不允许存在无限循环的可能性, 因为ebpf代码可能被整个系统上不同进程调用内核时不停的调用, 内核认为无限循环的ebpf代码会影响机器工作效率, 甚至导致内核出现死循环.
ebpf中读取结构体的方式也比较复杂, 不仅要像内核那样区分用户态/内核态读写内存, 还要使用一系列
bpf_core_readapi来以内核允许的方式安全读取结构体成员. 下面是一些例子
1 | BPF_CORE_READ_INTO (&filter, task, seccomp.filter); |
bpf_core_read系列api实际上有两个作用, 1是防止ebpf代码越界对内核内存进行读写, 2是方便libbpf对其进行core处理. 如你所见该api会获取你想要的成员名, 在libbpf加载这些代码时会根据内核BTF(BTF我简单将其理解为调试信息类似物, 用来根据指定成员名获取到该成员在结构体内部的偏移)将当前内核的正确偏移写入ebpf待重定位的代码
总是看到有人拿javascript之于浏览器类比ebpf之于内核, 个人认为还挺恰当的. 相同之处在于设定好极高的安全限制, 再允许外部代码在高权限环境下运行, 不同的是js更加好写, 而ebpf更加高效. 安全-高效-好写 这何尝不是编程语言的不可能三角呢?
ebpf加载过程(libbpf为例)
clang -target bpf编译.bpf.c代码, 生成.bpf.o文件- libbpf提供
app_bpf__open_and_load函数, 加载ebpf时会申请内核map. 例如.bss、.data、.rodata被创建为不同的内核map, libbpf在这之后会将mapfd写入待重定位的段引用 - libbpf会根据
/sys/kernel/btf/vmlinux提供的内核结构体偏移信息做CORE(compile once, run everywhere), .bpf.o会记录读取的结构体成员, 之后libbpf会写入待重定位的结构体访问偏移 - 提交代码段, libbpf将翻译后的ebpf代码通过bpf系统调用提交, 内核对其进行验证并加载或拒绝
这部分描述的比较简单, 实际情况比这复杂多了, 但作为初步学习和使用ebpf的人, 了解到这一步也够用了
ringbuf
目前ebpf主要和用户态进行通信的方式是ringbuf. 可以理解为用户态程序和内核的ebpf有一块共享内存. ebpf可以申请ringbuf, 在写入数据后将其提交. 用户态程序使用ring_buffer__poll可以收到提交事件, 并且用指定的回调函数来处理该事件.
下面是一些伪代码助于理解.
1 | // ebpf |
capture功能
global模式
这个模式将一个ebpf程序挂载在内核加载seccomp的入口点(seccomp_check_filter)和出口点(do_seccomp), 在当前机器上进行全局(global)的监测seccomp加载.
程序会在入口点获取到用户态传入的filter(准确来说这时候已经是被复制到内核态的filter了), 但是这时无法判断该filter是否被内核成功加载, 所以先按照当前进程的pid存入ebpf下的hash_map. 而在出口点根据pid索引拿到同样的filter, 并且根据do_seccomp的返回值来确认filter是否加载成功. 如果加载成功, 就将其通过ringbuf发送到用户态
pid模式
设计思路
设计全局模式的时候, 我们发现从内核进程task结构体可以获取到seccomp_filter这个结构体, 而seccomp_filter是一个链表节点, 本身指向struct bpf_prog *prog, prev指向seccomp_filter *prev, 在struct bpf_prog *prog中可以获取到该进程每次加载的bpf filter. 于是我们就有了个想法, 是不是只要获取了某个进程的task结构体, 就可以dump出该进程历史加载的所有bpf filter, pid模式应运而生
后来我们发现结构体会将cbpf转换ebpf进行保存(取决于内核配置和版本, 但现代内核通常统一将cbpf转换为ebpf进行保存和执行), 这促使着我们开发了对ebpf的disasm功能
1 | struct seccomp_filter { |
接下来我们查询到了bpf_task_from_pid这个api, 可以获取到指定pid的task结构体, 那么整条线就串起来了. 读取结构体的思路如下, 理论上这样可以dump到任意进程的所有加载过的filter结构体(但是ebpf不允许任何理论上可能无限循环, 所以这里只能加上一个32的人为截断)
1 | struct task_struct *task = bpf_task_from_pid (target_pid); |
pid模式的一些缺憾
bpf_task_from_pid这个api虽然好用, 但直到6.2才被引入(我和另一位主要开发者🚀都是使用archlinux, 但我们肯定也希望该功能能被更多人使用). 除开版本之外, bpf_task_from_pid该api的pid必须是在initns中的pid, 也就是在容器等环境中可能会出现非预期的工作表现.
bpf_task_from_vpid的pid则是current_ns中的, 但该api就得到6.13版本了, 所以我们暂时放弃使用该api