驾驭eBPF验证器
在我于Trail of Bits实习期间,我原型设计了一个工具链(harness),用于提升eBPF验证器的可测试性,简化eBPF程序的测试流程。我的eBPF工具链在用户空间运行,独立于任何本地运行的内核,从而为跨不同内核版本测试eBPF程序打开了大门。
eBPF使用户能够通过将小型程序加载到操作系统内核中来检测运行中的系统。作为安全措施,内核在加载时“验证”eBPF程序,并拒绝任何被视为不安全的程序。然而,使用eBPF是CI/CD的噩梦,因为无法知道给定的eBPF程序是否会成功加载并通过验证,除非在运行中的内核上进行测试。
我的工具链旨在通过在运行中的内核之外执行eBPF验证器来消除这一噩梦。要使用该工具链,开发者需要调整我基于libbpf的示例程序(hello.bpf.c和hello_loader.c),以使其适应被测试的eBPF程序。我的工具链提供的libbpf版本链接到一个实现实际bpf系统调用的“内核库”,这提供了与运行中内核的隔离。该工具链在内核版本5.18上运行良好,但它仍然是一个概念验证;启用对其他内核版本和额外eBPF程序功能的支持将需要大量工作。
能力越大,责任越大
eBPF是一种日益强大的技术,用于增强系统可观测性、实施安全策略和执行高级网络操作。例如,osquery开源终端代理使用eBPF进行安全监控,使组织能够监控其整个舰队中发生的进程和文件事件。
将eBPF代码注入运行中的内核的能力似乎要么是对内核安全、完整性和可靠性的启示,要么是巨大的风险。但是,将用户提供的代码加载到内核并在那里执行究竟如何安全?这个问题的答案有两个方面。首先,eBPF不是“正常”代码,它不以与正常代码相同的方式执行。其次,eBPF代码通过算法“验证”以确保执行安全。
eBPF不是正常代码
eBPF(扩展伯克利包过滤器)是一个重载术语,既指程序的专用字节码表示,也指运行这些字节码程序的内核内虚拟机。eBPF是经典BPF的扩展,后者功能比eBPF少(例如,两个寄存器而不是十个),使用内核内解释器而不是即时编译器,并且仅专注于网络包过滤。
用户应用程序可以将eBPF代码加载到内核空间并在那里运行,而无需修改内核源代码或加载内核模块。加载的eBPF代码由内核的eBPF验证器检查,该验证器试图证明代码将在不崩溃的情况下终止。
eBPF系统示意图
上图显示了用户空间和内核空间之间通过bpf系统调用发生的一般交互。eBPF程序以eBPF字节码表示,可以通过Clang后端获得。交互开始于用户空间进程执行一系列bpf系统调用中的第一个,用于将eBPF程序加载到内核中。然后内核运行验证器,该验证器强制执行确保eBPF程序有效的约束(稍后详述)。如果验证器批准程序,验证器将完成将其加载到内核的过程,并在触发时运行。然后,该程序将作为套接字过滤器,监听套接字并仅将通过过滤器的信息转发到用户空间。
验证eBPF
eBPF安全的关键是eBPF验证器,它将有效的eBPF程序集限制在那些可以保证不会损害内核或引起其他问题的程序。这意味着eBPF在设计上不是图灵完备的。
随着时间的推移,验证器接受的eBPF程序集已经扩大,但这些程序集的可测试性并未提高。Linux内核文档“BPF设计问答”部分中的以下引述说明了这一点:
[eBPF]验证器正在稳步变得“更智能”。限制正在被移除。知道程序将被验证器接受的唯一方法是尝试加载它。BPF开发过程保证未来内核版本将接受早期版本接受的所有BPF程序。
这种“开发过程”依赖于可以通过kselftest系统运行的一组有限的回归测试。这些测试要求源代码版本与运行中的内核版本匹配,并且针对内核开发者;其他人寻求运行或修改此类测试的入门门槛很高。随着eBPF日益被依赖用于关键的可观测性和安全基础设施,令人担忧的是Linux内核eBPF验证器是一个单一故障点,从根本上难以测试。
信任但验证
eBPF面临的主要问题是可移植性——也就是说,编写一个eBPF程序,使其通过验证器并在所有内核版本(或者,甚至在一个版本上)正确工作,是出了名的困难。BPF编译一次到处运行(CO-RE)的引入显著提高了eBPF程序的可移植性,尽管问题仍然存在。BPF CO-RE依赖于eBPF加载器库(libbpf)、Clang编译器和内核中的eBPF类型格式(BTF)信息。简而言之,BPF CO-RE意味着eBPF程序可以在一个Linux内核版本上编译(例如,由Clang编译),修改以匹配另一个内核版本的配置,并加载到该版本的内核中(通过libbpf),就像eBPF字节码是为其编译的一样。
然而,不同的内核版本有不同的验证器限制和支持不同的eBPF操作码。这使得从工程角度难以判断特定的eBPF程序是否会在除测试过的版本之外的其他内核版本上运行。此外,相同内核版本的不同配置也会有不同的验证器行为,因此确定程序的可移植性需要在所有所需配置上测试程序。在构建CI基础设施或尝试交付生产软件时,这是不现实的。
使用eBPF的项目采取各种方法来克服其可移植性挑战。对于主要专注于跟踪系统调用的项目(如osquery和opensnoop),BPF CO-RE不太必要,因为系统调用参数在内核版本之间是稳定的。在这些情况下,限制因素是验证器行为的变化。Osquery选择对其eBPF程序施加严格约束;它不利用现代eBPF验证器对诸如有界循环等结构的支持,而是继续编写会被最早验证器接受的eBPF程序。其他项目,如SysmonForLinux,为不同的内核版本维护多个版本的eBPF程序,并在编译期间动态选择程序版本。
什么是eBPF验证器?
eBPF的关键好处之一是它提供的保证:加载的代码不会使内核崩溃,将在时间限制内终止,并且不会将信息泄漏给无特权的用户进程。为了确保代码可以安全有效地注入内核,Linux内核的eBPF验证器对eBPF程序的能力施加了限制。验证器的名称略有误导性,因为尽管它旨在强制执行限制,但它不执行形式验证。
验证器对代码执行两个主要过程。第一个过程由check_cfg()函数处理,该函数通过执行所有可能执行路径的迭代深度优先搜索来确保程序保证终止。第二个过程(在do_check()函数中完成)涉及对字节码的静态分析;此过程确保所有内存访问有效,类型使用一致(例如,标量值从不作为指针使用),并且分支数量和总指令数在一定的复杂性限制内。
正如文章前面提到的,验证器强制执行的约束随着时间的推移发生了变化。例如,eBPF程序被限制为最多4,096条指令,直到内核版本5.2将该数字增加到100万。内核版本5.3引入了eBPF程序使用有界循环的能力。但请注意,验证器将始终向后兼容,因为所有未来版本的验证器将接受旧版本验证器接受的任何eBPF程序。
令人担忧的是,将eBPF程序加载到内核的能力并不总是仅限于root用户或具有CAP_SYS_ADMIN能力的进程。事实上,eBPF的初始计划包括对无特权用户的支持,要求验证器禁止与用户程序共享内核指针并执行常量盲化。在发生了几起影响eBPF的权限提升漏洞后,大多数Linux发行版默认禁用了对无特权用户的支持。然而,覆盖默认设置仍然存在导致严重权限提升攻击的风险。
无论eBPF是否仅限于特权用户,如果eBPF被依赖用于安全关键功能,则不能容忍验证器中的缺陷。正如LWN.net文章中所解释的,归根结底,“[验证器]是大约2000行中等复杂度的代码,已经由相对较少(但能力极强)的人审查过。在真实意义上,它是禁止行为的黑名单实现;要使其按广告宣传工作,必须想到所有可能的攻击并有效阻止。这是一个相对较高的门槛。”虽然代码可能已经由能力极强的人审查过,但验证器仍然是嵌入Linux内核中的一段复杂代码,缺乏 cohesive 的测试框架。没有 thorough 测试,存在违反向后兼容性原则或允许整个类别的潜在不安全程序通过验证器的风险。
启用eBPF验证器的 rigorous 测试
鉴于eBPF验证器是关键基础设施的基础,它应该通过可以轻松集成到CI工作流程中的 rigorous 测试过程进行分析。需要为每个内核版本运行Linux内核的内核自测和示例eBPF程序是不够的。
eBPF验证器工具链旨在允许在各种内核版本上进行测试,而不依赖于本地运行的内核版本或配置。换句话说,该工具链允许验证器(verifier.c文件)在用户空间中运行。
由于内核的单片性质和内核特定的习惯用语和功能,仅编译部分内核源代码以在用户空间中执行是困难的。幸运的是,eBPF验证的任务范围有限,并且许多涉及的功能和文件在内核版本之间是一致的。因此,为用户空间替代品存根化内核特定功能使得可以隔离运行验证器。例如,因为验证器期望从运行中的内核内部调用,它在分配内存时调用内核特定的内存分配函数。当它在工具链内运行时,它调用用户空间内存分配函数 instead。
该工具链不是第一个旨在提高验证器可测试性的工具。IO Visor项目的BPF fuzzer有一个非常相似的目标,即在用户空间中运行验证器并启用高效fuzzing——该工具已经发现了至少一个错误。但eBPF工具链与现有类似解决方案之间有一个主要区别:该工具链旨在支持所有内核版本,使得可以轻松比较相同eBPF程序在不同内核版本上的表现。该工具链尽可能保持真实内核功能的完整性,以维护 closely 近似真实内核上下文的执行环境。
系统设计
该工具链由以下主要组件组成:
- Linux源代码(以Git子模块的形式)
- LibBPF镜像(也是Git子模块)
- header_stubs.h(允许覆盖或完全排除某些内核功能和宏)
- 工具链源代码(即,存根化内核功能的实现)
eBPF验证器工具链架构
在高层次上,该工具链通过使用sample.bpf.c中的标准libbpf约定并在sample_loader.c中调用bpf_object__load(),将示例eBPF程序通过验证器运行。libbpf代码正常运行(例如,探测“内核”以查看支持哪些操作,如果配置为这样做则自动创建映射等),但它不是调用实际的bpf()系统调用并陷入运行中的内核,而是执行工具链“系统调用”并在工具链化内核内继续运行。
编译部分Linux内核涉及做出许多关于应包含哪些源文件以及哪些应存根化的决策。例如,内核频繁调用kmalloc()和kfree()函数进行动态内存分配。因为验证器在用户空间中运行,这些函数可以被用户空间版本如malloc()和free()替换。内核代码还包括许多在工具链中不必要的同步原语,因为工具链是单线程应用程序;那些原语也可以安全地存根化。
其他内核功能更难以有效替换。例如,使工具链工作需要找到一种模拟Linux内核虚拟文件系统的方法。这是必要的,因为验证器负责确保eBPF映射的安全使用,这些映射由文件描述符标识。为了模拟对文件描述符的操作,工具链还必须能够模拟与描述符关联的文件的创建。
演示
那么工具链实际上如何工作?示例程序是什么样子?下面是一个包含有界循环的简单eBPF程序;验证器对有界循环的支持是在内核版本5.3中引入的,因此所有早于5.3的内核版本应该拒绝该程序,而所有晚于5.3的版本应该接受它。让我们通过工具链运行它,看看会发生什么!
bounded_loop.bpf.c:
|
|
使用工具链需要将每个eBPF程序编译成eBPF字节码;完成后,“加载器”程序调用处理bpf系统调用设置的libbpf函数。加载器程序看起来类似于下面显示的程序,但可以调整以允许不同的配置和设置选项(例如,禁用映射的自动创建)。
bounded_loop_loader.c:
|
|
使用必要的Linux源代码部分、libbpf和工具链运行时编译示例程序会产生一个可执行文件,该文件将运行验证器并报告程序是否通过验证。
bounded_loop.bpf.c在通过版本5.18的验证器运行时的输出
展望未来
该工具链仍然是一个概念验证,在可用于生产之前,其几个方面需要改进。例如,为了完全支持所有eBPF映射类型,该工具链需要能够完全存根化额外的内核级内存分配原语。该工具链还需要可靠地支持3.15到最新版本之间的所有验证器版本。实现该支持将涉及手动 accounting 这些版本之间内部内核应用程序编程接口(API)的差异,并根据需要调整存根化的子系统。最后,更 cohesive 地组织存根化的功能,以及关于其组织的 thorough 文档,将使区分未修改的内核代码和已用用户空间替代品存根化的功能变得更加容易。
由于这些问题需要大量工作,我们邀请更大的社区在我们发布的工作基础上进行构建。虽然我们有许多改进想法,将使eBPF验证器更接近采用,但我们相信还有其他