李浩(1999-), 男, 硕士生, 主要研究领域为操作系统架构与安全
古金宇(1994-), 男, 博士, 助理研究员, CCF专业会员, 主要研究领域为操作系统, 系统安全
夏虞斌(1982-), 男, 博士, 副教授, 博士生导师, CCF高级会员, 主要研究领域为计算机体系结构, 操作系统, 虚拟化, 系统安全
臧斌宇(1965-), 男, 博士, 教授, 博士生导师, CCF会士, 主要研究领域为操作系统, 计算机体系结构
陈海波(1982-), 男, 博士, 教授, 博士生导师, CCF杰出会员, 主要研究领域为操作系统, 并行与分布式系统, 虚拟化, 系统安全
Linux内核中的eBPF (extended Berkeley packet filter)机制可以将用户提供的不受信任的程序安全地加载到内核中. 在eBPF机制中, 检查器负责检查并保证用户提供的程序不会导致内核崩溃或者恶意地访问内核地址空间. 近年来, eBPF机制得到了快速发展, 随着加入越来越多的新功能, 其检查器也变得愈发复杂. 观察到复杂的eBPF安全检查器存在的两个问题: 一是“假阴性”问题: 检查器复杂的安全检查逻辑中存在诸多漏洞, 而攻击者可以利用这些漏洞设计能够通过检查的恶意eBPF程序来攻击内核; 二是“假阳性”问题: 检查器采用静态检查的方式, 由于缺乏运行时信息只能进行保守检查, 可能造成原本安全的程序无法通过检查, 也只能支持很受限的语义, 为eBPF程序的开发带来了困难. 通过进一步分析, 发现eBPF检查器中的静态模拟执行检查机制代码量大, 复杂度高, 分析保守, 是引起安全漏洞和误报的主要原因. 因此, 提出使用轻量级动态检查的方式取代eBPF检查器中的静态模拟执行检查机制, eBPF检查器中原本由于模拟执行而存在的漏洞与保守检查不复存在, 从而能够消除诸多上述的“假阴性”和“假阳性”问题. 具体来说, 将eBPF程序运行在内核态沙箱中, 由沙箱对程序运行时的内存访问进行动态检查, 保证程序无法对内核内存进行非法访问; 为高效实现轻量化的内核态沙箱, 利用新型硬件特性Intel PKS (protection keys for supervisor)进行零开销的访存指令检查, 并提出高效的内核与沙箱中eBPF程序交互方法. 评测结果表明, 所提方法能够消除内核eBPF检查器中的内存安全漏洞(自2020年以来该类型漏洞在eBPF检查器的总漏洞中占比超过60%); 即使在吞吐量较高的网络包处理场景下, 轻量化内核沙箱带来的性能开销低于3%.
The extended Berkeley packet filter (eBPF) mechanism in the Linux kernel can safely load user-provided untrusted programs into the kernel. In the eBPF mechanism, the verifier checks these programs and ensures that they will not cause the kernel to crash or access the kernel address space maliciously. In recent years, the eBPF mechanism has developed rapidly, and its verifier has become more complex as more and more new features are added. This study observes two problems of the complex eBPF verifier. One is the “false negative” problem: There are many bugs in the complex security check logic of the verifier, and attackers can leverage these bugs to design malicious eBPF programs that can pass the verifier to attack the kernel. The other is the “false positive” problem: Since the verifier adopts the static check method, only conservative checks can be performed due to the lack of runtime information. This may cause the originally safe program to fail the check of the verifier and only support limited semantics, which brings difficulties to the development of eBPF programs. Further analysis shows that the static simulation execution check mechanism in the eBPF verifier features massive codes, high complexity, and conservative analysis, which are the main reasons for security vulnerabilities and false positives. Therefore, this study proposes to replace the static simulation execution check mechanism in the eBPF verifier with a lightweight dynamic check method. The bugs and conservative checks that originally existed in the eBPF verifier due to simulation execution no longer exist, and hence, the above-mentioned “false negative” and “false positive” problems can be eliminated. Specifically, the eBPF program is run in a kernel sandbox, which dynamically checks the memory access of the program in the runtime to prevent it from accessing the kernel memory illegally. For efficient implementation of a lightweight kernel sandbox, the Intel protection keys for supervisor (PKS), a new hardware feature, is used to perform a zero-overhead memory access check, and an efficient interaction method between the kernel and the eBPF program in the sandbox is presented. The evaluation results show that this study can eliminate memory security vulnerabilities of the eBPF verifier (this type of vulnerability has accounted for more than 60% of the total vulnerabilities of the eBPF verifier since 2020). Moreover, in the scenario of high-throughput network packet processing, the performance overhead brought by the lightweight kernel sandbox is lower than 3%.
Extended Berkeley packet filter (eBPF)机制可以将用户提供的程序直接运行在内核中, 促进了内核可编程和动态可扩展技术的发展. 随着eBPF机制被加入到Linux内核, 它发展迅速并成为了内核中的重要组成部分. 与修改并重新编译内核或加载内核模块不同, eBPF可以将用户提供的程序编译成字节码并插入到Linux内核中预先配置好的地方(例如网络包处理、系统调用)执行, 或者基于Kprobe技术将其动态加载到内核中几乎任意函数的执行处[
Linux中的eBPF检查器会模拟执行待检测eBPF程序的每一条指令, 并追踪每一个变量的取值范围, 以防止eBPF程序越界访问内核内存. 由于eBPF程序可能包含条件分支, 该检查器需要遍历所有的分支状态, 这使得分析一个非常复杂的程序变得不现实. 为了加速检查, 该检查器还缓存了运行过若干条指令后的程序状态, 并实现了一套等价性检查的算法来进行动态剪枝. 尽管如此, eBPF机制对待检测程序的复杂度(例如指令数量)仍然存在严格的要求.
eBPF机制中静态安全检查机制的设计存在两个关键问题. 其一是检查器的代码自身存在漏洞, 可能被攻击者利用而造成“假阴性”问题. 在5.10版本的Linux内核中, eBPF检查器的代码约为12000行, 逻辑复杂, 规模庞大. 自2020年以来, Linux内核披露的漏洞中涉及eBPF检查器的高达14个[
观察到检查器采用静态模拟执行检查的方式是造成“假阴性”和“假阳性”的主要原因, 本工作提出在内核中为eBPF程序构建沙箱作为运行环境, 使用动态检查取代静态模拟执行检查. 运行在内核沙箱中的eBPF程序没有权限访问沙箱外的内存空间. 该设计一方面显著简化了检查器的静态检查过程, 进而减小了检查器中出现漏洞的可能性, 而且其运行时保护机制可以防止逃逸检查的恶意eBPF程序危害内核, 这缓解了上述的“假阴性”问题. 另一方面, 简化后的检查器允许更多原本安全的代码通过检查, 例如可以将变量间的关系的追踪交由运行时检查; 而且还能提供更多的语义, 例如支持动态内存分配等, 这些则缓解了上述的“假阳性”问题. 但是这种设计面临两个新的挑战: 一是如何高效地构造轻量化沙箱, 在保证其中运行的eBPF程序无法访问内核内存的同时仅引入极低的性能开销; 二是如何为沙箱中的eBPF程序提供高效而安全的与内核交互的机制.
为了应对上述挑战, 本工作提出利用Intel处理器的protection keys for supervisor (PKS)硬件特性[
本文工作主要有以下贡献.
● 提出软硬协同的内核态沙箱设计以增强eBPF机制的安全性和灵活性.
● 设计与实现面向eBPF程序的高效的内核沙箱原型系统.
● 对原型系统进行了功能和性能测试, 评测结果表明本工作能有效缓解eBPF检查器的“假阴性”和“假阳性”问题, 并且性能开销极低.
eBPF机制的前身是1992年提出的一个高效的网络包过滤器BPF[
eBPF程序执行过程
为了便于eBPF程序的开发, 内核为eBPF程序提供了map数据结构[
eBPF程序只能访问有限的内存区域, 它们包括: (1) 512 B的栈区域; (2) 程序上下文区域: 根据程序类型不同, eBPF程序可以访问不同的确定大小的上下文区域, 它们主要被用于从内核向eBPF程序传递参数; (3) 网络包区域: 需要处理网络包的eBPF程序可以直接访问网络包中的数据; (4) map区域: 内核提供map相关的API供程序使用, map可以和其他eBPF程序或者用户态程序共享. 其中网络包区域的大小是静态检查时未知的, 它的起始位置和大小被作为参数储存在程序上下文区域中. eBPF检查器会通过静态检查追踪每个变量的类型和取值范围, 并判断每次访存是否合法, 并拒绝加载访问了超出上述内存区域的eBPF程序.
Intel在Skylake-SP处理器上推出了protection keys for userspace (PKU), 主要被用来实现用户态应用的进程间隔离[
但是PKU机制对内核页表无效, 为了实现内核地址空间的隔离, Intel计划在下一代处理器中引入PKS[
与传统的基于software fault isolation (SFI)[
硬件模拟: 由于截止到目前Intel尚未发布支持PKS特性的商用CPU, 我们参考之前的工作[
eBPF检查器利用一套静态检查机制来判断用户提供的程序是否安全, 这套机制的设计与实现较为复杂. 本节详细分析了eBPF检查器的检查过程, 总结出eBPF检查器存在的“假阴性”和“假阳性”问题.
用户通过BPF系统调用将eBPF字节码传入内核, 内核通过严格的检查器检查用户提供的程序是否符合安全要求. 安全要求主要包括两部分: 一是确保程序会终止, 不会出现死循环等会导致处理器无限制等待的情况; 二是eBPF程序仅能访问有限的内存, 不会泄露内核中的数据或者恶意访问无效内存地址导致内核崩溃. 具体来说, 检查器会做以下检查.
(1) 环路检查: 根据传入的字节码构建控制流图(control flow graph, CFG), 通过深度优先算法判断程序是否存在回路(循环结构). 尽管在5.3版本的内核中已经支持了有限次数的循环[
(2) 复杂度检查: 统计eBPF程序的指令条数、分支条数, 如果超过限制, 就拒绝该程序. 由于eBPF检查器后续会通过模拟执行的方法分析每一条指令, 如果指令条数过多或分支条数过多, 会导致分析的复杂度太高, 例如会出现“路径爆炸”的问题.
(3) 访存检查: 采用模拟执行的方式分析每一条指令, 记录每一个变量的类型以及取值范围. 变量的类型可以分为标量和指针, 其中指针又被进一步地分为26个小类, 例如指向上下文的指针、指向map的指针、指向栈的指针等. 每当分析到内存访问指令时, 会根据访存的变量的类型和范围判断该访存是否合法. 例如一次栈上的访存, 其偏移量超过了512 B (栈的大小), 就会被判定为非法访存. 此外, 每当分析到辅助函数的时候, 会根据内核提供的辅助函数的参数类型判断调用是否合法. 内核为每一个辅助函数定义了一个说明其参数类型的结构体, 例如查询map的函数的第一个参数应该为指向map的指针, 如果实际传入的指针的类型与之不符, 检查器就会拒绝该程序.
(4) 其他检查: 例如检查除法操作的被除数是否可能为0, 程序是否可能会死锁等.
然而eBPF检查器面临着“假阴性”与“假阳性”的问题. eBPF检查器的“假阴性”问题是由于检查器自身存在漏洞而造成的, 攻击者可以利用这些漏洞构造出能够通过检查器检查但是会导致内核崩溃或者修改内核敏感信息的eBPF程序. 例如在2022年披露的Linux eBPF漏洞CVE-2022-23222[
eBPF检查器的“假阳性”问题是由于检查器为降低复杂性采用保守检查原则而导致的, 可能会拒绝不会危害内核的安全程序. 首先, 检查器依赖模拟执行的方式分析eBPF字节码中的每一条指令, 当程序规模增大时, 特别是条件分支增多时, 会导致“路径爆炸”的问题, 因此检查器只支持有限规模的eBPF程序. 其次, 检查器限制了eBPF程序的表达能力. 例如eBPF程序无法动态分配内存; 在程序中使用函数一般要强制内联, 否则检查器不能很好地支持跨函数的变量范围追踪. 再次, 检查器过于严格给eBPF程序开发带来了不便, 开发者必须得为代码增加许多额外的检查才能使得程序通过检查器. 最后, 检查器为编译期的代码优化带来了障碍, LLVM编译器不得不采用保守的策略以避免代码优化违反检查器的安全要求[
通过分析Linux内核中的检查器代码, 本工作观察到主要是第2.1节中所述的第3步访存检查导致了检查器的“假阴性”和“假阳性”问题. 首先, 本工作分析了自2020年以来内核中披露的关于检查器的14个CVE, 发现其中9个与检查器的访存检查相关, 例如CVE-2021-45402[
其次, 同样是第3步的访存检查限制了eBPF程序的表达能力, 使得检查器更容易拒绝用户提供的原本正确的代码. 在第3步中, 检查器通过深度优先算法遍历每一条执行路径, 同时缓存部分语句执行后的系统状态, 当第2次执行到相同语句时会将当前状态与缓存的状态比较, 如果状态相同, 就证明此后的执行流已经被检查过了, 因此可以进行剪枝操作, 继续遍历下一条执行路径. 尽管经过剪枝操作, 整个检查过程依旧非常耗时, 阻碍了检查器分析更加复杂的程序. 因此eBPF程序开发者不得不尽可能精简程序以符合该要求. 而且这一部分的检查过于严格, 检查器采用一种保守的检查策略, 亦即不允许所有“可能出错”的程序运行, 而实际上由于检查器缺乏程序的运行时语义, 往往不能做出正确的判断. 此时需要开发者提供更多的手动检查语句, 这一方面增加了开发者开发eBPF程序的难度, 另一方面也给检查器的检查工作带来额外的负担.
基于上述观察, 本工作摒弃eBPF检查器中的静态访存检查, 采用动态访存检查方式, 即在eBPF程序运行时对访存指令的目标地址进行检查. 许多现有的动态访存检查方法依赖SFI[
PKS内核沙箱示意图
本文提出的动态检查机制有以下优势: 一是可以显著减少检查器的工作, 减小了TCB的代码量; 二是可以有效防御恶意eBPF程序攻击内核, 恶意eBPF程序运行时无法非法访存; 三是允许开发者编写更加灵活的代码, 而不需要手动增加不必要的检查语句; 四是允许编译器更加激进地优化代码, 而不需要过度考虑检查器规定的安全语义. 但是这带来了新的挑战: PKS机制仅提供了内存隔离的功能, 如何将其用于为沙箱中的eBPF程序提供高效而安全的与内核交互的机制.
系统设计架构图如后文
基于PKS的eBPF增强系统架构图
当用户通过BPF系统调用加载一个eBPF程序时, 内核会为其分配一个新的沙箱. 如
内核地址空间布局
(1) map区域. eBPF程序若要使用map, 需要预先定义map的类型、键和值的大小、储存的键值对的最大个数等. 在加载map的时候, 首先需要使用BPF系统调用在内核空间内创建该map数据结构, 并返回一个匿名的文件描述符, 此后该eBPF程序使用该文件描述符访问对应的map. 为了保证沙箱中的eBPF程序能够正常访问map, 本设计保证在创建map数据结构的时候将分配的内存页置于内核沙箱内. 由于PKS机制以页的粒度保护内存区域, 因此本设计修改了每一种map类型的内存分配函数, 使得其分配的内存以页的粒度对齐.
此外, map可以在不同eBPF程序间共享, 为了支持这一特性, 本设计引入了共享沙箱. 共享沙箱不用于执行eBPF程序, 而是仅用来标记共享内存. 如
(2) 数据包区域. 为了避免网络数据包拷贝带来的开销, eBPF程序可以直接访问网络包的数据. Linux内核提供了对网络包处理的统一接口: 套接字缓存(skb). 网络包即为skb结构体中data与data_end之间的内存空间. 网络包数据被所有具有网络包访问权限的eBPF程序共享, 为了支持这一特性, 本设计提出将所有的网络包置于一个共享沙箱中. 如
Linux内核中预先定义了一系列eBPF函数的入口函数, 内核线程仅能通过这些入口函数进入沙箱中执行eBPF机器码.
上下文区域处理示意图
内核沙箱中的eBPF程序没有上下文对象的访问权限, 本文提出影子对象机制解决该问题. 影子对象即为内核数据结构在沙箱内的一份拷贝, 它和原数据结构在内容上保持一致. 本文在入口函数处为上下文对象构建一个影子对象拷贝, 并使用指向影子对象的指针(ctx_iso)替换原指针(ctx)作为参数传给eBPF机器码, 因此得以对eBPF程序透明地支持上下文区域的隔离. 当eBPF函数执行完毕后, 影子对象的值可能被eBPF程序修改, 此时再将影子对象的值拷贝回原对象. 不同类型的eBPF程序的上下文结构体不同,
常见类型的eBPF程序的上下文结构体
程序类型 | 结构体类型 | 说明 |
注: * 根据注入代码的位置不同, 上下文结构体也不相同, 本文选择所有结构体中最大的作为影子对象的大小 | ||
Socket Filter | __sk_buff | __sk_buff实际映射sk_buff, 储存网络包元数据 |
Kprobe | pt_regs | 储存寄存器状态 |
Tracepoint | 种类众多* | 储存注入代码处上下文信息 |
XDP | xdp_md | xdp_md实际映射xdp_buff, 储存网络包元数据 |
Perf Event | bpf_perf_event_data | 储存寄存器状态和其他perf元数据 |
Raw Tracepoint | bpf_raw_tracepoint_args | 储存寄存器状态和系统调用序号 |
影子对象的同步机制存在以下两个问题. 一是资源浪费的问题: eBPF程序可能仅会访问影子对象中的少部分域, 而将原对象全部拷贝到影子对象中会浪费大量的处理器资源. 例如cilium项目[
本工作提出用时拷贝的机制解决上述问题. 如
为了准确识别需要同步的域, 本设计向开发者提供一个可选的接口, 允许其提供待拷贝的域的列表, 因此本设计可以在不影响程序安全性的前提下使得eBPF程序获得最优的性能. 更进一步地, 开发者可以指定每一个待拷贝域的读写属性, 如果某个域被指定为只读, 在eBPF程序执行完毕后无需将其同步回原对象.
在构建影子对象的时候还需要考虑嵌套数据结构的问题. 例如__sk_buff结构体包含了一个指向bpf_flow_keys结构体的指针, 仅拷贝该指针会导致eBPF程序没有对嵌套结构体的访问权限. 如
为了避免动态分配影子对象带来的性能损失, 本设计在加载eBPF程序时预先为影子对象分配一段区域, 并将其置于沙箱内. 这块区域被初始化为一个影子对象的数组, 其大小为CPU核心的数目. 当某个CPU核心执行eBPF程序时, 首先获取其CPU序号, 以其为索引访问保留的影子对象数组中对应的影子对象. 当前线程会原子性地获取该对象的所有权, 并在执行完毕后将所有权释放, 因此避免了耗时的动态内存分配.
沙箱中运行的eBPF程序需要访问栈区域, 也可能调用一些辅助函数, 本节将分别介绍.
(1) 栈区域. eBPF程序拥有512 B的栈区域用于储存局部变量和函数调用的栈帧. 因为eBPF检查器的访存检查会检查栈访问是否超过了512 B的界限, 因此执行eBPF程序时可以直接沿用内核栈而无需换栈. 但是本工作使用PKS动态检查替换了静态访存检查, 这带来了新的问题: 如果内核栈不位于沙箱中, eBPF程序就失去了对栈区域的访问权限; 而如果将内核栈置于沙箱中, eBPF程序就拥有了访问栈上其他函数栈帧的权限, 进而可能危害系统安全, 例如恶意的eBPF程序可以通过修改其他栈帧的返回地址跳转到内核的任意位置执行.
本文提出影子栈机制解决该问题. 影子栈是位于内核沙箱中的一块内存区域, eBPF程序执行的时候会将栈指针切换到影子栈, 待执行完毕后再切换回内核栈. 与管理影子对象类似, 为了避免运行时分配影子栈带来性能开销, 本设计在创建eBPF沙箱的时候预先在沙箱中分配影子栈数组, 并在程序执行时根据当前的CPU序号直接读取影子栈的地址.
(2) 辅助函数. 内核中定义了一系列供eBPF程序调用的辅助函数, 例如可以输出调试信息、获取系统运行时间、与map交互等. 但是内核沙箱中的eBPF程序不能直接调用辅助函数, 一方面辅助函数可能会访问内核数据结构, 而沙箱中运行的线程没有访问权限; 另一方面, 内核不应该直接访问沙箱中的影子对象, 因为这些对象储存的信息可能不完整, 而且直接操作这些对象还可能导致影子对象与内核对象不同步.
本文提出跳板函数机制来解决辅助函数调用的问题. 如
辅助函数处理示意图
但是这种设计会带来潜在的安全问题, 因为恶意的eBPF程序可能会传递错误的参数来攻击内核, 例如 Linux 5.1版本内核开始支持的bpf_spin_lock辅助函数可以将传入的指针指向的内存修改为1. 如果没有检查器在模拟执行时的安全检查, 这个辅助函数可以被攻击者用来修改内核任意处的内存.
为了解决该问题, 本工作分析了Linux 5.10版本的内核支持的全部155个辅助函数, 它们属于以下情形的一类或几类.
(1) 无参数, 用于获取系统信息, 例如bpf_get_numa_node_id.
(2) 与map交互, 其中一个参数类型为bpf_map的指针, 例如bpf_map_lookup_elem.
(3) 其中一个参数为上下文结构体的指针, 例如bpf_skb_adjust_room.
(4) 其中一个参数是eBPF程序可以访问的指针, 例如bpf_trace_printk、bpf_spin_lock.
(5) bpf_tail_call, 用于调用另一个eBPF程序.
(6) bpf_kprobe_read等本身允许访问任意内核内存的函数.
得益于避免了繁琐的静态检查, 本机制可以更方便地添加新的辅助函数. 例如eBPF程序中不支持动态内存分配, 这在原eBPF机制中难以得到支持, 因为静态分析时难以获取需要分配的空间大小, 因此难以追踪对分配空间的内存访问是否越界. 而使用动态检查的方法可以解决这个问题: 在eBPF程序加载时保留一部分空间, 并将其置于内核沙箱中, 新添加的辅助函数可以直接在沙箱内管理这部分内存空间.
此外, 内核沙箱中的eBPF程序在运行时需要全局描述符表(global descriptor table, GDT)、局部描述符表(local descriptor table, LDT)、中断描述符表(interrupt descriptor table, IDT)等内核数据结构的访问权限, 因此在分配这些数据结构的时候将它们置于一个只读的隔离内存域中, 始终允许eBPF程序读取.
本节讨论基于PKS动态隔离的eBPF系统所面临的功能性和安全性的问题.
(1) PKS支持的域的数目有限. PKS仅支持至多16个内存域(0–15号域), 其中0号域是所有内核代码和数据结构所在的默认的域, 1号域被用作储存GDT等只读内核数据结构, 2号域被用来储存网络包的数据, 因此eBPF程序至多可以使用剩余的内存域构建13个内核沙箱. 为了支持运行更多的eBPF程序, 本设计允许在一个内核沙箱中运行多个eBPF程序. 系统中的eBPF程序往往由少数用户加载, 例如Kubernetes管理员使用cilium[
(2) PKS缺页异常处理. 当恶意eBPF程序访问到内核沙箱外的内存时, 会触发PKS缺页异常. 为了避免内核崩溃, 缺页异常的处理函数中会终止eBPF程序的执行, 将eBPF程序从内核中卸载, 并将执行流重置到eBPF入口函数返回的地址继续执行. 由于eBPF程序本身不会修改内核的数据结构, 也不会对系统的全局状态做出修改, 因此只需要执行卸载eBPF程序的相关代码(例如释放eBPF程序占用的内存等), 而无需进行复杂的状态保存与恢复.
(3) 执行流安全性分析. 检查器的CFG检查和受限的指令集保证了eBPF无法在运行时逃逸沙箱. 在加载eBPF程序时, 检查器的CFG检查保证了eBPF程序的跳转目标地址在eBPF程序内, 而且仅能跳转到程序中指令的开始处, 因此攻击者无法劫持eBPF程序的控制流以执行任意代码.JIT编译器仅能生成类型有限的字节码, 其中不包含WRMSR等特权指令, 因此被隔离的eBPF程序无法通过执行特权指令获取自身沙箱外的内存访问权限. 此外, 检查器的CFG检查保证了eBPF程序的可终止性.
我们在Linux 5.10版本的内核上实现了基于PKS (PKU模拟)动态检查隔离eBPF程序的原型系统, 并开展了一系列测试. 为了说明本文提出的动态隔离机制的有效性, 我们将其与原始eBPF检查器的静态检查机制在安全性、易用性和性能3个维度进行对比, 对比结果表明本机制在几乎不引入开销的情况下提升了eBPF机制的安全性与易用性. 本节将分别从这3个维度回答以下问题.
● 本工作提出的机制能否解决eBPF检查器的“假阴性”导致的安全性问题? (第4.1节)
● 本工作提出的机制能否解决eBPF检查器的“假阳性”导致的易用性问题? (第4.2节)
● 本工作提出的机制对系统性能有何影响? (第4.3节)
由于eBPF检查器存在“假阴性”问题, 攻击者有机会精心构造不安全的程序, 它们能够通过检查, 但可以对内核发起攻击. 本工作利用PKS硬件特性对eBPF运行时内存访问进行检查, 因此能够避免这类程序攻击. 基于PKS的动态检查方法可以简化大部分eBPF检查器的代码. 在我们实现的原型系统中, eBPF检查器的代码共12031行, 在删减模拟执行相关的代码后, 检查器的代码只剩下3949行, 简化了67.2%的代码. 我们分析了2020年以来内核中与eBPF检查器相关的14个CVE, 其中有9个漏洞相关代码都在被删减的代码中. 我们使用动态检查的方式可以有效地防御这些CVE. 本节将从一个典型的CVE入手, 详细地分析本文提出的新设计如何进行防御.
CVE-2022-23222[
代码1列出了利用该漏洞攻击内核的eBPF程序的伪代码. 第1行是一个辅助函数调用, 被用于向内核申请保留一定大小的环状缓存区, 其中的第2个参数是保留的缓冲区的大小, 其返回值的类型被检查器识别为指向内存的可能为空的指针(PTR_TO_MEM_OR_NULL). 在调用该辅助函数时将第2个参数设置为最大的64位整数, 因此该函数的实际返回值为空指针. 执行完第2行的赋值操作后, r1的类型也被识别为PTR_TO_MEM_OR_NULL, 其实际值也为空指针. 在eBPF的设计中, 指针是不能直接进行算术运算的, 但是由于检查器存在漏洞, 未能拒绝第3行的算术运算, 此时r1的实际值为x, 其类型仍旧为指针. 在第4行的判断后, 检查器将认为第5行所在的分支中的r0是空指针, 由于检查器认为r0和r1指向同一个对象, 此时检查器会错误地认为r1也为0, 而实际上r1可以是用户自定义的任意值x. 第5行中的r10为栈顶指针, 检查器认为该语句仅访问了栈区域, 但是这条指令使得程序拥有了访问内核中任意内存的能力.
代码
1. r0 = bpf_ringbuf_reserve(ptr, u64_max, 0);
2. r1 = r0;
3. r1 = r1 + x;
4. if r0为空 then
5. *(r10 + r1) = y
6. end if
检查器中还存在很多类似的漏洞, 例如在CVE-2021-45402[
导致这些漏洞的直接原因各不相同, 但是其根本原因都是检查器在模拟检查时忽略了一些特殊情况, 导致恶意eBPF可以绕过检查器的检查. 我们观察到这些漏洞的最终结果总是使得eBPF程序拥有了访问eBPF程序之外的内存空间的权限, 进一步导致内核敏感数据被泄露、内核崩溃或者用户程序的权限被非法提升.
而本文提出的基于PKS的动态检查机制可以很好地防御此类攻击, 尽管没有检查器的检查, 但是PKS机制限制了eBPF程序能够访问的内存地址范围, 当恶意eBPF程序尝试访问内核沙箱外的内存时, 该动态检查机制就会触发一个PKS缺页异常阻止其访问.
由于eBPF检查器过于严格, 开发者不得不从eBPF检查器的角度审视提交的代码, 这为开发工作带来了很大的负担. 在很多时候尽管提交的代码符合eBPF程序的安全规范(不会出现死循环或者访问到超出eBPF内存模型的内存空间), eBPF检查器仍然有可能拒绝该程序. 本节从真实使用场景出发, 列举了一些影响eBPF机制易用性的因素, 并说明本文提出的新设计可以显著降低开发eBPF程序的难度.
(1) 函数需要强制内联. eBPF检查器在模拟执行时对每一个函数的分析相对独立, 因此函数参数的追踪信息在调用过程中无法被保留. 在eBPF程序的开发中通用的做法是将所有函数强制内联到主函数中. 许多开发者没有注意到这一点, 导致程序无法通过检查. 这种“错误”往往也很隐蔽, 因为eBPF检查器的报错信息往往与之无关. 代码2列出了一个无法通过检查器的代码的例子.
代码
1. static int inline foo_internal(struct __sk_buff* skb, struct cb_space* cb) {
2. //... Use skb and cb
3. }
4. SEC("classifier/foo")
5. int foo(struct __sk_buff* skb) {
6. return foo_internal(skb, (struct cb_space*)&(skb->cb));
7. }
代码2展示的场景较为普遍. 第1行的inline关键字只是给编译器一个内联的提示, 而当该函数复杂度越来越高时, 编译器可能会出于优化代码的目的取消内联, 因此应当将inline改为always_inline强制编译器使用内联以避免这种情况的发生. 在上面这个例子中, cb在foo_internal中被识别为指向上下文的指针, 但是检查器无法获取它与skb之间的关系, 因此在访问cb时会报错. 而在本文提出的新设计中, 检查器对上下文指针的检查被替换成了PKS动态检查, 因此上述代码可以正常运行.
(2) 无法追踪变量间关系. eBPF还存在一个显著的问题, 即无法准确追踪变量之间的关系. 代码3中列出了一个访问栈区域的例子, 该程序无法通过eBPF检查器的检查.
代码
1. r1 = r0;
2. if (r0 > 512) return;
3. *(u8 *)(r10 – r1) = 0; // r10是栈的基地址
在代码3中, 初始时检查器记录的r0和r1的取值范围都是[0, u64_max]. 第1行赋值语句结束后两个寄存器的取值范围相同, 但是检查器未能将这个信息用于此后的分析. 第2行条件分支语句执行结束后r0与r1的取值范围都为[0, 512), 但是检查器未能正确分析出r1的取值范围. 第3行语句原本应是访问栈上的合法内存, 但是由于检查器认为r1的取值范围为[0, u64_max], 因此该程序会被检查器拒绝. 与之类似的还有一些变种情况, 例如将r1是一个指向上下文区域的指针, 在r1上执行自增操作会导致检查器失去对r1类型的追踪, 此后便无法使用r1访问上下文区域(尽管做了完整的范围检查).
这些检查出现“假阳性”的根源都在于eBPF检查器的静态检查机制无法理解较为复杂的程序语义, 导致无法正确判断程序的安全性. 而在使用本文提出的基于PKS的动态检查机制的系统中, 仅需保证程序执行时不访问eBPF内存模型区域以外的内存即可, 上述代码可以正常运行.
(3) 阻碍编译器进行代码优化. 检查器制定了许多安全规则以简化检查的逻辑, 违反这些规则无法通过检查器的检查, 但是并不代表程序是不安全的, . 为了生成符合检查器安全规则的代码, LLVM编译器不得不在代码优化上做出妥协. 代码4列出的是eBPF检查器的栈访问对齐的规则阻碍编译器进行代码优化的例子.
代码
1. // 优化前
2. *(u8 *)(r0 + off) = 0;
3. *(u8 *)(r0 + off + 1) = 0;
4. // 期望优化后
5. *(u16 *)(r0 + off) = 0;
检查器的安全规则要求栈上的访存需要以访存的粒度对齐. 在代码4中, 如果r0+off是偶数, 那么可以将两次访存优化为一次访存; 而如果是奇数, 那么这个优化就可能会导致无法通过检查器的检查. 因此LLVM编译器选择保守的优化策略, 不对此类代码进行优化. 出现此类问题的根源在于代码优化与代码安全检查耦合了, 本文提出的基于PKS的机制能够将运行前的优化和运行时的检查解耦, 这给予了LLVM等编译器更多的优化空间.
为了准确评估本文提出的方案的性能开销, 我们开展了微基准测试和真实应用场景测试. 其中微基准测试的目标是测试动态检测机制引入的开销. 尽管PKS硬件特性保证在内存检查时不引入额外开销, 但是在内核沙箱的出入口仍需要切换内存域, 此外还需要设置影子栈以及配置影子对象, 微基准测试旨在测试这些部分的耗时. 真实场景测试则针对一些流行项目中的eBPF程序, 分析并测试这套新机制给整个系统带来的负载.
测试环境: 我们在搭载Intel i7-10700 CPU的机器上开展测试, 并将CPU的频率锁定为2.0 GHz以减小实验数据波动. 机器上运行内核版本为5.10的Ubuntu 18.04LTS操作系统. 由于Intel暂未发布支持PKS的处理器, 我们在实现该机制的时候使用PKU模拟PKS. PKU机制通过用户态指令WRPKRU来实现快速的内存域权限切换, 其指令开销约为28个时钟周期. 而PKS机制通过特权级指令WRMSR来实现内存域切换, 其指令开销暂不可知. 根据WRMSR指令写的特殊模块寄存器 (model-specific register, MSR)的不同, 该指令的开销也不尽相同, 但测得其开销一般少于100个时钟周期. 我们将内存域切换指令换为等待80个时钟周期.
我们利用Linux内核提供的map_perf_test程序进行微基准测试. 该程序被用于向指定类型的eBPF map中依次执行一次更新、查找和删除操作, 并测试其吞吐量,
微基准测试下不同种类map的吞吐量 (Mops/s)
map类型 | 无动态隔离机制 | 动态隔离机制 |
hash map | 5.34 | 3.64 |
percpu hash map | 5.21 | 3.61 |
由于测试时CPU的频率被限定为2 GHz, 因此可以计算出两种情况下eBPF程序的执行时间. 以hash map为例, 无动态隔离的时候该eBPF程序的执行需要379个时钟周期, 而动态隔离的时候eBPF程序的执行周期为549个时钟周期, 因此动态隔离带来的开销约为170个时钟周期.
动态隔离带来的开销可以被进一步地划分为切换pkrs权限的开销、设置影子栈的开销、设置并拷贝影子对象的开销, 以及辅助函数的开销. 其中切换pkrs权限的开销占了绝大部分(eBPF程序的入口和出口处分别执行一次WRMSR, 共160个时钟周期). 由于影子栈与影子对象均在eBPF程序加载的时候分配, 其地址被储存在eBPF程序的内核表示的结构体中, 可以直接获取. 初始化影子对象的时候还需要将部分域从原对象中拷贝到影子对象. 这部分也只涉及少量内存访问, 开销也很低. 此外, eBPF用到的map在沙箱内部分配, 调用操作map的辅助函数也无需出入沙箱. 综上所述, 微基准测试结果表明执行PKS动态检测机制的开销约为170个时钟周期, 其中的绝大部分来源于eBPF函数的入口和出口处的内存域切换.
我们首先测试了动态检查机制对真实应用的影响. memcached作为一个通用的分布式内存对象缓存系统被广泛应用于网络应用中. 我们在待测试的机器上部署了memcached作为后端, 在另一台机器上生成YCSB[
YCSB基准测试
进一步地, 我们分别针对eBPF程序的3种典型应用场景(网络包处理、内核代码追踪和内核安全监测)进行测试和分析, 更具一般性地评估本设计在真实应用场景中为系统带来的负载.
(1) 网络包处理场景. 我们利用Cilium项目[
(2) 内核代码追踪场景. 在该场景下, 用户一般利用bpftrace[
(3) 内核安全监测场景. eBPF机制可以被用来保护内核安全, 例如在Falco项目[
综合以上分析, 基于PKS的动态检查机制在eBPF的典型应用场景中性能表现良好, 能够支持较高的吞吐量的网络包处理场景.
为了解决检查器的“假阴性”和“假阳性”的问题, 现有工作[
Gershuni等人的工作[
Mahadevan等人在2021年提出的PRSafe系统[
Nelson等人在2021年提出的ExoBPF系统[
为了在内核中构建沙箱, 现有工作[
尽管本文主要利用Intel处理器提供的PKS硬件特性设计eBPF内存隔离机制, 但是该机制可以被推广到其他主流处理器硬件上. 例如ARM在ARMV8和AArch32中引入了内存域(memory domain)机制[
本文分析了eBPF检查器存在的“假阴性”与“假阳性”问题, 提出了一种基于新型PKS硬件特性的eBPF内存隔离机制. 本文提出的新方案在一方面减少了eBPF检查器的代码量, 可以在运行过程中拦截恶意的eBPF程序访存, 解决了“假阴性”问题; 在另一方面将检查器的部分工作移至运行时检查, 因此允许更灵活的语义, 减轻了eBPF开发者的负担, 并给eBPF编译器提供了更激进优化的可能性. 同时, 性能测试与分析表明本技术在真实系统上带来的开销可以忽略不计.
https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=BPF]]>
https://nvd.nist.gov/vuln/detail/CVE-2022-23222]]>
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html]]>
https://cilium.io/]]>
https://www.iovisor.org/technology/bcc]]>
https://bpftrace.org/]]>
https://falco.org/]]>
https://github.com/facebookincubator/katran]]>
https://lwn.net/Articles/656307/]]>
http://llvm.org/docs/CodeGenerator.html#the-extended-berkeley-packet-filter-ebpf-backend]]>
https://prototype-kernel.readthedocs.io/en/latest/bpf/ebpf_maps.html]]>
https://lwn.net/Articles/826091/]]>
http://www.jos.org.cn/1000-9825/4778.htm]]>
http://www.jos.org.cn/1000-9825/4778.htm]]>
http://www.jos.org.cn/1000-9825/6211.htm]]>
http://www.jos.org.cn/1000-9825/6211.htm]]>
余劲, 黄皓, 诸渝, 许封元. DBox: 宏内核下各种设备驱动程序的高性能安全盒. 计算机学报, 2020, 43(4): 724–739. [doi: 10.11897/SP.J.1016.2020.00724]
Yu J, Huang H, Zhu Y, Xu FY. Dbox: High-performance secure boxes for various device drivers of monolithic kernels. Chinese Journal of Computers, 2020, 43(4): 724–739 (in Chinese with English abstract). [doi: 10.11897/SP.J.1016.2020.00724]
Pomonis M, Petsios T, Keromytis AD, Polychronakis M, Kemerlis VP. Kernel protection against just-in-time code reuse. ACM Transactions on Privacy and Security, 2019, 22(1): 5. [doi: 10.1145/3277592]
https://lwn.net/Articles/794934/]]>
https://nvd.nist.gov/vuln/detail/CVE-2021-45402]]>
https://nvd.nist.gov/vuln/detail/CVE-2021-3490]]>
https://lwn.net/Articles/636647/]]>
https://nvd.nist.gov/vuln/detail/CVE-2021-20268]]>
http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0056d/BABBJAED.html]]>
https://riscv.org/specifications/]]>