验证器
验证器是 BPF 子系统的核心组件。它的主要职责是确保 BPF 计划“安全”执行。它通过根据一组规则检查程序来实现这一点。验证器还执行一些额外的任务,主要是使用在验证过程中收集的信息进行优化。
验证器之所以存在,是因为 BPF 程序被转换为本机机器代码并以内核模式执行。这意味着如果 BPF 程序没有得到适当的检查,它们可能会对系统造成非常糟糕的事情,例如损坏内存、泄露敏感信息、导致内核崩溃或导致内核挂起/死锁。
此模型是在易用性和性能之间进行权衡。一旦您能够通过验证器,就无需进行昂贵的运行时检查,因此 BPF 程序可以以本机速度运行。带有虚拟机或解释器的替代模型会慢得多。
基本
那么我们一直在谈论的这个“安全”概念是什么呢?总体思路是不允许 BPF 程序以任何方式破坏内核,并且不应违反系统的安全模型。这导致了一长串要避免做的事情。以下是不允许执行的非详尽列表:
- 程序必须始终终止(在合理的时间内):因此没有无限循环或无限递归。
- 不允许程序读取任意内存:如果能够读取任何内存,则允许程序泄露敏感信息。也有例外,跟踪程序可以访问帮助函数,使它们能够以受控方式读取内存。但是这些程序类型需要
root
权限,因此不会带来安全风险。 - 不允许网络程序访问数据包边界之外的内存,因为相邻内存可能包含敏感信息。
- 程序不允许死锁,因此必须释放任何持有的旋转锁,并且一次只能持有一个锁,以避免多个程序出现死锁。
- 不允许程序读取未初始化的内存,因为这可能会泄露敏感信息。
这样的例子不胜枚举。很多规则是有条件的,每种程序类型都有额外的规则。并非所有程序类型都可以使用相同的帮助函数或访问相同的上下文字段。有关不同程序类型和帮助函数的页面中更详细地讨论了这些限制。
分析
基本前提是验证器以数学方式检查程序的每个可能的排列。它首先遍历代码并根据分支指令构建一个图。它将拒绝任何静态死代码,无法访问的代码或可能是漏洞链中的一环。
接下来,验证器从顶部开始,设置初始寄存器。例如,R1 几乎总是指向上下文的指针。它会遍历每条指令并更新寄存器和堆栈的状态。此状态包含类似 smax32
(此寄存器中可能存在的最大 32 位有符号整数)等信息。它有许多这样的变量,可以用来评估一个分支,如 “if R1 > 123
” 是否总是被采用、有时被采用或从未被采用。
每次验证器遇到分支指令时,它都会分叉当前状态,将其中一个分支+状态排队以供以后调查和更新状态。
例如,如果我有一个寄存器 R3,其值在 10 和 30 之间,然后我遇到 “
if R3 > 20
” 指令,则一个分叉的 R3 为10-20
,另一个分叉的 R3 为21-30
。这是一个非常简单的例子,但它说明了这一点。
它还跟踪链接的寄存器。如果我选择 R2 = R3,那么执行上述示例,验证器知道 R2 也与 R3 具有相同的范围。这通常用于数据包边界检查。
在我提到指向上下文的指针之前,验证器还会跟踪数据类型。例如,它还知道我们何时处理普通数字或指向映射值的指针。例如,每次取消引用上下文的偏移量时,它都会检查是否允许访问当前程序类型,以及偏移量是否在上下文的边界内。它还可以跟踪可能的 null
值,例如从地映射查找返回的值。并使用该信息强制执行 null
检查,然后再取消引用指针。
它使用相同类型的信息跟踪来断言已将正确的参数传递给帮助程序函数或函数调用。验证器还可以使用 BTF 来强制映射值包含计时器字段(例如)或旋转锁。BTF 还用于强制将正确的参数传递给 KFuncs
,确保 BTF 函数定义与实际的 BPF 函数匹配,并且这些 BTF 函数定义与回调匹配。
验证器将尝试评估所有排队的状态和分支。但为了保护自己,它是有限制的。它跟踪检查的指令量,这是针对任何排列的,因此程序的复杂性不仅取决于指令的数量,还取决于分支的数量。验证器的状态存储量有限,因此无限递归不会消耗太多内存。
注意:直到 5.2 版本,有硬性 4k 指令限制和 128k 复杂度限制。之后,两者都是100万。
特性
尾调用
尾部调用允许一个 BPF 程序调用另一个 BPF 程序,goto程序,而不是函数调用。这些程序是单独加载和验证的,因此不计入验证器的复杂度限制。因此,尾部调用是一种流行的方法,通过将程序的逻辑拆分为多个程序来绕过验证器复杂性限制。
有关详细信息,请查看尾调用页面。
消除死代码
在 4.15 版本开始首次出现了与死代码消除相关的代码,从那时起,任何动态死代码(可通过条件语句访问,但条件始终为真或始终为假)都被 NOP
指令替换。这还不能消除死代码,但使其无害(我们不希望不检查 JIT 代码,即使我们自己从未跳到它)。
从 5.1 版本开始添加了死代码消除功能。
- 第一步是将有条件的分支指令转换为无条件的跳转指令,以避免误预测惩罚。
- 第二步是实际删除死代码。这需要重新计算相对跳跃并调整 BTF 线路信息。
- 第三步是删除带有空体的条件跳跃,因为它们不做任何事情。
5.1 版本中的死代码消除仅对特权程序发生。由于这是一个优化步骤,因此它不是严格要求的,任何错误都可能导致安全问题,因此通过不在特权程序上执行它,我们可以避免潜在的权限提升。
有界循环
在引入有界循环之前,验证器会拒绝任何包含循环的程序。长期以来的解决方法是在编译器中展开循环。这不是一个很好的解决方案,因为它增加了程序的大小,并且并不总是可以展开循环。
有界循环允许验证器检查循环是否始终终止。缺点是,要这样做,验证器将检查循环的每个排列。因此,如果你有一个循环,最多 100 次,有 20 条指令和几个分支,那么这个循环就等于几千条指令,达到复杂度极限。
有关在 BPF 中执行循环的更多详细信息,请参阅循环页面。
逐个函数验证
在此功能之前,每个 BPF 到 BPF 函数都必须是 static
的。静态函数是从调用者的角度进行验证的。每次程序调用函数时,该函数中的验证都会继续进行,并保留参数的状态,以证明每个调用站点的调用都是安全的。这意味着验证器可能需要多次验证某些函数,这很慢并且会增加复杂性。
此功能允许您使用全局函数(没有 static
关键字的函数)。这些限制略有不同。验证器将不假定有关参数的信息,并将单独验证函数。这意味着无论调用多少次,验证器只需要验证一次函数。这要快得多,并降低了复杂性。
此外,全局函数可以被 freplace
程序替换,因为这些函数在其签名之外存在假设。
回调
bpf_for_each_map_elem
帮助函数还介绍了回调的概念。这允许用户声明一个静态函数,该函数不是由 BPF 程序直接调用的,而是作为函数指针传递给要调用的帮助器。
在以后的版本中,此机制也用于计时器、bpf_find_vma
和循环。