深入探索BPF追踪技术:从零构建系统调用分析工具

本文详细介绍了如何使用BPF技术在Linux内核中编写追踪工具,包括BPF程序的基本结构、映射管理、性能事件输出等核心概念,并通过实际代码示例展示如何构建系统调用计数器和性能分析器。

All your tracing are belong to BPF - The Trail of Bits Blog

Alessandro Gario
November 09, 2021
ebpf
Originally published August 11, 2021

TL;DR: 这些更简单、分步的方法使您能够将BPF追踪技术应用于实际问题——无需专用工具或库。

BPF是Linux内核中的一种追踪技术,最初用于网络栈追踪,最近由于新的扩展使其能够在BPF原始范围之外实现新颖用例而变得流行。如今,它可用于实现程序性能分析工具、系统和程序动态追踪实用程序等。

在这篇博客文章中,我们将向您展示如何使用Linux的BPF实现来编写访问系统和程序事件的工具。IO Visor的优秀工具使用户能够轻松利用BPF技术,而无需花费大量时间用本地代码语言编写专用工具。

什么是BPF?

BPF本身只是一种表达程序的方式,以及用于"安全"执行该程序的运行时解释器。它是一组虚拟架构规范,详细说明了专用于运行其代码的虚拟机应如何行为。BPF的最新扩展不仅引入了新的、非常有用的辅助函数(如读取进程内存),还为BPF字节码引入了新寄存器和更多栈空间。

我们的主要目标是帮助您利用BPF并将其应用于实际问题,而不依赖于可能具有不同目标和需求的外部工具或库。

您可以在我们的存储库中找到本文中的示例。请注意,代码已简化以专注于概念。这意味着在可能的情况下,我们跳过了错误检查和适当的资源清理。

BPF程序限制

尽管我们不会手写BPF汇编,但了解代码限制很有用,因为如果我们违反其规则,内核验证器将拒绝我们的指令。

BPF程序极其简单,仅由单个函数组成。指令作为操作码数组发送到内核,这意味着不涉及可执行文件格式。没有节,不可能有全局变量或字符串字面量;一切都必须存在于栈上,栈最多只能容纳512字节。允许分支,但直到内核版本5.3,跳转操作码才能向后跳转——前提是验证器能够证明代码不会永远执行。

在不要求最新内核版本的情况下使用循环的唯一其他方法是展开它们,但这可能会使用大量指令,而较旧的Linux版本不会加载任何超过4096个操作码计数限制的程序(请参见linux/bpf_common.h下的BPF_MAXINSNS)。在某些情况下错误处理是强制性的,验证器将通过拒绝程序来阻止您使用可能初始化失败的资源。

这些限制非常重要,因为这些程序可以挂接到内核代码上。当验证器质疑代码的正确性时,可以防止系统崩溃或由于加载格式错误的代码而减慢速度。

外部资源

为了使BPF程序真正有用,它们需要与用户模式进程通信并管理长期数据,即通过映射和性能事件输出。

尽管存在许多映射类型,但它们本质上都像键值数据库一样行为,通常用于在用户模式和/或其他程序之间共享数据。其中一些类型将数据存储在每CPU存储中,使得在从不同CPU核心并发运行相同BPF程序时轻松保存和检索状态。

性能事件输出通常用于将数据发送到用户模式程序和服务,并实现为环形缓冲区。

事件源

没有一些要处理的数据,我们的程序将无所事事。Linux上的BPF探针可以附加到几个不同的事件源。出于我们的目的,我们主要对函数追踪事件感兴趣。

动态检测

与代码钩子类似,BPF程序可以附加到任何函数。探针类型取决于目标代码所在的位置。追踪内核函数时使用Kprobes,而处理用户模式库或二进制文件时使用Uprobes。

虽然Kprobe和Uprobe事件在进入受监视函数时发出,但每当函数返回时都会生成Kretprobe和Uretprobe事件。即使被追踪的函数有多个退出点,这也能正确工作。这种事件不转发类型化的系统调用参数,仅附带一个包含调用时寄存器值的pt_regs结构。需要了解函数原型和系统ABI才能将函数参数映射回正确的寄存器。

静态检测

在编写工具时,依赖函数钩子并不总是理想的,因为随着内核或软件的更新,破坏的风险会增加。在大多数情况下,最好使用更稳定的事件源,例如追踪点。

有两种类型的追踪点:

  • 一种用于用户模式代码(USDT,即用户级静态定义追踪点)
  • 一种用于内核模式代码(有趣的是,它们仅被称为"追踪点")。

两种类型的追踪点都由程序员在源代码中定义,本质上定义了一个稳定的接口,除非严格必要,否则不应更改。

如果已启用并挂载了DebugFS,注册的追踪点都将出现在/sys/kernel/debug/tracing文件夹下。与Kprobes和Kretprobes类似,Linux内核中定义的每个系统调用都带有两个不同的追踪点。第一个sys_enter在系统中的程序转换到内核内的系统调用处理程序时激活,并携带有关已接收参数的信息。第二个(也是最后一个)sys_exit仅包含函数的退出代码,并在系统调用函数终止时调用。

BPF开发先决条件

尽管不计划使用外部库,我们仍然有一些依赖项。最重要的是访问使用BPF支持编译的最新LLVM工具链。如果您的系统不满足此要求,可以使用——实际上鼓励使用——osquery工具链。您还需要CMake,因为我在示例代码中使用它。

在BPF环境中运行时,我们的程序使用需要至少4.18以上内核版本的特殊辅助函数。虽然可以避免使用它们,但这将严重限制我们从代码中可以做的事情。

使用Ubuntu 20.04或等效版本是一个不错的选择,因为它既具有良好的内核版本,又具有带有BPF支持的最新LLVM工具链。

一些LLVM知识很有用,但代码不需要任何高级LLVM专业知识。如果需要,官方网站上的Kaleidoscope语言教程是一个很好的介绍。

编写我们的第一个程序

有许多新概念要介绍,因此我们将从简单开始:我们的第一个示例加载一个程序,该程序返回而不执行任何操作。

首先,我们创建一个新的LLVM模块和一个包含我们逻辑的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
std::unique_ptr<llvm::Module> createBPFModule(llvm::LLVMContext &context) {
  auto module = std::make_unique<llvm::Module>("BPFModule", context);
  module->setTargetTriple("bpf-pc-linux");
  module->setDataLayout("e-m:e-p:64:64-i64:64-n32:64-S128");
  return module;
}

std::unique_ptr<llvm::Module> generateBPFModule(llvm::LLVMContext &context) {
  // 为BPF程序创建LLVM模块
  auto module = createBPFModule(context);

  // BPF程序由单个函数组成;我们暂时不关心参数
  llvm::IRBuilder<> builder(context);
  auto function_type = llvm::FunctionType::get(builder.getInt64Ty(), {}, false);

  auto function = llvm::Function::Create(
      function_type, llvm::Function::ExternalLinkage, "main", module.get());

  // 要求LLVM将此函数放在其自己的节中,以便在将其编译为BPF代码后更容易找到它
  function->setSection("bpf_main_section");

  // 创建入口基本块并使用我们编写的辅助函数组装printk代码
  auto entry_bb = llvm::BasicBlock::Create(context, "entry", function);

  builder.SetInsertPoint(entry_bb);
  builder.CreateRet(builder.getInt64(0));

  return module;
}

由于我们不处理事件参数,我们创建的函数不接受任何参数。除了返回指令之外,这里没有发生太多其他事情。请记住,每个BPF程序恰好有一个函数,因此最好要求LLVM将它们存储在单独的节中。这使得在模块编译后更容易检索它们。

我们现在可以使用LLVM的ExecutionEngine类将我们的模块JIT为BPF字节码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
SectionMap compileModule(std::unique_ptr<llvm::Module> module) {
  // 创建新的执行引擎构建器并配置它
  auto exec_engine_builder =
      std::make_unique<llvm::EngineBuilder>(std::move(module));

  exec_engine_builder->setMArch("bpf");

  SectionMap section_map;
  exec_engine_builder->setMCJITMemoryManager(
      std::make_unique<SectionMemoryManager>(section_map));

  // 创建执行引擎并构建给定模块
  std::unique_ptr<llvm::ExecutionEngine> execution_engine(
      exec_engine_builder->create());

  execution_engine->setProcessAllSections(true);
  execution_engine->finalizeObject();

  return section_map;
}

我们自定义的SectionMemoryManager类主要充当LLVM原始SectionMemoryManager类的直通——它只是为了跟踪ExecutionEngine对象在编译我们的IR时创建的节。

代码构建后,我们为模块内创建的每个函数获取一个字节向量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int loadProgram(const std::vector<uint8_t> &program) {
  // 程序需要知道它将如何被使用。我们只对追踪点感兴趣,因此我们将硬编码此值
  union bpf_attr attr = {};
  attr.prog_type = BPF_PROG_TYPE_TRACEPOINT;
  attr.log_level = 1U;

  // 这是我们从ExecutionEngine接收的(struct bpf_insn)指令数组
  // (有关更多信息,请参见compileModule()函数)
  auto instruction_buffer_ptr = program.data();
  std::memcpy(&attr.insns, &instruction_buffer_ptr, sizeof(attr.insns));

  attr.insn_cnt =
      static_cast<__u32>(program.size() / sizeof(struct bpf_insn));

  // 许可证很重要,因为如果它不兼容,我们将无法在BPF VM内调用某些辅助函数
  static const std::string kProgramLicense{"GPL"};

  auto license_ptr = kProgramLicense.c_str();
  std::memcpy(&attr.license, &license_ptr, sizeof(attr.license));

  // 验证器将在此处提供我们的BPF程序的文本反汇编。
  // 如果我们的代码有任何问题,我们还会找到一些诊断输出
  std::vector<char> log_buffer(4096, 0);
  attr.log_size = static_cast<__u32>(log_buffer.size());

  auto log_buffer_ptr = log_buffer.data();
  std::memcpy(&attr.log_buf, &log_buffer_ptr, sizeof(attr.log_buf));

  auto program_fd =
      static_cast<int>(::syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr)));

  if (program_fd < 0) {
    std::cerr << "Failed to load the program: " << log_buffer.data() << "\n";
  }

  return program_fd;
}

加载程序并不难,但您可能已经注意到,我们没有为正在使用的bpf()系统调用定义辅助函数。追踪点是最容易设置的事件类型,也是我们暂时使用的类型。

一旦发出BPF_PROG_LOAD命令,内核验证器将验证我们的程序,并在我们提供的日志缓冲区中提供其反汇编。如果内核输出长于可用字节,操作将失败,因此仅在生产代码中提供日志缓冲区(如果加载已经失败)。

attr联合中的另一个重要字段是程序许可证;指定GPL以外的任何值可能会禁用暴露给BPF的某些功能。我不是许可专家,但应该可以为生成器和生成的代码使用不同的许可证(但请先咨询律师和/或您的雇主!)。

我们现在可以使用我们构建的辅助函数组装main()函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main() {
  initializeLLVM();

  // 生成我们的BPF程序
  llvm::LLVMContext context;
  auto module = generateBPFModule(context);

  // 使用执行引擎将模块JIT为BPF代码
  auto section_map = compileModule(std::move(module));
  if (section_map.size() != 1U) {
    std::cerr << "Unexpected section count\n";
    return 1;
  }

  // 我们之前要求LLVM在特定节中创建我们的函数;从中获取我们的代码并加载它
  const auto &main_program = section_map.at("bpf_main_section");
  auto program_fd = loadProgram(main_program);
  if (program_fd < 0) {
    return 1;
  }

  releaseLLVM();
  return 0;
}

如果一切正常,当二进制文件以root用户身份运行时不会打印错误。您可以在配套代码存储库的00-empty文件夹中找到空程序的源代码。

但是……这个程序并不令人兴奋,因为它什么都不做!现在我们将更新它,以便在发生某些系统事件时执行它。

创建我们的第一个有用程序

为了实际执行我们的BPF程序,我们必须将它们附加到事件源。

创建新的追踪点事件很容易;它只涉及从debugfs文件夹下读取和写入一些文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int createTracepointEvent(const std::string &event_name) {
  const std::string kBaseEventPath = "/sys/kernel/debug/tracing/events/";

  // 这个特殊文件包含追踪点的id,这是初始化事件所需的
  // 使用perf_event_open
  std::string event_id_path = kBaseEventPath + event_name + "/id";

  // 读取追踪点id并将其转换为整数
  auto event_file = std::fstream(event_id_path, std::ios::in);
  if (!event_file) {
    return -1;
  }

  std::stringstream buffer;
  buffer << event_file.rdbuf();

  auto str_event_id = buffer.str();
  auto event_identifier = static_cast<int>(
      std::strtol(str_event_id.c_str(), nullptr, 10));

  // 创建事件
  struct perf_event_attr perf_attr = {};
  perf_attr.type = PERF_TYPE_TRACEPOINT;
  perf_attr.size = sizeof(struct perf_event_attr);
  perf_attr.config = event_identifier;
  perf_attr.sample_period = 1;
  perf_attr.sample_type = PERF_SAMPLE_RAW;
  perf_attr.wakeup_events = 1;
  perf_attr.disabled = 1;

  int process_id{-1};
  int cpu_index{0};

  auto event_fd =
      static_cast<int>(::syscall(__NR_perf_event_open, &perf_attr, process_id,
                                 cpu_index, -1, PERF_FLAG_FD_CLOEXEC));

  return event_fd;
}

要创建事件文件描述符,我们必须找到追踪点标识符,该标识符位于一个名为(毫不奇怪)“id"的特殊文件中。

对于我们的最后一步,我们将程序附加到刚刚创建的追踪点事件。这很简单,可以通过在事件的文件描述符上调用几个ioctl来完成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
bool attachProgramToEvent(int event_fd, int program_fd) {
  if (ioctl(event_fd, PERF_EVENT_IOC_SET_BPF, program_fd) < 0) {
    return false;
  }

  if (ioctl(event_fd, PERF_EVENT_IOC_ENABLE, 0) < 0) {
    return false;
  }

  return true;
}

我们的程序应该最终成功运行我们的BPF代码,但尚未生成任何输出,因为我们的模块实际上只包含一个返回操作码。生成一些输出的最简单方法是使用bpf_trace_printk辅助函数打印固定字符串:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void generatePrintk(llvm::IRBuilder<> &builder) {
  // bpf_trace_printk()函数原型可以在/usr/include/linux/bpf.h头文件中找到
  std::vector<llvm::Type *> argument_type_list = {builder.getInt8PtrTy(),
                                                  builder.getInt32Ty()};

  auto function_type =
      llvm::FunctionType::get(builder.getInt64Ty(), argument_type_list, true);

  auto function =
      builder.CreateIntToPtr(builder.getInt64(BPF_FUNC_trace_printk),
                             llvm::PointerType::getUnqual(function_type));

  // 在栈上分配8字节
  auto buffer = builder.CreateAlloca(builder.getInt64Ty());

  // 将字符串字符复制到64位整数
  static const std::string kMessage{"Hello!!"};

  std::uint64_t message{0U};
  std::memcpy(&message, kMessage.c_str(), sizeof(message));

  // 将字符存储在我们
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计