4. eBPF验证器与安全边界

eBPF 程序能安全地跑在内核里,靠的不是“信任程序员”,而是加载时由内核验证器(verifier)做一次静态审查。验证器不运行程序,而是沿着所有可能的控制流路径,逐条指令地模拟执行,检查寄存器类型、栈与包内存访问、循环边界和辅助函数调用是否合法。只有证明“无论输入如何,程序都不会越界、不会死循环、不会泄漏内核指针”,程序才会被 JIT 编译并挂载。理解验证器的判定逻辑,是写出能通过加载的网络程序的前提。

验证器的工作原理:抽象解释与寄存器状态

验证器把 eBPF 虚拟机抽象成一组寄存器(r0–r10)和一块 512 字节栈。r1–r5 是调用辅助函数时的参数寄存器,r6–r9 是被调用者保存寄存器,r10 是只读栈帧指针。每条指令执行时,验证器维护每个寄存器的“类型状态”,例如 SCALAR(标量值)、PTR_TO_CTX(指向上下文)、PTR_TO_PACKET(指向包数据)、PTR_TO_MAP_VALUE(指向 map 值)等,并记录标量的取值范围(如 umin=0, umax=1500)。

以 XDP 程序读取以太网头为例:r1 初始为 PTR_TO_CTX,调用 bpf_xdp_adjust_head 或直接做 data/data_end 边界检查后,才能把包指针转成 PTR_TO_PACKET。验证器要求:任何对包内存的读写,都必须先证明“指针 + 偏移 + 长度 ≤ data_end”。这个证明依赖前面的比较分支,验证器会把分支条件记录为路径约束。

状态剪枝:为什么复杂程序也能通过

如果验证器穷举所有路径,状态数会指数爆炸。它采用状态剪枝:当两条不同路径到达同一指令时,若寄存器类型和取值范围满足“新状态被已有状态包含”(即新状态更宽松或相等),则新路径不再继续探索。这就是 states 去重。剪枝让带分支的包解析程序能在有限时间内验证完。

但剪枝也带来一个常见坑:如果某条路径上寄存器范围被“放宽”了,验证器可能认为后续访问不安全。例如先判断 if (len > 100) 再访问偏移 100,验证器知道此时 len ≥ 101;若把判断写成 if (len >= 100),则 len 可能等于 100,访问偏移 100 的 1 字节仍安全,但访问 2 字节就越界。范围边界必须精确。

循环限制与有界循环

早期 eBPF 完全禁止循环,因为无法证明终止。内核 5.3 起支持有界循环(bounded loop),验证器要求循环变量有可证明的上界,且循环体内状态收敛。写法上通常用 #pragma unroll 让编译器展开,或写成 for (int i = 0; i < MAX; i++) 且 MAX 是编译期常量。若循环上界来自包数据(如 while (i < pkt_len)),验证器无法证明终止,会直接拒绝。

内存访问检查:栈、包与 map

三类内存各有规则。栈访问必须偏移在 [-512, -1] 且对齐;未初始化栈读取会被拒绝,需先 memset 或赋值。包访问必须做 data/data_end 检查,且检查后指针不能被“污染”。map 值访问要先 bpf_map_lookup_elem 并判空,否则空指针解引用会被拒。下面这张图概括了验证器对一条指令的判定链与状态剪枝关系。

eBPF 验证器判定链与状态剪枝 加载 eBPF 字节码 bpf(BPF_PROG_LOAD) 逐指令抽象解释 维护寄存器类型与范围 状态是否安全? 类型/范围/边界 状态剪枝去重 已包含则停止探索 拒绝加载 越界/未初始化/无界循环 否 JIT 编译并挂载 XDP/TC 运行 是 三类内存访问检查规则 栈(512B) • 偏移范围 [-512, -1] • 访问需对齐 • 先初始化再读取 • r10 只读,不可写 ✗ 读未初始化栈 → 拒绝 包数据(XDP/TC) • 先取 data / data_end • 证明 ptr+off+len ≤ data_end • 分支后范围要精确 • 不可跨包边界读 ✗ 缺 data_end 检查 → 拒绝 Map 值 • lookup 后必须判空 • 空指针解引用被拒 • 值大小与 map 定义一致 • 不可越界写 value ✗ 未判空直接读 → 拒绝

编写可通过验证器的安全网络程序

下面是一个 XDP 程序示例,统计 IPv4 包并按协议计数。它演示了边界检查、map 判空和有界循环的写法。参数为教学示例,实际部署需按网卡 MTU 调整。

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 256);
    __type(key, __u32);
    __type(value, __u64);
} proto_cnt SEC(".maps");

SEC("xdp")
int xdp_proto_count(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)   /* 边界检查 */
        return XDP_PASS;
    if (eth->h_proto != __constant_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end)    /* 再检查 */
        return XDP_PASS;

    __u32 key = ip->protocol;
    __u64 *val = bpf_map_lookup_elem(&proto_cnt, &key);
    if (!val)                            /* map 判空 */
        return XDP_PASS;

    __sync_fetch_and_add(val, 1);        /* 原子累加 */
    return XDP_PASS;
}
char _license[] SEC("license") = "GPL";

这段代码能通过验证器的关键点:每次指针推进后都重新做 > data_end 检查;lookup 返回值判空;没有无界循环;__sync_fetch_and_add 是内核提供的原子操作,验证器认可。若把 if ((void *)(ip + 1) > data_end) 删掉,加载时会报 invalid access to packet, off:... r1 min value is outside of the allowed memory range。

调试技巧:用 bpftool prog load xdp.o /sys/fs/bpf/xdp type xdp 加载,失败时加 --log-level 2 查看验证器日志;日志会逐条打印寄存器状态,是定位越界最直接的手段。
常见拒绝原因:包指针检查后又被算术运算“污染”;循环上界来自包长度;栈变量未初始化;map 值指针未判空;辅助函数参数类型不匹配。遇到 R1 type=scalar expected=fp 通常是栈指针被当普通标量用了。

总结成一条可执行原则:先证明边界,再访问内存;先判空,再解引用;循环上界必须是编译期常量或可证明收敛的标量。把这三条写进编码习惯,绝大多数网络程序都能一次通过验证器。