驾驭 eBPF 验证器
在我于 Trail of Bits 实习期间,我开发了一个原型工具,旨在提升 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 后端获得。交互开始于用户空间进程执行一系列用于将 eBPF 程序加载到内核的 bpf 系统调用中的第一个。然后内核运行验证器,该验证器强制执行确保 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 内核中的一段复杂代码,缺乏统一的测试框架。如果没有彻底测试,则存在违反向后兼容性原则或允许整个类别可能不安全的程序通过验证器的风险。
启用 eBPF 验证器的严格测试
鉴于 eBPF 验证器是关键基础设施的基础,应通过可以轻松集成到 CI 工作流程中的严格测试过程进行分析。需要为每个内核版本运行 Linux 内核的内核自测和示例 eBPF 程序是不够的。
eBPF 验证器工具旨在允许在各种内核版本上进行测试,而不依赖于本地运行的内核版本或配置。换句话说,该工具允许验证器(verifier.c 文件)在用户空间中运行。
由于内核的整体性质以及内核特定的习惯用语和功能,仅编译部分内核源代码以在用户空间中执行是困难的。幸运的是,eBPF 验证的任务范围有限,并且涉及的许多功能和文件在内核版本之间是一致的。因此,为用户空间替代品存根内核特定功能使得可以独立运行验证器。例如,因为验证器期望从运行中的内核内部调用,它在分配内存时调用内核特定的内存分配函数。当它在工具中运行时,它调用用户空间内存分配函数。
该工具不是第一个旨在提高验证器可测试性的工具。IO Visor 项目的 BPF 模糊测试器有一个非常相似的目标,即在用户空间中运行验证器并实现高效的模糊测试——该工具至少发现了一个错误。但 eBPF 工具与现有类似解决方案之间有一个主要区别:该工具旨在支持所有内核版本,使得可以轻松比较相同 eBPF 程序在不同内核版本上的表现。该工具尽可能保持真实内核功能的完整性,以维护密切近似真实内核上下文的执行环境。
系统设计
该工具由以下主要组件组成:
- 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 到最新版本的所有验证器版本。实现该支持将涉及手动处理这些版本之间内部内核应用程序编程接口(API)的差异,并根据需要调整存根子系统。最后,更统一地组织存根函数,以及关于其组织的彻底文档,将使得更容易区分未修改的内核代码和已用用户空间替代品存根的函数。
由于这些问题需要大量工作,我们邀请更广泛的社区在我们发布的工作基础上进行构建。虽然我们有许多改进想法,将使 eBPF 验证器更接近采用,但我们相信还有其他