通过使用Linux eBPF(伯克利数据包过滤器)技术追踪进程,Tracee可以将收集到的信息进行关联,并识别恶意行为模式。
eBPF
BPF是一个帮助进行网络流量分析的系统。后来的eBPF系统扩展了经典的BPF,以提高Linux内核在不同领域的可编程性,如网络过滤、函数挂钩等。由于其基于寄存器的虚拟机被嵌入到内核中,eBPF可以执行用限制性C语言编写的程序,而不需要重新编译内核或加载模块。通过eBPF,你可以在内核上下文中运行你的程序,并钩住内核路径中的各种事件。要做到这一点,eBPF需要对内核正在使用的数据结构有深入的了解。
eBPF CO-RE
eBPF与Linux内核ABI(应用程序二进制接口)接口。从eBPF虚拟机访问内核结构取决于具体的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 选项来构建,以便提供暴露BTF格式的内核类型的/sys/kernel/btf/vmlinux 接口。这允许libbpf解析和匹配所有的类型和字段,并更新必要的偏移量和其他可重定位的数据,以确保eBPF程序对目标主机上的特定内核正常工作。
问题所在
当一个eBPF程序被写成可移植的,但目标内核没有暴露/sys/kernel/btf/vmlinux 接口时,问题就出现了。
为了在不同的内核中加载和运行一个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,有两个选择。
-
为你的内核建立和安装eBPF对象。这取决于Clang和特定内核版本的kernel-headers软件包的可用性。
-
从BTFHUB为你的内核版本下载BTF文件,并通过
TRACEE_BTF_FILE环境变量将其提供给tracee-ebpf's loader。
第一个选项不是一个CO-RE解决方案。它编译了eBPF二进制文件,包括一长串的内核头文件。这意味着你需要在目标系统上安装内核开发包。另外,这个解决方案需要在你的目标机器上安装Clang。Clang编译器可能是资源密集型的,所以编译eBPF代码可能会使用大量的资源,可能会影响到精心平衡的生产工作负载。也就是说,在你的生产环境中避免出现编译器是一个好的做法。这可能会导致攻击者成功地建立一个漏洞并进行权限升级。
第二个选择是一个CO-RE解决方案。这里的问题是,你必须在你的系统中提供BTF文件,以使Tracee工作。整个档案有近1.3GB。当然,你可以为你的内核版本提供恰到好处的BTF文件,但在处理不同的内核版本时,这可能会很困难。
最后,这些可能的解决方案也会带来问题,而这正是Trace发挥其魔力的地方。
一个可移植的解决方案
通过一个不复杂的构建程序,即使目标环境不提供BTF信息,Tracee项目也能将二进制文件编译为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代码运行所需的内容。这种减少允许Tracee嵌入所有这些文件,这些文件现在更轻(只有几千字节),支持没有暴露/sys/kernel/btf/vmlinux 接口的内核。
Tracee构建
下面是Tracee构建的执行流程。

图片由:
(Alessio Greggi and 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的仓库。
btfhubbtfhub-archive
一旦下载并放置在3rdparty 目录中,该程序将执行下载的脚本3rdparty/btfhub/tools/btfgen.sh 。这个脚本生成缩小的BTF文件,为tracee.bpf.core.o eBPF二进制文件量身定做。
该脚本从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.oeBPF对象- BTF还原文件(适用于所有内核版本)
tracee-ebpfGo源代码
在这一点上,go build 被调用来完成它的工作。在embedded-ebpf.go 文件中,你可以找到以下代码。
//go:embed "dist/tracee.bpf.core.o"
//go:embed "dist/btfhub/*"
在这里,Go编译器被指示将eBPF CO-RE对象与所有BTF-reduced文件嵌入其内部。一旦编译完成,这些文件将可以使用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运行的执行流程。

图片由:
(Alessio Greggi and Massimiliano Giovagnoli, CC BY-SA 4.0)
如流程图所示,tracee-ebpf 执行的最初阶段之一是发现它所运行的环境。第一个条件是对cmd/tracee-ebpf/initialize/bpfobject.go 文件的抽象,特别是BpfObject() 函数发生的地方。程序进行一些检查以了解环境并根据环境做出决定。
- 给出了BPF文件并且存在BTF(vmlinux或env):总是以CO-RE的形式加载BPF
- 给出了BPF文件但没有BTF存在:它是一个非CO-RE的BPF
- 没有给出BPF文件,但存在BTF(vmlinux或env):将嵌入式BPF加载为CO-RE
- 没有给出BPF文件,也没有BTF可用:检查嵌入式BTF文件
- 没有给出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
}
该函数有一个第一阶段,它收集关于运行中的内核的信息(osId,versionId,kernelRelease, 等等)。然后,它创建了将承载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二进制文件。这是在initBPF() 函数中完成的,在pkg/ebpf/tracee.go 。这里是程序执行的配置。
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的args,填充libbfgo结构NewModuleArgs{} 。通过它的BTFObjPath 参数,我们能够指示libbpf使用之前由BpfObject() 函数提取的BTF文件。
在这一点上,tracee-ebpf 已经准备好正常运行了!

Image by:
(Alessio Greggi and 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团队找到了一种在这些要求无法满足的情况下利用可移植性的方法。
同时,我们确信这只是一个不断发展的子系统的开始,它将一次又一次地找到越来越多的支持,甚至在不同的操作系统中。