Tracee 如何解决 BTF 信息缺失问题

通过使用 Linux eBPF(伯克利包过滤器)技术跟踪进程,Tracee 可以关联收集到的信息并识别恶意行为模式。
还没有读者喜欢这篇文章。
Mesh networking connected dots

Tracee 是 Aqua Security 的一个项目,用于在运行时跟踪进程。通过使用 Linux eBPF(伯克利包过滤器)技术跟踪进程,Tracee 可以关联收集到的信息并识别恶意行为模式。

eBPF

BPF 是一个帮助进行网络流量分析的系统。后来的 eBPF 系统扩展了经典的 BPF,以提高 Linux 内核在不同领域的可编程性,例如网络过滤、函数挂钩等等。由于其嵌入在内核中的基于寄存器的虚拟机,eBPF 可以执行用受限 C 语言编写的程序,而无需重新编译内核或加载模块。通过 eBPF,您可以在内核上下文中运行您的程序,并挂钩内核路径中的各种事件。为此,eBPF 需要深入了解内核正在使用的数据结构。

eBPF CO-RE

eBPF 与 Linux 内核 ABI(应用程序二进制接口)交互。从 eBPF VM 访问内核结构取决于特定的 Linux 内核版本。

eBPF CO-RE(一次编译,到处运行)是指编写一个 eBPF 程序,该程序将成功编译,通过内核验证,并在不同的内核版本上正确工作,而无需为每个特定的内核重新编译。

要素

CO-RE 需要以下组件的精确协同作用

  • BTF(BPF 类型格式)信息: 允许捕获关于内核和 BPF 程序类型和代码的关键信息,从而使 BPF CO-RE 拼图的所有其他部分成为可能。
     
  • 编译器 (Clang): 记录重定位信息。例如,如果您要访问 task_struct->pid 字段,Clang 会记录它是一个名为 pid 的字段,类型为 pid_t,驻留在结构体 task_struct 中。该系统确保即使目标内核的 task_struct 布局中 pid 字段移动到 task_struct 结构中的不同偏移量,您仍然可以通过其名称和类型信息找到它。
     
  • BPF 加载器 (libbpf): 将来自内核和 BPF 程序的 BTF 绑定在一起,以调整编译后的 BPF 代码以适应目标主机上的特定内核。

那么,这些要素如何混合在一起才能成功呢?

开发/构建

为了使代码具有可移植性,以下技巧发挥了作用

  • CO-RE 助手/宏
  • BTF 定义的映射
  • #include "vmlinux.h"(包含所有内核类型的头文件)

运行

内核必须使用 CONFIG_DEBUG_INFO_BTF=y 选项构建,以便提供 /sys/kernel/btf/vmlinux 接口,该接口公开 BTF 格式的内核类型。这允许 libbpf 解析和匹配所有类型和字段,并更新必要的偏移量和其他可重定位数据,以确保 eBPF 程序在目标主机上的特定内核上正常工作。

问题

当 eBPF 程序被编写为可移植的,但目标内核没有公开 /sys/kernel/btf/vmlinux 接口时,问题就出现了。有关更多信息,请参考此列表,其中列出了支持 BTF 的发行版。

为了在不同的内核中加载和运行 eBPF 对象,libbpf 加载器使用 BTF 信息来计算字段偏移量重定位。如果没有 BTF 接口,加载器就没有必要的信息来调整程序在处理运行内核的对象后尝试访问的先前记录的类型。

是否有可能避免这个问题?

用例

本文探讨了 Tracee,这是一个 Aqua Security 开源项目,它提供了一种可能的解决方案。

Tracee 提供了不同的运行模式,以使其自身适应环境条件。它支持两种 eBPF 集成模式

  • CO-RE: 一种可移植模式,可以在所有支持的环境中无缝运行
  • 非 CO-RE: 一种内核特定模式,需要为目标主机构建 eBPF 对象

它们都在 eBPF C 代码 (pkg/ebpf/c/tracee.bpf.c) 中实现,其中进行了预处理条件指令。这允许您编译 CO-RE eBPF 二进制文件,并在构建时使用 Clang 传递 -DCORE 参数(请查看 bpf-core Make 目标)。

在本文中,我们将介绍一种可移植模式的案例,即 eBPF 二进制文件是 CO-RE 构建的,但目标内核没有使用 CONFIG_DEBUG_INFO_BTF=y 选项构建。

为了更好地理解这种情况,了解当内核没有在 sysfs 上公开 BTF 格式的类型时可能发生的情况很有帮助。

无 BTF 支持

如果您想在没有 BTF 支持的主机上运行 Tracee,有两种选择

  1. 构建并安装 适用于您的内核的 eBPF 对象。这取决于 Clang 以及内核版本特定的 kernel-headers 软件包的可用性。
     
  2. BTFHUB 下载适用于您的内核版本的 BTF 文件,并通过 TRACEE_BTF_FILE 环境变量将其提供给 tracee-ebpf 的加载器。

第一种选择不是 CO-RE 解决方案。它编译 eBPF 二进制文件,包括一个很长的内核头文件列表。这意味着您需要在目标系统上安装内核开发软件包。此外,此解决方案需要在您的目标机器上安装 Clang。Clang 编译器可能占用大量资源,因此编译 eBPF 代码可能会使用大量资源,从而可能影响精心平衡的生产工作负载。 也就是说,避免在生产环境中出现编译器是一种很好的做法。这可能导致攻击者成功构建漏洞并执行权限提升。

第二种选择是 CO-RE 解决方案。这里的问题是您必须在系统中提供 BTF 文件才能使 Tracee 工作。整个存档接近 1.3 GB。当然,您可以仅为您的内核版本提供正确的 BTF 文件,但这在处理不同的内核版本时可能会很困难。

最终,这些可能的解决方案也可能引入问题,而这正是 Tracee 发挥其魔力的地方。

一种可移植的解决方案

通过一个非平凡的构建过程,Tracee 项目编译一个二进制文件,即使目标环境不提供 BTF 信息,它也是 CO-RE 的。这可以通过 embed Go 包实现,该包在运行时提供对程序中嵌入文件的访问。在构建期间,持续集成 (CI) 管道下载、提取、最小化,然后将 BTF 文件与 eBPF 对象一起嵌入到 tracee-ebpf 生成的二进制文件中。

Tracee 可以提取正确的 BTF 文件并将其提供给 libbpf,libbpf 进而加载 eBPF 程序以在不同的内核上运行。但是 Tracee 如何嵌入从 BTFHub 下载的所有这些 BTF 文件,而最终不会占用太多空间呢?

它使用 Kinvolk 团队最近在 bpftool 中引入的一项功能,称为 BTFGen,它可以使用 bpftool gen min_core_btf 子命令。给定一个 eBPF 程序,BTFGen 生成缩减的 BTF 文件,仅收集 eBPF 代码运行所需的 BTF 文件。这种缩减允许 Tracee 嵌入所有这些现在更轻(只有几千字节)的文件,并支持没有公开 /sys/kernel/btf/vmlinux 接口的内核。

Tracee 构建

以下是 Tracee 构建的执行流程

Detailed flowchart of tracee build from tracee/3rdparty/btfhub.sh to tracee-ebpf bin compiled

(Alessio Greggi 和 Massimiliano Giovagnoli,CC BY-SA 4.0)

首先,您必须构建 tracee-ebpf 二进制文件,即加载 eBPF 对象的 Go 程序。Makefile 提供了命令 make bpf-core 来构建带有 BTF 记录的 tracee.bpf.core.o 对象。

然后 STATIC=1 BTFHUB=1 make all 构建 tracee-ebpf,它以 btfhub 为目标依赖项。最后一个目标运行脚本 3rdparty/btfhub.sh,该脚本负责下载 BTFHub 存储库

  • btfhub
  • btfhub-archive

下载并放置在 3rdparty 目录后,该过程执行下载的脚本 3rdparty/btfhub/tools/btfgen.sh。此脚本生成针对 tracee.bpf.core.o eBPF 二进制文件量身定制的缩减 BTF 文件。

该脚本从 3rdparty/btfhub-archive/ 收集 *.tar.xz 文件以解压缩它们,并最终使用 bpftool 处理它们,使用以下命令

for file in $(find ./archive/${dir} -name *.tar.xz); do
    dir=$(dirname $file)
    base=$(basename $file)
    extracted=$(tar xvfJ $dir/$base)
    bpftool gen min_core_btf ${extracted} dist/btfhub/${extracted} tracee.bpf.core.o
done

此代码已简化,以便更容易理解该场景。

现在,您已经为食谱准备好所有要素

  • tracee.bpf.core.o eBPF 对象
  • BTF 缩减文件(适用于所有内核版本)
  • tracee-ebpf Go 源代码

此时,调用 go build 来完成其工作。在 embedded-ebpf.go 文件中,您可以找到以下代码

//go:embed "dist/tracee.bpf.core.o"
//go:embed "dist/btfhub/*"

在这里,Go 编译器被指示将 eBPF CO-RE 对象与所有 BTF 缩减文件一起嵌入到自身内部。编译后,可以使用 embed.FS 文件系统访问这些文件。为了了解当前情况,您可以想象二进制文件具有如下结构的文件系统

dist
├── btfhub
│   ├── 4.19.0-17-amd64.btf
│   ├── 4.19.0-17-cloud-amd64.btf
│   ├── 4.19.0-17-rt-amd64.btf
│   ├── 4.19.0-18-amd64.btf
│   ├── 4.19.0-18-cloud-amd64.btf
│   ├── 4.19.0-18-rt-amd64.btf
│   ├── 4.19.0-20-amd64.btf
│   ├── 4.19.0-20-cloud-amd64.btf
│   ├── 4.19.0-20-rt-amd64.btf
│   └── ...
└── tracee.bpf.core.o

Go 二进制文件已准备就绪。现在来试用一下!

Tracee 运行

以下是 Tracee 运行的执行流程

Flow chart of tracee run assuming BTF info is not available in the kernel, which leads to "copy btf kernel related file" and "load btf file using libbpf under the hood"

(Alessio Greggi 和 Massimiliano Giovagnoli,CC BY-SA 4.0)

正如流程图所示,tracee-ebpf 执行的最早阶段之一是发现它正在运行的环境。第一个条件是 cmd/tracee-ebpf/initialize/bpfobject.go 文件的抽象,特别是 BpfObject() 函数发生的地方。程序执行一些检查以了解环境并根据其做出决策

  1. 给定 BPF 文件 BTF(vmlinux 或 env)存在:始终将 BPF 加载为 CO-RE
  2. 给定 BPF 文件 BTF 不存在:它是非 CO-RE BPF
  3. 未给定 BPF 文件 BTF(vmlinux 或 env)存在:将嵌入的 BPF 加载为 CO-RE
  4. 未给定 BPF 文件 BTF 不可用:检查嵌入的 BTF 文件
  5. 未给定 BPF 文件 BTF 不可用没有嵌入的 BTF:非 CO-RE BPF

以下是代码摘录

func BpfObject(config *tracee.Config, kConfig *helpers.KernelConfig, OSInfo *helpers.OSInfo) error {
	...
	bpfFilePath, err := checkEnvPath("TRACEE_BPF_FILE")
	...
	btfFilePath, err := checkEnvPath("TRACEE_BTF_FILE")
	...
	// Decision ordering:
	// (1) BPF file given & BTF (vmlinux or env) exists: always load BPF as CO-RE
        ...
	// (2) BPF file given & if no BTF exists: it is a non CO-RE BPF
        ...
	// (3) no BPF file given & BTF (vmlinux or env) exists: load embedded BPF as CO-RE
        ...
	// (4) no BPF file given & no BTF available: check embedded BTF files
	unpackBTFFile = filepath.Join(traceeInstallPath, "/tracee.btf")
	err = unpackBTFHub(unpackBTFFile, OSInfo)
	
	if err == nil {
		if debug {
			fmt.Printf("BTF: using BTF file from embedded btfhub: %v\n", unpackBTFFile)
		}
		config.BTFObjPath = unpackBTFFile
		bpfFilePath = "embedded-core"
		bpfBytes, err = unpackCOREBinary()
		if err != nil {
			return fmt.Errorf("could not unpack embedded CO-RE eBPF object: %v", err)
		}
	
		goto out
	}
	// (5) no BPF file given & no BTF available & no embedded BTF: non CO-RE BPF
	...
out:
	config.KernelConfig = kConfig
	config.BPFObjPath = bpfFilePath
	config.BPFObjBytes = bpfBytes
	
	return nil
}

此分析侧重于第四种情况,即 eBPF 程序和 BTF 文件未提供给 tracee-ebpf 的情况。在这一点上,tracee-ebpf 尝试加载 eBPF 程序,并从其嵌入的文件系统中提取所有必要的文件。即使在恶劣的环境中,tracee-ebpf 也能提供其运行所需的文件。这是一种高弹性模式,在没有任何条件满足时使用。

如您所见,在第四种情况分支中,BpfObject() 调用了这些函数

  • unpackBTFHub()
  • unpackCOREBinary()

它们分别提取

  • 底层内核的 BTF 文件
  • BPF CO-RE 二进制文件

解压缩 BTFHub

现在从 unpackBTFHub() 开始查看

func unpackBTFHub(outFilePath string, OSInfo *helpers.OSInfo) error {
	var btfFilePath string

	osId := OSInfo.GetOSReleaseFieldValue(helpers.OS_ID)
	versionId := strings.Replace(OSInfo.GetOSReleaseFieldValue(helpers.OS_VERSION_ID), "\"", "", -1)
	kernelRelease := OSInfo.GetOSReleaseFieldValue(helpers.OS_KERNEL_RELEASE)
	arch := OSInfo.GetOSReleaseFieldValue(helpers.OS_ARCH)

	if err := os.MkdirAll(filepath.Dir(outFilePath), 0755); err != nil {
		return fmt.Errorf("could not create temp dir: %s", err.Error())
	}

	btfFilePath = fmt.Sprintf("dist/btfhub/%s/%s/%s/%s.btf", osId, versionId, arch, kernelRelease)
	btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)
	if err != nil {
		return fmt.Errorf("error opening embedded btfhub file: %s", err.Error())
	}
	defer btfFile.Close()

	outFile, err := os.Create(outFilePath)
	if err != nil {
		return fmt.Errorf("could not create btf file: %s", err.Error())
	}
	defer outFile.Close()

	if _, err := io.Copy(outFile, btfFile); err != nil {
		return fmt.Errorf("error copying embedded btfhub file: %s", err.Error())

	}

	return nil
}

该函数的第一阶段是收集有关正在运行的内核的信息(osIdversionIdkernelRelease 等)。然后,它创建将托管 BTF 文件的目录(默认情况下为 /tmp/tracee)。它从 embed 文件系统中检索正确的 BTF 文件

btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)

最后,它创建并填充文件。

解压缩 CORE 二进制文件

unpackCOREBinary() 函数执行类似的操作

func unpackCOREBinary() ([]byte, error) {
	b, err := embed.BPFBundleInjected.ReadFile("dist/tracee.bpf.core.o")
	if err != nil {
		return nil, err
	}

	if debug.Enabled() {
		fmt.Println("unpacked CO:RE bpf object file into memory")
	}

	return b, nil
}

一旦主函数 BpfObject() 返回,tracee-ebpf 就可以通过 libbpfgo 加载 eBPF 二进制文件了。这在 pkg/ebpf/tracee.go 中的 initBPF() 函数中完成。以下是程序执行的配置

func (t *Tracee) initBPF() error {
        ...
        newModuleArgs := bpf.NewModuleArgs{
		KConfigFilePath: t.config.KernelConfig.GetKernelConfigFilePath(),
		BTFObjPath:      t.config.BTFObjPath,
		BPFObjBuff:      t.config.BPFObjBytes,
		BPFObjName:      t.config.BPFObjPath,
	}

	// Open the eBPF object file (create a new module)

	t.bpfModule, err = bpf.NewModuleFromBufferArgs(newModuleArgs)
	if err != nil {
		return err
	}
        ...
}

在这段代码中,我们正在初始化 eBPF 参数,填充 libbfgo 结构 NewModuleArgs{}。通过其 BTFObjPath 参数,我们能够指示 libbpf 使用先前由 BpfObject() 函数提取的 BTF 文件。

此时,tracee-ebpf 已准备好正常运行!

Illustration of the kernel

(Alessio Greggi 和 Massimiliano Giovagnoli,CC BY-SA 4.0)

eBPF 模块初始化

接下来,在执行 Tracee.Init() 函数期间,配置的参数将用于打开 eBPF 对象文件

Tracee.bpfModule = libbpfgo.NewModuleFromBufferArgs(newModuleArgs)

初始化探针

t.probes, err = probes.Init(t.bpfModule, netEnabled)

将 eBPF 对象加载到内核中

err = t.bpfModule.BPFLoadObject()

使用初始数据填充 eBPF 映射

err = t.populateBPFMaps()

最后,将 eBPF 程序附加到选定事件的探针

err = t.attachProbes()

结论

正如 eBPF 简化了内核编程的方式一样,CO-RE 正在解决另一个障碍。但是,利用这些功能有一些要求。幸运的是,借助 Tracee,Aqua Security 团队找到了一种在无法满足这些要求的情况下利用可移植性的方法。

与此同时,我们确信这仅仅是一个不断发展的子系统的开始,它将一次又一次地找到越来越多的支持,甚至在不同的操作系统中也是如此。

接下来阅读什么
me
Alessio 是一位 DevOps 工程师,在 Clastix 从事容器和自动化工作。他拥有网络安全背景,曾在国防部门的一家公司担任安全分析师。Alessio 拥有罗马托尔维加塔大学计算机科学学士学位,并且对开源和社区参与充满热情。
massimiliano_giovagnoli
Massimiliano Giovagnoli 一直对数学和计算机着迷,他的职业生涯始于 Web 开发人员,并且由于需要深入了解事物的工作原理,他的兴趣和经验转移到了基础设施设计和管理。

评论已关闭。

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 许可。
© . All rights reserved.