找回密码
 立即注册
搜索
查看: 207|回复: 0

[翻译] Windows平台上的eBPF技术简介

[复制链接]

233

主题

0

回帖

2083

积分

管理员

积分
2083
发表于 2025-9-11 13:30:03 | 显示全部楼层 |阅读模式
    在Linux领域,eBPF技术已存在多年。其目的是允许编写在Linux内核中运行的程序。然而,与标准内核模块不同,eBPF运行在一个受限的环境中,其应用程序接口(API)受到限制,以免对内核造成损害。此外,每个eBPF程序在获准执行前都必须经过验证,以确保其安全性(如内存安全、无无限循环等),且不会对系统造成任何损害。

    微软几年前启动了一个项目,并在GitHub上公开开展工作,旨在创建Windows版的eBPF。我们都知道,在Windows上运行内核驱动程序存在固有风险——任何此类驱动程序都可能以各种方式危及系统安全,更不用说导致系统崩溃(即“蓝屏死机”)了,2024年7月19日CrowdStrike事件所带来的惨痛教训就充分证明了这一点。然而,内核驱动程序无法被完全摒弃。微软所能做的最佳举措,就是竭尽全力确保内核驱动程序的可靠性和质量。而eBPF或许正是朝着这一方向迈出的良好一步,因为它不允许无限制地访问内核API。

    (eBPF 代表“扩展伯克利数据包过滤器(Extended Berkley Packet Filter)”,这是该技术最初的用途。如今,eBPF已不再代表任何特定含义,因为其应用范围已超越了网络数据包过滤。有关 eBPF 的起源,可在线搜索更多信息,或参阅 Liz Rice 所著的《Learning eBPF》一书。)

    eBPF-for-Windows 代码库根目录下的自述文件(Readme)很好地解释了 Windows 上 eBPF 的架构,以及如何入门使用。在本文中,我想展示一个构建 eBPF 程序、运行该程序并观察结果的示例。

免责声明:本文基于我对 Windows 版 eBPF(有限)的使用经验撰写。

入门指南

    在 Windows 上使用 eBPF 有几种入门方式,其中最简单的是使用发布版本中提供的 MSI 安装程序。在撰写本文时,最新版本为 0.20。你可以为虚拟机平台下载相应的 MSI 安装包,甚至可以直接下载包含所有构建产物的完整目录(Debug 或 Release 版本),就如同你自己构建的一样。这对于调试很有帮助(提供了 PDB 调试文件),而且如果你想进一步学习,拥有所有示例和测试用例也大有裨益。在此,为了简便起见,我将选择使用 MSI 安装程序。

    你需要配置一台处于测试签名模式的虚拟机,这样 eBPF 驱动程序(以及你编写的程序)才能在未经过可信证书签名的情况下加载运行。使用以下以管理员身份运行的命令行进入测试签名模式(需要重启):

    bcdedit /set testsigning on

    现在你可以安装 MSI 安装包,它将提供经典的 Windows 安装体验——在每个页面上只需点击“下一步”即可完成安装。


编写 eBPF 程序

    传统上,eBPF 程序是用 C 语言编写的,不过如今也有其他选择(如 Rust、Python 等),但我还是选择使用 C 语言。eBPF 程序会被编译成一种中间语言,利用 eBPF 虚拟机来运行。这使得经过编译的 eBPF 代码具有通用性,它是基于虚拟 CPU 的,因此后续可以编译成系统中实际目标处理器所支持的代码。此过程有两种模式可供选择:即时编译(JIT)和原生编译(Native)。在 JIT 模式下,在首次调用之前,会有某个实体(可能是内核的一部分,也可能是运行在用户模式下的某个实体,该实体随后将生成的代码推送到内核)对 eBPF 字节码进行编译。

    Windows 版 eBPF 的实现提供了一个用户模式服务,该服务能够对 eBPF 字节码(以 ELF 目标文件的形式提供)进行 JIT 编译,然后将结果推送到内核。不过,目前这种 JIT 模式正在逐步淘汰,未来可能支持也可能不支持。另一种选择是原生编译模式——字节码被编译成目标机器代码,并生成一个普通的可移植可执行文件(PE),它实际上就是一个内核驱动程序。在此阶段还会进行验证,如果验证失败,编译也会失败。

    所有这些技术细节究竟是如何运作的,超出了本文的讨论范围。eBPF-for-Windows 代码库中的文档应该会提供更多详细信息。我可能会在未来的文章中进一步阐述。

    要实际编写 eBPF 程序,我们可以使用任何文本编辑器,并借助 clang 编译器生成封装在 ELF 二进制文件中的 eBPF 字节码(eBPF-for-Windows 尽力与 Linux 的工作方式保持高度兼容,因此使用 clang 编译生成 ELF 文件,而非 PE 文件)。我们当然可以走这条路,但为了简化流程,我将使用 Visual Studio 和 NuGet 来方便地获取所需的头文件和库。

创建项目

    由于没有可用的 eBPF 或类似项目模板,我们创建一个新的 C++ 控制台应用程序。我们也可以编写普通的 C++ 代码(在程序正确编译后)将其加载到内核中,但本文不会这样做。相反,我们将使用 netsh 工具,该工具已通过一个由 eBPF 提供的 DLL 进行了扩展,允许加载程序以及执行其他一些操作。现在,我们继续使用 Visual Studio。我创建的项目名为 TraceConnections,其目的是统计每个进程发生的 TCP 连接操作次数。

    我将生成的 TestConnections.cpp 文件重命名为 TestConnection.c,这样我们就不使用任何 C++ 特性了——因为 eBPF 仅支持 C 语言。接下来,我们需要使用 eBPF 特定的头文件和其他工具——幸运的是,这些可以通过一个 NuGet 包获取。只需打开 NuGet 包管理器窗口,搜索“ebpf”。你会发现有三个针对 x86、ARM64 和 x64 架构的包。根据目标架构(很可能是 x64)选择相应的包并安装:
image-1.webp

    现在我们可以开始编写代码了。

编写 eBPF 程序
    我们从两个由 NuGet 包提供的头文件引入开始:
  1. #include <bpf_helpers.h>
  2. #include <ebpf_nethooks.h>
复制代码
    一个 eBPF 程序从一个函数开始,该函数可以任意命名,但必须根据程序的“类型”有特定的原型声明。就我们的目的而言,这是一个“绑定”到网络连接的程序。我们这样开始定义该函数:
  1. SEC("bind")
  2. bind_action_t TraceConnections(bind_md_t* ctx) {
复制代码
    函数名为 TraceConnections,它接受一个指针参数,并返回一个枚举值,用于指示是允许还是阻止该连接:
  1. typedef enum _bind_action {
  2.     BIND_PERMIT,   ///< Permit the bind operation.
  3.     BIND_DENY,     ///< Deny the bind operation.
  4.     BIND_REDIRECT, ///< Change the bind endpoint.
  5. } bind_action_t;
复制代码
    这能让你了解,如果我们想阻止某个连接,操作起来会多么简单。传入主函数的指针类型取决于我们所编写的“程序”类型。在本例中,该指针类型为 bind_md_t,它提供了有关该连接的详细信息:
  1. typedef struct _bind_md {
  2.     uint8_t* app_id_start;         ///< 指向应用 ID 起始位置的指针。
  3.     uint8_t* app_id_end;           ///< 指向应用 ID 结束位置的指针。
  4.     uint64_t process_id;           ///< 进程 ID。
  5.     uint8_t socket_address[16];    ///< 要绑定的套接字地址。
  6.     uint8_t socket_address_length; ///< 套接字地址的字节长度。
  7.     bind_operation_t operation;    ///< 要执行的操作。
  8.     uint8_t protocol;              ///< 协议号(例如,IPPROTO_TCP)。
  9. } bind_md_t;
复制代码
    我们获取到了一些基本信息,比如进程ID、进程名称和网络地址。SEC 宏将代码放置在名为“bind”的节(section)中,这是告知 eBPF 我们正在编写何种类型程序的一种方式。

    在此示例中,我们希望跟踪所有建立网络连接的进程,并统计此类连接的发生次数。为此,我们可以创建一个辅助结构体:
  1. typedef struct _process_info {
  2.     uint32_t id;      // 进程ID
  3.     char name[32];    // 进程名称
  4.     uint32_t count;   // 连接计数
  5. } process_info;
复制代码
    我们将跟踪进程 ID(process ID)、可执行文件名(executable name)以及计数本身(count)。接下来的问题是,所有这些信息将存储在哪里?

    eBPF 采用“映射(maps)”的概念进行工作,你可以将其视为键值对(key/value pairs),其中键可以根据映射类型通过多种方式进行管理。要定义一个映射,我们可以使用一些辅助宏构建一个结构体,并将任何变量置于生成的 ELF 目标文件的名为“.maps”的节(section)中。对于这个示例,我定义的映射如下:

  1. struct {
  2.     __uint(type, BPF_MAP_TYPE_HASH);    // 映射类型为哈希表
  3.     __type(key, uint32_t);             // 键的类型为 uint32_t(进程ID)
  4.     __type(value, process_info);        // 值的类型为 process_info 结构体
  5.     __uint(max_entries, 1024);         // 映射的最大条目数为 1024
  6. } proc_map SEC(".maps");                // 将该映射变量放在名为“.maps”的节中
复制代码
    type 表示映射类型(这里是一个哈希表),键(key)是进程 ID(用于唯一标识被跟踪的进程),值(value)是我们的 process_info 结构体。最后,max_entries 是给映射实现的一个提示,表明预期存储多少个条目。一个名为 proc_map 的全局变量代表我们的映射。现在,我们可以开始实现函数主体了。首先,我们只关注绑定到端口的操作(而不是解除绑定),并且总是允许连接继续进行:
  1. SEC("bind")
  2. bind_action_t TraceConnections(bind_md_t* ctx) {
  3.     if (ctx->operation == BIND_OPERATION_BIND) {  // 仅处理绑定操作
  4.         // 此处后续代码将处理映射逻辑
  5.     }
  6.     return BIND_PERMIT;  // 总是允许连接
  7. }
复制代码
    接下来,我们获取进程 ID,并在映射中查找它。如果该进程 ID 已存在于映射中,则只需增加计数即可:
  1. uint32_t pid = (uint32_t)ctx->process_id;
  2. process_info pi = { 0 };
  3. process_info* p = bpf_map_lookup_elem(&proc_map, &pid);
  4. if (p) {
  5.     p->count++;
  6. }
复制代码
    最后,我们需要用新创建或更新后的值来更新映射表:
  1. bpf_map_update_elem(&proc_map, &pid, p, 0);
复制代码
    参数依次为:映射表变量指针、键的指针、值的指针,以及用于控制更新行为的标志位(该标志位用于指示当键已存在时是否更新,或始终强制更新等操作);其中标志位设为0表示:若键已存在则更新,若不存在则创建新条目。

    这样就大功告成了!最后只需添加一个许可证声明(具体内容对本示例无实质影响,仅用于合规性验证):
  1. char LICENSE[] SEC("license") = "Dual BSD/GPL";
复制代码
    以下是完整的代码及其简单注释:
  1. // 引入eBPF辅助函数和网络钩子头文件
  2. #include <bpf_helpers.h>
  3. #include <ebpf_nethooks.h>
  4. // 宏定义:取两数最小值(用于安全拷贝字符串)
  5. #define MIN(x, y) ((x) < (y)) ? (x) : (y)
  6. // 进程信息结构体
  7. typedef struct _process_info {
  8.     uint32_t id;      // 进程ID
  9.     char name[32];    // 进程名称(固定32字节)
  10.     uint32_t count;   // 连接计数
  11. } process_info;
  12. // 定义哈希映射表(存储进程信息)
  13. struct {
  14.     __uint(type, BPF_MAP_TYPE_HASH);    // 映射类型:哈希表
  15.     __type(key, uint32_t);             // 键类型:进程ID(uint32_t)
  16.     __type(value, process_info);        // 值类型:process_info结构体
  17.     __uint(max_entries, 1024);         // 最大条目数:1024
  18. } proc_map SEC(".maps");                // 映射变量,存储在ELF的.maps节
  19. // eBPF程序入口(绑定端口事件钩子)
  20. SEC("bind")
  21. bind_action_t TraceConnections(bind_md_t* ctx) {
  22.     // 仅处理绑定操作(忽略解绑操作)
  23.     if (ctx->operation == BIND_OPERATION_BIND) {
  24.         uint32_t pid = (uint32_t)ctx->process_id;  // 获取进程ID
  25.         process_info pi = { 0 };                    // 临时进程信息结构体
  26.         
  27.         // 在映射表中查找当前进程
  28.         process_info* p = bpf_map_lookup_elem(&proc_map, &pid);
  29.         if (p) {
  30.             p->count++;  // 存在则计数+1
  31.         }
  32.         else {
  33.             // 不存在则创建新条目
  34.             pi.id = pid;
  35.             // 安全拷贝进程名称(防止缓冲区溢出)
  36.             memcpy(pi.name, ctx->app_id_start,
  37.                   MIN(sizeof(pi.name), ctx->app_id_end - ctx->app_id_start));
  38.             pi.count = 1;
  39.             p = &pi;  // 指向临时结构体(注意:实际应拷贝到堆或使用临时映射)
  40.         }
  41.         
  42.         // 更新映射表(存在则更新,不存在则插入)
  43.         bpf_map_update_elem(&proc_map, &pid, p, 0);
  44.     }
  45.     return BIND_PERMIT;  // 允许连接继续
  46. }
  47. // 许可证声明(eBPF程序必需)
  48. char LICENSE[] SEC("license") = "Dual BSD/GPL";
复制代码

编译程序

    这一步稍微有点复杂。首先,我们需要一个 clang 编译器。Visual Studio 安装程序提供了 clang 编译器及相关工具,但看起来它并不支持 eBPF 目标(可使用 clang -print-targets 命令来验证)。安装官方版的 LLVM 工具集(撰写本文时版本为 18.1.8)可以提供支持 eBPF 的 clang。请确保将 LLVM 的 bin 路径添加到系统 PATH 环境变量中,以便更方便地使用 clang(安装向导会提供此选项以便于操作)。

    要设置编译命令行,请在解决方案资源管理器中右键单击 TestConnections.c 文件,然后选择“属性”。在“常规”选项卡中,将“项类型”更改为“自定义生成工具”(选中所有平台和配置,这样后续就无需重复操作了):

image-2.webp

    点击“确定”,然后再次打开属性窗口以使其刷新。现在,你可以编辑自定义工具命令行了。以下是所需内容:
  1. clang.exe -target bpf -g -O2 -Werror -I"../packages/eBPF-for-Windows.x64.0.20.0/build/native/include" -c %(FileName).c -o $(OutDir)%(FileName).o
  2. pushd $(OutDir)
  3. powershell -NonInteractive -ExecutionPolicy Unrestricted $(SolutionDir)packages\eBPF-for-Windows.x64.0.20.0\build\native\bin\Convert-BpfToNative.ps1 -FileName %(Filename) -IncludeDir $(SolutionDir)packages\eBPF-for-Windows.x64.0.20.0\build\native\include -Platform $(Platform) -Configuration $(Configuration) -KernelMode $true
  4. popd
复制代码
   让我们逐一分析。第一步是调用 clang 编译器,将 eBPF 代码编译成 ELF 目标文件。其中,-g 选项用于添加调试信息,这有助于后续查看生成的代码(稍后会详细介绍);-target bpf 选项的含义不言而喻;-O2 选项用于启用某些必要的优化;-Werror 选项将警告视为错误,因此不能忽略警告;-I 选项用于指定 NuGet 包中 eBPF 辅助头文件所在的路径。

    接下来,需要将目标文件编译成本地 SYS 文件——即将 eBPF 程序打包到驱动程序 PE 文件中。此时,Convert-BpfToNative 命令行工具就派上用场了。它有一些严格的要求——不接受完整路径名,因此我们必须在继续操作之前切换到目标文件所在目录;这就是 pushd 和 popd 命令的作用。

    接下来,我们需要在“自定义生成工具”配置的“输出”行中设置两个输出文件:


(OutputPath)
(OutputPath)%(Filename).sys

    现在就可以进行构建了。如果一切顺利,输出文件夹中应该会生成一个 TestConnections.sys 文件。我们会将它复制到目标系统的某个位置(例如 c:\Test)。

加载与测试

    在目标系统中,以管理员身份打开命令提示符窗口,输入 netsh。接着输入 ebpf,进入 eBPF 扩展模块。

    输入 show programs,查看是否没有已加载的程序。现在输入 add program c:\Test\Testconnections.sys(注意文件名大小写需与实际一致)。如果操作成功,再次输入 show programs,你应该会看到类似以下内容:

netsh ebpf>show programs
  1. ID  Pins  Links  Mode       Type           Name
  2. ====== ==== ===== ========= ============= ====================
  3. 3     1        1 NATIVE     bind          TestConnections
复制代码
   我们已成功加载并运行了程序!你可以验证是否存在相关映射表(注意,你的映射表ID和程序ID可能会与示例不同):
netsh ebpf>show maps
  1.      ID            Map Type  Size   Size  Entries     ID  Pins  Name
  2. =======  ==================  ====  =====  =======  =====  ====  ========
  3.       2                hash     4     40     1024     -1     0  proc_map
复制代码
    我们可以使用一款我正在开发的名为 eBPF Studio 的工具来查看映射(map)数据。你可以下载最新版本并在目标系统上运行(注意:eBPF Studio 有发布版(Release)和调试版(Debug)两个版本。由于当前 C 运行时库(CRT)与 eBPF API 的链接方式,这两个版本都是必需的)。先运行发布版,如果崩溃了,再运行调试版。希望这个问题能在未来 Windows 版 eBPF 的发布中得到解决。

    运行 eBPF Studio 时,它会通过三个独立的选项卡显示当前已加载到内核中的程序、映射和链接(此处不做讨论)。如果你点击“映射(Maps)”选项卡,就可以选择一个映射,其内容将显示在下方区域:

image-3.png

    目前,该视图不会自动刷新。你必须点击“刷新(Refresh)”按钮来更新映射和程序视图。你可以清晰地看到映射中每个项的键(进程ID)和对应的值。

    你也可以使用 eBPF Studio 打开目标文件(ELF *.o 文件)并查看其内容。以下是查看 TestConnections.o 文件时会看到的内容:
image-4.png
    你可以看到源代码与 eBPF“机器”指令交错显示。注意,之前提到的 -g 编译选项能让你看到源代码与指令的对应关系。如果没有使用该选项,你将只能看到“汇编”指令。

扩展示例

    现在,你可以添加一些简单功能,比如基于进程ID(PID)或可执行文件名来阻止某个进程运行。我把这部分内容留给感兴趣的读者自行练习。

    完整源代码可在此处获取。

原文链接




您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表