mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1898 字
5 分钟
eBPF 验证器:如何保证安全
2026-04-19

eBPF 最核心的设计决策是:绝不允许一个 eBPF 程序崩溃内核。这不是一句口号,而是由验证器(Verifier)在程序加载时严格保证的。验证器对 eBPF 字节码进行静态分析,检查每一条可能的执行路径,确保程序在所有情况下都是安全的。

理解验证器的工作原理,不仅能帮你写出能通过验证的程序,更能让你理解 eBPF 安全模型的边界——什么能做,什么不能做,以及为什么。

一、为什么需要验证器#

1.1 内核模块的教训#

传统的内核模块(LKM)没有任何加载时安全检查——一个有 Bug 的模块可以直接导致内核崩溃(Kernel Panic)、内存损坏、安全漏洞。历史上,大量内核漏洞源于内核模块:

  • 空指针解引用 → Kernel Panic
  • 越界内存访问 → 内存损坏
  • 无限循环 → 系统挂起
  • 竞态条件 → 数据损坏

1.2 eBPF 的安全哲学#

eBPF 选择了完全不同的安全模型:

flowchart TB subgraph 内核模块["内核模块(信任模型)"] LKM["加载内核模块"] -->|"无检查"| RUN1["直接运行"] RUN1 -->|"Bug"| CRASH[" 内核崩溃"] end subgraph eBPF["eBPF(验证模型)"] BPF["加载 eBPF 程序"] -->|"验证器检查"| VER{"验证通过?"} VER -->|"否"| REJECT["拒绝加载"] VER -->|"是"| RUN2["安全运行"] end style CRASH fill:#ffcdd2,stroke:#c62828 style REJECT fill:#fff9c4,stroke:#f9a825 style RUN2 fill:#c8e6c9,stroke:#2e7d32
Note

验证器是 eBPF 与内核模块最本质的区别。内核模块说”我相信你是安全的”,eBPF 说”我验证你是安全的”。

二、验证器的核心算法#

2.1 DAG 验证与路径探索#

验证器将 eBPF 程序建模为一个有向无环图(DAG),每个基本块(Basic Block)是一个节点,跳转指令形成边。验证器从入口开始,沿着所有可能的执行路径进行探索:

flowchart TB ENTRY["入口块<br/>insn 0-3"] --> B1["块 B1<br/>insn 4-7"] ENTRY --> B2["块 B2<br/>insn 8-11"] B1 --> B3["块 B3<br/>insn 12-15"] B2 --> B3 B1 --> B4["块 B4<br/>insn 16-19"] B3 --> EXIT["退出块<br/>insn 20"] B4 --> EXIT style ENTRY fill:#bbdefb,stroke:#1565c0 style EXIT fill:#c8e6c9,stroke:#2e7d32

验证器的路径探索策略:

  1. 深度优先搜索(DFS):从入口块开始,沿每条路径深入
  2. 状态合并:当多条路径汇合到同一块时,合并寄存器状态
  3. 剪枝:如果某条路径的状态与之前探索过的路径等价,则剪枝
  4. 有界探索:限制总探索的指令数和路径数

2.2 寄存器状态追踪#

验证器为每个寄存器维护精确的状态信息:

状态字段说明
type值类型(未初始化、标量、Map 指针、ctx 指针、栈指针等)
min_value最小可能值
max_value最大可能值
umin_value无符号最小值
umax_value无符号最大值
map_ptr指向的 Map 信息
raw原始类型信息
// 验证器中的寄存器状态(简化)
struct bpf_reg_state {
enum bpf_reg_type type; // NOT_INIT, SCALAR_VALUE, PTR_TO_CTX, ...
s64 min_value, max_value;
u64 umin_value, umax_value;
struct bpf_map *map_ptr;
// ... 更多字段
};

2.3 路径敏感分析#

验证器是路径敏感的——同一条指令在不同路径上的状态可能不同:

// 示例:路径敏感分析
SEC("xdp")
int example(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
// 路径 A:data + 14 <= data_end
if (data + 14 <= data_end) {
// 验证器知道:data_end - data >= 14
// 可以安全访问前 14 字节
struct ethhdr *eth = data;
__u16 proto = eth->h_proto; // 安全:验证器已证明
}
// 路径 B:data + 14 > data_end
// 不能安全访问以太网头
return XDP_PASS;
}

三、验证器的安全检查#

3.1 检查清单#

验证器执行以下安全检查:

检查类别检查内容失败后果
指令合法性操作码、寄存器编号是否有效拒绝加载
控制流无不可达代码、无无限循环拒绝加载
内存访问所有访问在边界内拒绝加载
指针运算指针不能做算术运算(有限例外)拒绝加载
Helper 调用函数号有效、参数类型匹配拒绝加载
栈使用不超过 512 字节、无未初始化读取拒绝加载
程序复杂度指令数、路径数在限制内拒绝加载
权限检查程序类型允许的操作拒绝加载

3.2 内存访问检查#

内存访问是验证器最核心的检查。eBPF 程序不能随意访问内核内存,所有访问必须通过以下方式之一:

flowchart TB MEM["eBPF 内存访问方式"] MEM --> CTX["上下文访问<br/>ctx + offset"] MEM --> MAP["Map 访问<br/>bpf_map_lookup_elem"] MEM --> STACK["栈访问<br/>r10 + offset"] MEM --> PKT["数据包访问<br/>data/data_end 指针"] CTX -->|"验证器检查<br/>offset 在结构体范围内"| OK1[" 允许"] MAP -->|"验证器检查<br/>返回值可能为 NULL"| OK2[" 允许(需 NULL 检查)"] STACK -->|"验证器检查<br/>offset 在栈范围内"| OK3[" 允许"] PKT -->|"验证器检查<br/>ptr < data_end"| OK4[" 允许"] style MEM fill:#e8eaf6,stroke:#283593 style OK1 fill:#c8e6c9,stroke:#2e7d32 style OK2 fill:#c8e6c9,stroke:#2e7d32 style OK3 fill:#c8e6c9,stroke:#2e7d32 style OK4 fill:#c8e6c9,stroke:#2e7d32

3.3 数据包边界检查#

网络程序中最常见的验证器交互是数据包边界检查:

SEC("xdp")
int parse_packet(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
// 错误:没有边界检查
// struct ethhdr *eth = data;
// __u16 proto = eth->h_proto; // 验证器拒绝!
// 正确:先做边界检查
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
// 验证器现在知道:data_end - data >= sizeof(*eth)
__u16 proto = eth->h_proto; // 安全!
// 解析 IP 头
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return XDP_PASS;
// 验证器现在知道可以访问 IP 头
__u8 protocol = iph->protocol; // 安全!
return XDP_PASS;
}
Warning

数据包边界检查必须使用 ptr + size > data_end 的模式。验证器只识别这种特定的比较模式。如果你使用 data_end - ptr >= size 或其他等价但不同形式的比较,验证器可能无法识别,导致拒绝加载。

四、复杂度限制#

4.1 指令数限制#

内核版本最大指令数说明
4.x4096早期限制
5.2+100 万大幅提升,支持复杂程序
5.16+100 万同上,但路径探索更高效

4.2 路径复杂度限制#

验证器限制探索的路径总数,防止指数爆炸:

// 内核中的复杂度限制
#define BPF_COMPLEXITY_LIMIT_STATES 64 // 每条指令最大状态数
#define BPF_COMPLEXITY_LIMIT_STACK 512 // 栈大小限制
#define BPF_COMPLEXITY_LIMIT_JMP_SEQ 8 // 连续跳转限制

4.3 循环的限制#

eBPF 程序不支持任意循环——验证器要求所有循环必须有界且可证明会终止:

// 错误:无限循环
while (1) { // 验证器拒绝:无法证明终止
// ...
}
// 错误:动态循环上限
for (int i = 0; i < count; i++) { // 验证器拒绝:count 未知
// ...
}
// 正确:常量循环上限(5.3+ 支持 bounded loops)
for (int i = 0; i < 10; i++) { // 验证器接受:上限已知
// ...
}
// 正确:使用 #pragma unroll 展开循环
#pragma unroll
for (int i = 0; i < 4; i++) {
// 编译器展开为 4 条独立指令
}
Note

Linux 5.3 引入了对有界循环(bounded loop)的支持,但循环次数必须是验证器可推断的常量。对于复杂的循环逻辑,建议使用尾调用或 Map 辅助。

五、常见验证失败原因与修复#

5.1 经典错误信息#

错误信息原因修复方法
invalid mem access越界访问添加边界检查
unreachable instruction不可达代码移除死代码
back-edge in program不可证明终止的循环使用有界循环
invalid stack type读取未初始化的栈变量初始化所有变量
helper call not allowedHelper 函数不允许检查程序类型
program is too complex路径数超限简化逻辑
invalid access to map value未检查 NULL 返回添加 NULL 检查

5.2 典型错误与修复#

错误 1:未检查 Map 查找的 NULL 返回

// 错误
SEC("kprobe/do_sys_open")
int bad_example(void *ctx)
{
u32 key = 1;
u64 *val = bpf_map_lookup_elem(&my_map, &key);
*val += 1; // 验证器拒绝:val 可能为 NULL
return 0;
}
// 正确
SEC("kprobe/do_sys_open")
int good_example(void *ctx)
{
u32 key = 1;
u64 *val = bpf_map_lookup_elem(&my_map, &key);
if (!val) // NULL 检查
return 0;
*val += 1; // 验证器知道 val 非 NULL
return 0;
}

错误 2:数据包边界检查不正确

// 错误:边界检查模式不正确
if (data_end - data < 14) // 验证器不识别这种模式
return XDP_PASS;
// 正确:使用指针比较
if (data + 14 > data_end) // 验证器识别的模式
return XDP_PASS;

错误 3:栈变量未初始化

// 错误:读取未初始化的栈变量
SEC("kprobe/sys_open")
int bad_example(void *ctx)
{
u32 key; // 未初始化
u64 *val = bpf_map_lookup_elem(&my_map, &key); // 验证器拒绝
return 0;
}
// 正确:初始化变量
SEC("kprobe/sys_open")
int good_example(void *ctx)
{
u32 key = 0; // 初始化
u64 *val = bpf_map_lookup_elem(&my_map, &key);
return 0;
}

5.3 调试验证器#

# 查看详细的验证日志
sudo bpftool prog load hello.bpf.o /sys/fs/bpf/hello \
type tracepoint name sys_enter_execve
# 如果验证失败,bpftool 会输出详细的验证日志
# 包括每条指令的寄存器状态
# 使用 libbpf 的调试模式
export LIBBPF_LOG_LEVEL=4
sudo ./hello
# 在代码中启用验证器日志
struct bpf_object_open_opts opts = {
.kernel_log_level = 2, // 1=stats, 2=verbose
};

六、验证器的演进#

6.1 关键里程碑#

timeline title eBPF 验证器演进 2014 : eBPF 验证器诞生 : 基本指令检查 : 4096 指令限制 2016 : 路径敏感分析 : 寄存器范围追踪 2018 : Map-in-Map 支持 : Socket Filter 增强 2019 : 有界循环支持 (5.3) : 100 万指令限制 (5.2) 2020 : LSM BPF 支持 (5.7) : 精确值追踪增强 2021 : bpf_timer (5.15) : bpf_kptr (5.16) 2022 : bpf_arena (6.x) : 更强的循环支持

6.2 验证器性能优化#

验证器本身也是性能优化的目标——对于复杂程序,验证可能耗时数秒:

优化内核版本效果
状态等价剪枝4.x避免重复探索相同状态
路径合并5.x合并等价路径减少状态数
寄存器范围追踪5.x更精确的值范围推断
按需验证5.x仅验证实际执行的路径

七、验证器与安全模型#

7.1 最小权限原则#

eBPF 验证器实现了最小权限原则——eBPF 程序只能执行其程序类型明确允许的操作:

程序类型可访问的上下文可调用的 Helper
XDPxdp_mdbpf_xdp_redirect, bpf_csum_diff, …
TC__sk_buffbpf_skb_store_bytes, bpf_l3_csum_replace, …
kprobept_regsbpf_get_stackid, bpf_perf_event_output, …
LSMlsm 上下文bpf_inode_storage_get, bpf_task_storage_get, …

7.2 能力边界#

flowchart TB subgraph eBPF可以做["eBPF 可以做"] A1["读取上下文数据"] A2["读写 Map"] A3["调用允许的 Helper"] A4["修改数据包(网络程序)"] A5["返回决策(PASS/DROP)"] end subgraph eBPF不能做["eBPF 不能做"] B1["任意访问内核内存"] B2["调用任意内核函数"] B3["无限循环"] B4["修改内核数据结构"] B5["阻塞等待"] end style eBPF可以做 fill:#e8f5e9,stroke:#2e7d32 style eBPF不能做 fill:#fce4ec,stroke:#c62828

八、动手实践#

8.1 触发验证器拒绝#

// unverified.bpf.c — 故意触发验证器错误
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int bad_program(struct xdp_md *ctx)
{
// 错误 1:没有边界检查的数据包访问
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
__u16 proto = eth->h_proto; // 验证器拒绝!
return XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";
# 编译并尝试加载
clang -O2 -target bpf -c unverified.bpf.c -o unverified.bpf.o
sudo bpftool prog load unverified.bpf.o /sys/fs/bpf/bad type xdp
# 预期输出:
# Error: invalid mem access 'scalar'

8.2 查看验证器日志#

# 使用 libbpf 的详细日志模式
sudo bpftool prog load -v hello.bpf.o /sys/fs/bpf/hello type xdp
# 输出验证过程的详细信息:
# func#0 @0
# 0: R1=ctx(off=0,imm=0) R10=fp0
// 1: (bf) r6 = r1
// R1=ctx(off=0,imm=0) R6=ctx(off=0,imm=0)
// 2: (61) r2 = *(u32 *)(r6 +0)
// R2=scalar(umin=0,umax=4294967295)

8.3 对比有界循环与无界循环#

// bounded_loop.bpf.c — 有界循环(5.3+ 通过验证)
SEC("xdp")
int bounded(struct xdp_md *ctx)
{
int sum = 0;
#pragma unroll
for (int i = 0; i < 4; i++) {
sum += i;
}
return sum ? XDP_PASS : XDP_DROP;
}
// unbounded_loop.bpf.c — 无界循环(验证器拒绝)
SEC("xdp")
int unbounded(struct xdp_md *ctx)
{
int sum = 0;
while (sum < 100) { // 验证器无法证明终止
sum += 1;
}
return XDP_PASS;
}

九、本章小结#

上一章建立了eBPF 虚拟机与指令集的认知框架。

主题核心要点关键词
DAG 验证将程序建模为有向无环图,探索所有可能的执行路径DAG, 路径探索
寄存器状态追踪精确追踪每个寄存器的类型、值范围类型追踪, 值范围
路径敏感分析同一条指令在不同路径上的状态可能不同路径敏感, 状态分支
内存访问检查所有内存访问必须在验证器可证明的边界内边界证明, 安全访问
复杂度限制指令数、路径数、循环次数都有明确上限1M 指令, 路径上限
常见错误NULL 检查缺失、边界检查模式不正确、未初始化变量NULL 检查, 边界模式

验证器是 eBPF 安全的守护者——理解它的工作原理,才能写出既安全又能通过验证的 eBPF 程序。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

eBPF 验证器:如何保证安全
https://blog.souloss.com/posts/ebpf/verifier/
作者
Souloss
发布于
2026-04-19
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

相关文章 智能推荐
1
eBPF 安全:LSM 与进程监控
eBPF eBPF 不仅是一种可观测性工具,更是一种安全执行机制——LSM BPF 允许在内核安全检查点挂载 eBPF 程序,实现细粒度的安全策略;Tetragon 基于 eBPF 实现实时进程监控和运行时安全。本章详解 LSM BPF 的架构、策略编写、Tetragon 的部署与使用,以及 eBPF 安全的最佳实践。
2
综合实战:构建 eBPF 网络安全工具
eBPF 综合实战——从零构建 eBPF 网络安全工具——XDP 防 DDoS + TC 流量控制 + LSM 进程监控,运用前 17 章知识解决真实安全问题。
3
系列导读
eBPF 本系列从 eBPF 的底层原理出发,系统讲解 eBPF 虚拟机、验证器、Map 数据结构、Hook 机制、CO-RE 可移植性,再到 XDP/TC 网络处理、LSM 安全、Cilium 实践、Wasm 融合、Kubernetes 集成与生产部署,带你从「听说过 eBPF」进阶到「能用 eBPF 解决真实问题」。
4
eBPF Hook 点:kprobe/tracepoint/uprobe
eBPF eBPF 程序的价值在于它能挂载到内核的各种检查点——Hook 点。本章详解三大 Hook 机制——kprobe(动态内核函数追踪)、tracepoint(静态追踪点)、uprobe(用户态函数追踪),以及 USDT 静态用户态追踪点,并通过实战代码展示每种 Hook 的使用方式与适用场景。
5
eBPF 可观测性
eBPF eBPF 最大的应用场景是可观测性——零侵入、低开销、内核级的全链路追踪。本章详解三大可观测性工具链——bpftrace(一行命令追踪内核)、BCC(Python 前端 + 丰富工具集)、Beyla(零侵入应用性能监控),并通过实战展示性能分析、分布式追踪、应用性能监控的完整工作流。