BPF允许任何人在Linux内核中执行任意代码,这初听起来是个可怕的想法。如果没有BPF验证器,在生产系统中运行BPF程序会有很高的风险。引用内核网络维护人员Dave S.Miller的话:“eBPF验证器是eBPF程序与毁灭的深渊之间的分水岭。”
显然,BPF验证器也是运行在系统上的程序,所以,BPF验证器也必须经过严格审查,以确保它可以正确执行它的工作。在过去的几年中,安全研究人员在验证器中发现了一些漏洞,例如,允许攻击者甚至以非特权的用户身份访问内核的随机内存。你可以在CVE(Common Vulnerabilities and Exposures)目录中阅读这类漏洞的更多信息。这个已知的安全漏洞列表是由美国国土安全部资助的。例如,CVE-2017-16995描述了用户如何绕过BPF验证器来读写内核内存。
本节将介绍验证器为了阻止BPF程序发生上述的问题而采取的措施。
验证器执行的第一项检查是对BPF虚拟机加载的代码进行静态分析。第一项检查的目的是确保程序能够按照预期结束。验证器代码将创建有向无环图(DAG)。验证器分析的每条指令将成为图中的一个节点,每个节点链接到下一条指令。验证器生成此图后,执行深度优先搜索(DFS),以确保程序执行完成且代码不包括危险路径。这意味着验证器将遍历图的每个分支,一直到分支的底部,确保没有递归循环。
下面是验证器在第一项检查时所做的工作:
·程序不包含控制循环。为确保程序不会陷入无限循环,验证器将拒绝任何类型的控制循环。曾有人提出允许在BPF程序中循环的提议,但截至本文撰写时,该提议没有被采用。
·程序不会执行超过内核允许的最大指令数。目前,执行的最大指令数是4096。此限制是为了防止BPF程序一直运行。在第3章中,我们将讨论如何嵌套不同的BPF程序以安全的方式解决该限制。
·程序不包含任何无法到达的指令,例如,未执行过的条件或功能。这样可以防止BPF虚拟机加载无效代码,这也会延迟BPF程序的终止。
·程序不会超出程序界限。
验证器执行的第二项检查是对BPF程序执行预运行。这意味着验证器尝试分析程序执行的每条指令,确保不会执行无效指令。同时也会检查所有内存指针是否可以正确访问和解引用。最后,预运行会将程序中控制流的执行结果告诉验证器,确保无论程序采用哪个控制路径,都会到达BPF_EXIT指令。为此,验证器会跟踪程序栈中所有访问的分支路径,并在采用新路径之前对其进行评估,确保特定路径不会被多次访问。经过这两项检查后,验证器认为程序可以安全执行。
如果你有兴趣查看程序如何被分析,则可以使用bpf系统调用调试验证器的检查。使用该系统调用加载程序时,你可以设置一些属性让验证器打印操作日志:
union bpf_attr attr = { .prog_type = type, .insns = ptr_to_u64(insns), .insn_cnt = insn_cnt, .license = ptr_to_u64(license), .log_buf = ptr_to_u64(bpf_log_buf), .log_size = LOG_BUF_SIZE, .log_level = 1, }; bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
log_level字段设置验证器是否打印日志。如果设置为1,则打印日志;如果设置为0,则不会打印任何内容。如果验证器要打印日志,还需要设置日志缓冲区及大小。日志缓冲区是多行字符串,你可以打印该字符串以检查验证器做出的决定。
当你在内核中运行任意程序时,BPF验证器在确保系统安全和可用性上起着重要作用,尽管有时候BPF验证器做出的一些决定很难理解。如果遇到加载BPF程序验证有问题,不要失去信心。在本书后面,我们将通过一些安全示例指导你理解BPF验证器,这些示例也将帮助你了解如何以安全的方式编写BPF程序。
接下来,我们将介绍BPF如何在内存中构造程序信息。这些信息有助于了解和访问BPF内部,帮助你调试以及理解程序的行为。