本节摘要:eBPF 程序不能直接调用内核任意函数。它只能调用一组按程序类型开放的 Helper:查 Map、读当前任务、安全拷贝内核内存、改包、输出事件。验证器按契约检查参数。把 Helper 当成 libc,是验证失败和安全事故最常见的源头之一。
阅读完本节,你应当能够:
第 2 章说 R1 是上下文,不是完整内核。那 task_struct 里的 comm、包上的端口、当前 cgroup,怎么拿?答案是 Helper:内核写好的、带契约的入口。你不是在内核里“调用函数”,你是在按批准过的按钮。
用户态 C 里 memcpy 想拷多少拷多少,越界是你的进程的事。内核里同样的事能把别的进程的页毁掉。所以 eBPF 不提供通用拷贝,而提供 bpf_probe_read_kernel 一类:目标大小在验证期或运行期被卡住,失败返回错误而不是 oops。
不同程序类型看到的按钮不同。XDP 能改包、能重定向;普通 kprobe 通常不能。LSM 程序能做允许/拒绝;socket filter 不能杀进程。这不是文档疏忽,是把第 1.2 节“全权模块”拆成多把专用钥匙。
电网调度室的比喻在这里更贴:切负荷按钮存在,拆汽轮机的扳手不存在。你抱怨扳手没有,是因为扳手一旦给你,验证器就无法证明安全。
读。 bpf_get_current_pid_tgid、bpf_get_current_comm、bpf_probe_read_kernel、bpf_probe_read_user。追踪类几乎离不开它们。直接 p->mm->exe_file 在模块里常见,在 eBPF 里通常过不了验证:指针链太长,生命周期不明。CO-RE 让字段偏移可重定位,但不等于允许无界解引用。还是要走读 Helper 或被验证器认可的上下文字段。
记。 Map 系列:lookup、update、delete。原子加在计数场景里比“读改写”更不容易丢更新。记住返回值:lookup 失败得到空,你必须分支,验证器靠这个分支才相信后续解引用。
改。 网络侧 bpf_skb_store_bytes、调整包空间、重定向;追踪侧偶发 bpf_override_return(依赖内核配置与权限)。改比读危险,开放范围更窄。能在用户态改策略就不要在内核改控制流。
送。 bpf_perf_event_output 与 ringbuf 提交。送的是给用户态的证据,不是给内核的状态。大记录会打满管道,回到 3.1 的丢失问题。
| 类别 | 代表能力 | 典型程序类型 | 主要风险 |
|---|---|---|---|
| 读 | 当前进程、安全拷贝 | 追踪、LSM | 读用户内存可能失败;忽略返回值 |
| 记 | Map 查找更新 | 几乎全部 | 满员、键设计、并发 |
| 改 | 改包、改返回值 | XDP/TC/部分追踪 | 语义破坏;权限过宽 |
| 送 | 事件外送 | 追踪、网络观测 | 管道满导致静默丢失 |
| 时间与随机 | 时间戳、随机数 | 观测与采样 | 用错时钟源导致对不齐 |
⚠️ 常见坑:忽略
bpf_probe_read_*的返回值,继续用缓冲区。验证器有时能拦,拦不住时你把垃圾当文件名,安全产品会自己变成噪音源。
💡 关键直觉:Helper 失败是正常控制流,不是异常。内核对象随时可能消失,读失败要当成“这次跳过”,不要当成“内核坏了”。
钩子里能做不等于应该做。我用三条尺子:
第一,是否必须在该瞬间完成。丢包、拒绝系统调用,必须在瞬间完成。字符串匹配一整份策略树,通常可以在用户态算完,内核只查哈希命中。
第二,验证器是否开始抱怨路径。为了过验证去写一堆 #pragma unroll 和手工边界,维护成本会超过收益。说明这段逻辑不该在字节码里。
第三,Helper 集合是否在暗示你越权。你发现自己在 kprobe 里找“怎么改调度器时间片”,这不是 Helper 缺了,是程序类型选错或需求不该用 eBPF。
瞬间决策 → Helper 在钩子里做完 批量匹配 → 用户态写入 Map,内核 O(1) 查 事后分析 → 送出事件,离开热路径
许可证与能力位也会裁剪 Helper。未签名、无 CAP_BPF 的环境,能用的按钮更少。开发机全能、生产收紧,是第 8 章部署要提前对齐的差异,不是运行时再发现。
有跟踪打印 Helper,但它会打到公共跟踪缓冲,有速率限制,生产热路径一开就能把观测自己打崩。它是调试火柴,不是日志系统。正式外送走环形缓冲区。
会增加,也会有废弃。CO-RE 主要修结构体布局,不修“这个 Helper 被删了”。要对内核版本矩阵做能力探测:没有的按钮就降级,而不是假设开发机内核等于生产。
拿当前进程的 pid 和 comm 看起来像一句话的事,实际是一串契约。你先调用取 pid 的 Helper,得到的是当前上下文里的任务,不一定是“你心里的那个应用线程”——在软中断或某些网络钩子里,当前任务可能是 ksoftirqd。把这个 pid 当业务归属,会把一堆包算到内核线程头上。网络类问题常常要沿套接字找所有者,而不是信当前任务。
comm 只有短短十六字节量级的截断名。同名进程、截断后的长名字,都会让安全规则误伤。更稳的归属是 cgroup id 或容器运行时写入的标签 Map。Helper 能给你的是内核当时愿意给的,不是业务身份的全部。身份要在用户态对齐运行时,再下发到策略表。
读用户内存更苛刻。指针可能被并发 munmap,读失败是正常的。失败时不要把栈上未写满的缓冲当字符串送出,否则你会得到上次留下的残渣。每次先清缓冲或检查返回长度。验证器关心你不要越界,不关心你把垃圾当文件名。产品正确性是你的义务。
改包 Helper 有另一套契约:你改了长度,就要保证后续校验和与卸载硬件仍一致。有的网卡校验和卸载会和你的改写打架。改完却不更新校验,表现为对端乱序或连接重置,排障会先骂应用。能不在内核改写应用协议,就不要改。XDP 丢垃圾包通常比改 HTTP 头更安全。
把常用 Helper 的失败模式写进团队备忘:哪些会返回空,哪些会截断,哪些只在特定程序类型存在。新人最容易从用户态 libc 习惯出发,把每个调用当必胜。在 eBPF 里,必胜的调用几乎没有,分支才是默认形状。
网络钩子里用当前任务的 comm 做归属,大量流量被算到 ksoftirqd。业务面板上某个内核线程成了头部客户。改成从套接字找所有者、找不到就记为未知,面板才像人话。Helper 给的是当前上下文,不是业务语义。业务语义要自己用 Map 对齐运行时。
读用户内存失败没检查,文件名事件里出现上一次的路径残渣,安全规则对着残渣告警,误报爆炸。后来每次先清空再读,失败则丢弃事件。验证器不管你把垃圾当证据,产品必须管。失败分支不是啰嗦,是正确性。
改包后没处理校验和,对端重置连接,应用组被骂了两天。最后发现是钩子改了长度。能丢则丢,能在用户态改则用户态改。内核改写应用协议,排障链条会跨过太多组,成本高于那次“巧妙的改写”。巧妙在复盘里通常是骂人的词。
Helper 随时可能失败。读用户内存、lookup、改包,都要当普通控制流处理。失败还继续用缓冲,就是用残渣当证据。残渣会让安全产品自己变成噪音源。当前任务不等于业务归属,网络钩子里尤其如此。归属用套接字和 cgroup 对齐运行时,对不齐就记未知。未知比错名诚实。错名会让内核线程成为头部客户,面板失去意义。改包还要管校验和与卸载硬件,管不了就不要改,能丢则丢。把常用失败模式写成备忘:谁返回空,谁截断,谁只在某程序类型存在。新人从 libc 习惯出发会假设必胜。在这里必胜几乎没有。没有失败分支的调用,不进热路径。热路径上的乐观,是误报和重置连接的预付款。预付款总会被催。
把 Helper 当标准库,是从用户态搬习惯时最容易犯的错。标准库失败少,失败了进程自己死。这里失败多,失败了内核还要继续为别人服务。所以每次调用都是一次可能的空。空不是尴尬,是设计。设计要求你写分支。分支写烦了,说明这段逻辑不该在钩子里。钩子里只留那些失败了就跳过也没关系、或失败了就必须放弃本次决策的动作。烦是一种信号。信号比再封装一层假必胜 API 诚实。
下一章决定火花在哪一拍点燃:追踪、网络、系统三类挂载点,选错瞬间就选错了整场观测。