深入解析eBPF验证器:构建跨内核版本的安全测试框架

本文详细介绍了eBPF验证器的工作原理及其安全性挑战,探讨了如何构建用户空间测试工具来跨内核版本验证eBPF程序,包括系统设计、实现方法和实际演示,为eBPF生态的安全测试提供了创新解决方案。

利用eBPF验证器 - Trail of Bits博客

在我于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的扩展,经典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内核中的复杂代码,缺乏统一的测试框架。没有彻底测试,存在违反向后兼容性原则或允许整个类别的潜在不安全程序通过验证器的风险。

启用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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include "vmlinux.h"
#include <BPF/BPF_helpers.h>

SEC("tracepoint/syscalls/sys_enter_execve")
int handle_tp(void *ctx)
{
    for (int i = 0; i < 3; i++) {
        BPF_printk("Hello World.\n");
    }
    return 0;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

使用该工具需要将每个eBPF程序编译成eBPF字节码;完成后,“加载器”程序调用处理bpf系统调用设置的libbpf函数。加载器程序类似于下面显示的程序,但可以调整以允许不同的配置和设置选项(例如,禁用映射的自动创建)。

bounded_loop_loader.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>
#include <stdlib.h>
#include "bounded_loop.skel.h"

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) {
    return vfprintf(stderr, format, args);
}

int load() {
  struct bounded_loop_bpf *obj;
  const struct bpf_insn *insns;
  int err = 0;

  libbpf_set_print(libbpf_print_fn);

  obj = bounded_loop_bpf__open();
  if (!obj) {
    fprintf(stderr, "failed to open BPF object. \n");
    return 1;
  }

  // this function invokes the verifier
  err = bpf_object__load(*obj->skeleton->obj);

  // free memory allocated by libbpf functions
  bounded_loop_bpf__destroy(obj);
  return err;
}

使用必要的Linux源代码部分、libbpf和工具运行时编译示例程序会产生一个可执行文件,该文件将运行验证器并报告程序是否通过验证。

通过版本5.18验证器运行bounded_loop.bpf.c的输出

展望未来

该工具仍然是一个概念验证,在可用于生产之前,其几个方面需要改进。例如,为了完全支持所有eBPF映射类型,该工具需要能够完全存根额外的内核级内存分配原语。该工具还需要可靠地支持从3.15到最新版本的所有验证器版本。实现该支持将涉及手动考虑这些版本之间内部内核应用程序编程接口(API)的差异,并根据需要调整存根子系统。最后,更统一地组织存根函数,以及关于其组织的彻底文档,将使区分未修改的内核代码和已用用户空间替代方案存根的函数变得更加容易。

由于这些问题需要大量工作,我们邀请更广泛的社区在我们发布的工作基础上进行构建。虽然我们有许多改进想法,将使eBPF验证器更接近采用,但我们相信还有其他

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计