Linux教程——Tracee如何解决BTF信息不足的问题

1,002 阅读11分钟

通过使用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,有两个选择。

  1. 为你的内核建立和安装eBPF对象。这取决于Clang和特定内核版本的kernel-headers软件包的可用性。

  2. 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构建的执行流程。

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

图片由:

(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的仓库。

  • btfhub
  • btfhub-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.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-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运行的执行流程。

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 and Massimiliano Giovagnoli, CC BY-SA 4.0)

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

  1. 给出了BPF文件并且存在BTF(vmlinux或env):总是以CO-RE的形式加载BPF
  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
}

该函数有一个第一阶段,它收集关于运行中的内核的信息(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 已经准备好正常运行了!

Illustration of the kernel

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团队找到了一种在这些要求无法满足的情况下利用可移植性的方法。

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