LWN: 努力实现对BPF program签名验证!
关注了就能看到更多这么棒的文章哦~
Toward signed BPF programs
By Jonathan Corbet
April 22, 2021
DeepL assisted translation
https://lwn.net/Articles/853489/
内核的 BPF virtual machine 的用法很多样,可以将 BPF program 加载到内核里从而执行大量(并且不断增加)任务。不断增长的这些 BPF 代码完全可以被当作是内核本身的代码。但是,虽然内核可以检查 loadable module 的签名并阻止加载那些未正确签名的 module,但对于 BPF program 说并没有这样的机制。也就是说任何一个有足够权限的进程都可以加载 BPF program,只要它能通过 BPF verifier 的检查就行。人们可能会想当然地认为给 BPF 添加签名检查是很简单的,但是这个子系统有一些特别的地方,使得事情比人们想象的更有挑战性。不过,有一个可能会成功的解决方案正在酝酿之中,而正是通过加载另一个 BPF program 来实现的。
loadable kernel module 的存储格式都是 ELF 格式。每当一个 module 被加载时,内核会解析这个 ELF 格式的文件,做一些必要工作从而使得该 module 可以在内核中运行起来。这些工作包括为变量分配内存、进行重定位(relocation)、解析符号(resolving symbols)等等。所有这些必要信息都存在于 ELF 文件中。给这个文件加签名,实际上就是对相关的数据计算校验和然后对结果进行签名。
BPF program 也有类似的需求,但其中这些必要信息的组织方式更加复杂,甚至有点混乱。代码本身被编译成了一个可执行程序,然后被链接到一个 loader program 中,这个 loader program 在用户空间中运行,它通过调用 bpf() 系统调用将 BPF program 加载到内存中。但是,BPF program 还包含一些数据区域,用来存放和分配 BPF maps,这些数据也需要 relocation(重定位),从而处理不同系统上的相同数据的不同的排布(layout)。那些必备的 mapping 在 loader program 中被 "声明" 为特殊的 ELF section;libbpf 库会找到这些部分并把它们变成更多的 bpf() 调用。然后,BPF program 本身会在被加载到内核之前先被修改,这样才能确保它在运行起来后能找到相关的 map。
这种结构,给对 BPF program 实现签名验证机制带来了挑战。这些 BPF map 是 BPF program 本身的一部分,如果它们没有按照预期建立起来,BPF program 就可能会出现各种无法预计的错误。但是内核无法强制制定 map configuration,因此它无法确保一个签名过的 BPF program 得到了正确的设置。此外,修改 BPF program 这个需求本身就会破坏签名验证机制,毕竟,对 BPF program 的修改正是签名机制所要防止的行为。因此,内核必须某种程度上来说需要在 BPF program 的加载中做更多的工作才行。
In-kernel BPF loading
我们这些老家伙们会记得,很久以前,内核的 module loader 就是位于 user space 的。在 2.5 开发周期中将其移入内核,当时导致了许多麻烦。20 年过去了,仍然有一些开发者因为那时的经历对 Rusty Russell 怀有芥蒂。但这些问题早已过去,内核内的 loader 也早已不再产生问题了。因此,从逻辑上讲,人们会期望将用户空间的 BPF loader 也移到内核中去,这应该是一个明智的做法。
根据 Alexei Starovoitov 在一组新 patch 的封面邮件中所说,人们已经尝试了多种方法来按照这个方向实现,但是"在尝试了几个月的之后,还是抛弃了这种做法"。显然,有人试图将 libbpf 移到内核中。这个结果也并不完全令人意外,因为这么复杂的代码放在 kernel 里面并不合适。另一个想法是创建一种新的可执行文件格式,其中会包含了一系列设置特定 BPF program 所需的系统调用。
在实施第二种方法时遇到了哪些问题,并没有得到详细说明。但是,在 Starovoitov 的 patch set 中所实现的这个第三种方法,其实可以被认为是这个想法的一个变种。不过,这里的实现并不是让内核去挨个调用一系列的系统调用,而是让 user space 来加载一个特殊的 BPF program 完成这项工作。
具体来说,这组 patch set 创建了另一种类型的 BPF program,它的唯一目标就是为了执行系统调用。这个 BPF program 将在运行它的进程的上下文中执行,并被限制只允许使用一小部分系统调用。在这组 patch set 中,只允许了 bpf()和 close()。人们期望利用这个 BPF program 来执行必要的 bpf()调用,从而 load 并 set up 用户真正想要运行的 BPF program。
这个 "loader program" 通过观察 libbpf 做了哪些工作来加载目标 BPF program,并捕获每个由此产生的 bpf()调用。然后,将这些 bpf() 调用都搜集起来,从而创建出 loader program 来真正重复执行这些动作,这些真正执行的动作会在正确的时间进行,由内核来完成。因此,内核实际上还是通过执行一系列封装好的系统调用来加载目标 program,只是这些动作被放到一个 BPF program 里面了。
之前提到的需要修改 BPF program 来找到正确的 map 的目标,也用通常的方式解决了,那就是增加另一个抽象层。先创建一个数组来放置文件描述符,BPF program 会通过该数组来引用 BPF map。当 BPF program 被加载时,这个数组可以被修改为指向那些真正 map 的文件描述符。
Next steps
正如 Starovoitov 在 patch 邮件中所指出的,这组 patch 并不是问题的最终完整解决方案,它只是第一步而已,给大家指出解决这个问题的方向。还缺失的一大块就是 BPF program 所需进行的 relocation 动作,实现了这个之后才能得以不依赖于编译配置而总能访问到相关的 structure field。这些 relocation 动作也需要修改 BPF program 的 text 段,所以相应的解决方案可能也会比较麻烦。可能需要更多的间接方式,从而带来更多性能损失,不过对于一些用户来说,他们可能愿意承受这些损失。
当然,还有一个小问题,那就是对 BPF program 进行签名以及检查,这个问题在这组 patch set 中也没有得到解决。简单提到一个想法是构造一个流程让 BPF 程序被打包到一个内核模块之内,如果确实是这样实现了,那么现有的对 module 签名的全套流程就也可以直接用于 BPF program 了。
乍一看,BPF loader 是一个挺复杂的解决方案。但值得注意的是,这种机制与用户空间中所做的事情相差不大。在用户空间中,运行一个程序通常需要启动 ld.so 来准备好各种部件,之后才能运行该程序。因此,这类解决方案有很好的先例。不过这种设计能否进入内核 mainline,还有待观察,毕竟这个方案刚提出来不久,还没有得到多少 review。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~