依赖eBPF进行安全监控的六大陷阱与解决方案

本文深入探讨了在Linux系统中使用eBPF进行安全监控时可能遇到的六大陷阱,包括探针未触发、数据截断、指令数限制等问题,并提供了相应的解决方案和规避方法。

依赖eBPF进行安全监控的陷阱(及一些解决方案)

eBPF(扩展伯克利包过滤器)已成为Linux安全监控和终端可观测性的事实标准。由于其低开销和多功能性,BPFTrace、Cilium、Pixie、Sysdig和Falco等技术都采用了eBPF。

然而,有一个公开的秘密:eBPF最初并非为安全监控而设计。它首先是一个网络和调试工具。正如Brendan Gregg所观察到的:

eBPF在提升计算机安全方面有许多用途,但直接将eBPF可观测性工具用于安全监控,就像把汽车开进海里还指望它能漂浮一样。

但eBPF仍然被用于安全监控,开发人员可能没有意识到这种用例带来的常见陷阱和未被充分报告的问题。在本文中,我们将讨论其中一些问题并提供解决方案。然而,使用eBPF进行安全监控的一些挑战是平台固有的,无法轻易解决。

陷阱一:eBPF探针未被调用

理论上,内核不应该无法触发eBPF探针。但实际上,它确实会发生。有时,尽管非常罕见,当用户代码期望看到探针时,内核不会触发eBPF探针。这种行为没有明确记录或承认,但你可以在eBPF工具的bug报告中找到相关线索。

这个bug报告提供了宝贵的见解。首先,涉及的问题很罕见且难以调试。其次,内核可能在技术上是正确的,但用户端观察到的行为是事件缺失,即使直接行为不同(例如,探针过多)。bug报告的评论提出了事件缺失的两个理论:

首先,内核可以同时激活的kRetProbes数量有一个设定限制。截至内核6.4.5,默认限制为4,096。尝试创建更多kRetProbes将失败,导致事件丢失。

其次,kProbe和kRetProbe的回调逻辑略有不同,这意味着有时kProbe不会看到匹配的kRetProbe,导致事件丢失。

内核中可能隐藏着更多这类问题,无论是作为记录的边缘情况还是无关设计决策的意外涌现效应。eBPF不是安全监控机制,因此不能保证探针会按预期触发。

解决方案

无。回调逻辑和kRetProbes最大数量的值都硬编码在内核中。虽然可以手动编辑和重建内核源代码,但在大多数情况下不建议或不可行。任何依赖eBPF的工具都必须为偶尔缺失的回调做好准备。

陷阱二:由于空间限制导致数据截断

eBPF程序的堆栈空间限制为512字节。在编写eBPF代码时,开发人员需要特别小心他们使用的临时数据量和调用堆栈的深度。这个限制影响了可以使用eBPF代码处理的数据量和种类。例如,512字节小于允许的最长文件路径长度4,096字节。

解决方案

有多个选项可以获得更多临时空间,但它们都涉及"作弊"。得益于bpf_map_lookup_elem辅助函数,可以直接使用映射的内存。直接使用映射作为存储有效地充当了malloc,但针对eBPF代码。一个合理的实现是具有单个键的每CPU数组,其大小对应于我们的分配需求:

1
2
u64 first_key = 0;
u8 *scratch_buffer = per_cpu_map.lookup(&first_key); // 使用bpf_map_lookup_elem实现

但是,我们如何将这些数据发送回用户模式代码?一个天真的方法是使用更多映射,但这种方法对于路径等可变大小对象会失败,并且也浪费内存。映射在内存使用方面可能非常昂贵,因为必须为每个CPU复制数据以确保完整性。不幸的是,每CPU映射基于可能的热插拔CPU数量分配内存。这个数字可能非常大——在VMWare Fusion上,默认为128,因此单个映射条目浪费的空间是其使用空间的127倍。

另一种方法是通过perf环形缓冲区流式传输数据。linuxevents库使用这种方法处理可变路径。以下是这种方法的一个示例伪代码实现:

1
2
3
4
5
6
u64 first_key = 0;
u8 *scratch_space = per_cpu_array.lookup(&first_key);
for (const auto &component_ptr : path.components()) {
  bpf_probe_read_str(scratch_space, component_ptr, scratch_space_size);
  perf_submit(scratch_space);
}

通过perf环形缓冲区流式传输数据显著增加了每个组件的有效大小,并提高了空间效率,尽管以额外的数据重建工作为代价。为了处理未触发探针或丢失/覆盖数据等边缘情况,必须在数据传输后实现恢复方法。不幸的是,perf缓冲区的分配方式与每CPU映射类似。在较新的系统上,可以使用BPF环形缓冲区来避免这个问题(相同的环形缓冲区在CPU之间共享)。

陷阱三:有限的指令数

eBPF程序只能有4,096条指令,并且无法重用代码(例如,通过定义函数)。直到最近,循环还不受支持(或者必须手动展开)。虽然eBPF允许在运行时最多执行100万条指令,但程序仍然只能有4,096条指令长。

解决方案

重新构建程序以利用有界循环(即迭代次数可以静态确定的循环)。这些循环现在受支持,与展开循环相比,它们节省了宝贵的程序空间。另一个增加程序大小的解决方案是多个程序相互尾调用,它们最多可以执行32次直到执行中断。这种方法的缺点是在每次转换之间程序状态会丢失。为了在尾调用之间保持状态,考虑将数据存储在可由所有32个程序访问的eBPF映射中。

陷阱四:检查时间到使用时间问题

eBPF程序可以并且将在不同的CPU核心上并发运行。即使对于内核代码也是如此。由于无法调用内核同步函数或从eBPF可靠地获取锁,数据竞争和检查时间到使用时间问题是一个严重的问题。

解决方案

唯一的解决方案是根据程序仔细选择事件附加点。例如,eBPF通常需要与接受用户数据的函数一起工作。在这种情况下,一个好的附加点是在用户数据被读入内核模式之后。

当处理内核代码并涉及同步时,你可能无法缓解检查时间到使用时间问题。例如,支持文件的dentry结构通常由内核在锁下修改,并且无法从eBPF探针获取这些锁。通常,出现问题的唯一指示是来自bpf_probe_read_user等API的错误返回代码。确保以不完全使事件数据不可用的方式处理此类错误。例如,如果你通过perf在不同数据包中流式传输数据,插入一个错误数据包,通知客户端数据缺失,以便它们可以重新对齐事件流而不会导致损坏。

陷阱五:事件过载

由于eBPF缺乏并发原语,并且eBPF探针无法阻塞事件生产者,附加点很容易被事件淹没。这可能导致以下问题:

  • 事件丢失,因为内核停止调用探针
  • 由于新数据缺乏存储空间导致数据丢失
  • 由于较新信息完全覆盖尚未消耗的旧数据导致数据丢失
  • 部分覆盖或复杂数据格式导致数据损坏,破坏正常程序操作

这些数据丢失和损坏场景取决于向事件流中添加项目的探针和事件数量以及系统活动程度。例如,docker容器启动序列或部署脚本可能触发大量事件。开发人员应仔细选择要监控的事件,并应避免重复和可能使从数据丢失中恢复更困难的构造。

解决方案

用户模式辅助函数应将来自eBPF探针的所有数据视为不可信。这包括来自你自己eBPF探针的数据,这些数据也容易意外损坏。还应有某种应用级机制来检测缺失或损坏的数据。

陷阱六:页面错误

最近未访问的内存可能被换出到磁盘——无论是交换文件、后备文件还是更奇特的位置。通常,当需要这些内存时,内核将发出页面错误,加载相关内容,并继续执行。由于各种原因,eBPF运行时禁用页面错误——如果内存被换出,则无法访问。这对安全监控工具来说是个坏消息。

解决方案

唯一的解决方案是在缓冲区使用后立即挂钩,并希望它在探针读取之前不会被换出。这不能严格保证,因为没有并发原语,但挂钩的实现方式可以增加成功可能性。

考虑以下示例:

1
2
3
4
5
6
int syscall_name(const char *user_mode_ptr) {
  function1();
  function2(user_mode_ptr);
  function3()
  return 0;
}

为了确保可以访问user_mode_ptr,此代码首先挂钩到syscall_name的入口,并将所有指针参数保存在映射中。然后它搜索一个user_mode_ptr几乎肯定可访问的地方(即function2调用之后的任何地方),并在那里设置附加点以读取数据。以下是附加点的一些选项:

  • function2退出时
  • function3入口时
  • function3退出时
  • syscall_name退出时

你可能想知道为什么我们不直接挂钩function2。虽然这偶尔有效,但通常是个坏主意:

  • function2通常在你感兴趣的上下文之外调用(即syscall_name之外)
  • function2在内核修订版中可能没有相同的签名。如果我们只是将函数用作不透明断点,签名更改不会影响我们的探针

还要注意,有时参数在系统调用期间会发生变化,我们需要在数据消失之前读取它。例如,execve系统调用会替换整个进程内存,在调用完成之前擦除所有初始数据。

再次强调,开发人员应假设某些内存可能无法被eBPF探针读取,并相应地进行开发。

拥抱好处,解决限制

eBPF是Linux可观测性和监控的强大工具,但它不是为安全而设计的,并且带有固有的限制。开发人员需要意识到诸如探针不可靠、数据截断、指令限制、并发问题、事件过载和页面错误等陷阱。存在解决方案,但它们不完美且通常增加复杂性。

底线是,虽然eBPF实现了令人兴奋的新功能,但它不是银弹。使用eBPF进行安全监控的软件必须构建为能够优雅地处理缺失数据和错误条件。健壮性需要成为首要任务。

通过谨慎和创造力,eBPF仍然可以用于构建下一代安全工具。但这需要承认并围绕eBPF的限制工作,而不是忽略它们。与任何技术一样,最有效的安全监控解决方案将拥抱eBPF,同时意识到它可能如何失败。

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