本节摘要:验证器给单程序的指令数、栈和路径数都有上限。复杂协议解析和多级策略不能塞进一个函数硬展开。尾调用把控制转给另一个程序并放弃返回;子程序允许受控调用;循环必须让上界可见。组合是为了可证明,不是为了把用户态状态机原样搬进内核。
阅读完本节,你应当能够:
第 5 章说路径爆炸时要拆程序。拆法有三种常用:尾调用、子程序、把活送回用户态。前两种仍在内核里,第三种往往最正确。
尾调用通过程序数组 Map,跳到另一个同类程序。当前栈帧结束,像汇编里的跳转,不是 call。适合流水线:第一段解析包头,第二段查策略,第三段出事件。每段自己通过验证,比一段巨无霸容易。
代价:不回来,难共享大量栈上临时量;有深度上限;跳转键写错会静默少跑一段。调试时必须给每段独立计数,否则你不知道卡在哪一段。
子程序则是真的 call,可以返回,受指令与栈限制更严。适合短的公共读字段函数。把整份策略引擎放进子程序,会把上限问题换个房间继续爆炸。
上图若用尾调用实现,箭头是跳转;若用子程序,解析段还想回到入口就得是 call。不要混用到谁也说不清寿命。
验证器不接受“直到包结束”这种开放条件,除非它能从比较中推出次数上限。实践是:
/* 概念性:上界写死,验证器才肯认 */ #pragma unroll for (int i = 0; i < 8; i++) { if (off >= data_end) break; off += hdr_len_at(off); }
展开会增加指令数,可能撞另一道上限。于是出现讨价还价:少扫几层,或拆到下一段尾调用继续扫。这很丑,但比假装能解析任意嵌套更诚实。HTTP/2、Protobuf 变长,这类东西多数不该在钩子里完整解析。
| 组合手段 | 返回吗 | 典型用途 | 主要风险 |
|---|---|---|---|
| 有界循环 | 在本程序 | 扫固定多层头 | 展开后指令爆炸 |
| 子程序 | 返回 | 短公共逻辑 | 栈与指令仍计入限制 |
| 尾调用 | 不返回 | 流水线分段 | 深度、静默跳错、状态靠 Map |
| 送回用户态 | 不在内核继续 | 复杂协议与策略树 | 失去瞬间拦截能力 |
⚠️ 常见坑:用尾调用模拟函数调用,把返回值塞进 Map 再跳回来。状态时序难推理,验证未必更简单,现场更不可读。
💡 关键直觉:内核侧只留“有界的识别 + O(1) 判定”。识别不完就放弃或抽样,不要追求解析完备。
推荐三段:
段之间只传小键:五元组哈希、cgroup id、cookie。大块数据不要指望栈,也不要为了传数据把 Map 当堆。
尾调用表用枚举当索引,不要用运行时算出来的随意整数,除非那整数已被卡住范围。跳转表本身也是 Map,要纳入第 5.2 节的预算和权限。
要。拆开是为了人能测、能关、能单独灰度。验证器变强不会自动给你可运维性。
取你真正需要的最浅深度,而不是协议理论上的最大嵌套。攻击者会构造深嵌套打你的最坏路径。上限就是延迟预算。
拆成三段之后,测试也要按段。识别段用合成包或合成系统调用,断言键正确、失败桶在畸形输入时增加。判定段用注入策略 Map,断言命中与未命中。输出段用假判定结果,断言事件形状和丢失计数。三段一起测只作为最后集成,日常回归按段跑,才能在验证失败时知道是哪一段的路径爆炸。
灰度也可以按段。先上识别和计数,判定永远放行,输出关闭。稳定后再打开判定的“只计数不拦截”,再打开输出抽样,最后才打开拦截。顺序和 8.3 的双开关一致,只是粒度更细。某段出问题,只回滚该段的尾调用槽,不必整包卸载——前提是跳转表设计允许把槽指回“空操作程序”。空操作程序是值得常驻的保险。
尾调用槽的版本要和制品版本一起走。老程序跳到新段、或反过来,键布局可能不兼容。加载时先放新段、再切入口,回滚时先切入口到空操作或旧入口、再卸新段。顺序反了会出现短暂的跳向空洞。空洞不一定崩溃,但会让计数停,表现为假恢复。
有界循环的上限要随压测调整,不要随协议热情调整。压测显示 8 层扩展头已经把 XDP 预算打满,就降到 2 层,超了的包走慢路径或丢掉。把上限写进指标,便于以后的人知道这不是魔法数字。注释里写清:上限来自延迟预算,不是来自 RFC 的最大值。
文档用 ASCII 画出跳转,而不是只在脑子里。现场排障时能对照图看每段计数,比读源码快。图过期比没图更糟,所以跳转表改动必须改图。这是流水线的运维税,比维护一个巨无霸函数便宜。
灰度切入口到新段时,新段还没加载完,跳转落空,计数停了十分钟,像故障自愈。其实是流水线空洞。后来规定:先放段,再切入口;回滚先切到空操作程序,再卸段。空操作程序常驻当保险。顺序是正确性,不是风格。
有界循环上限取了协议理论上的最大值,攻击构造深嵌套把 XDP 预算打穿。上限改成延迟预算允许的最小值,超了走慢路径。注释写明数字来自预算不是来自 RFC。后来的人不再把上限当魔法。
测试按段拆开之后,验证失败能定位到识别段的路径爆炸,而不是对着整包发呆。巨无霸的失败日志是小说,分段的失败日志是病历。病历才能治。
先放段再切入口,回滚先切空操作再卸段。顺序反了会出现空洞,计数停看起来像自愈。空操作程序值得常驻当保险。上限来自延迟预算不是 RFC 最大值,攻击会打最坏路径。测试按段:识别、判定、输出分开回归,集成放最后。跳转图表必须随表改,过期图比没图糟。段间只传小键。用 Map 模拟返回值跳回来,时序难推理,现场不可读,不要当模式。可运维性不会随着验证器变强自动出现。拆开是为了人能关能测能单独灰度。人不能关的流水线,和巨无霸程序同样危险。危险在于你不知道哪一段在干活。不知道就无法回滚其中一段。无法分段回滚,灰度就是整包赌博。赌博赢过一次,不能写进平台规范。规范写顺序和空操作。写了才有下次。
流水线让人上瘾,因为看起来很内核。内核并不奖励看起来。内核奖励可证明、可关掉、可单独测的一段。一段里既解析又拦截又出栈,灰度只能整包赌。整包赌赢过,会成为团队神话。神话会阻止下一次把空操作程序做进去。没有空操作,跳转空洞就会在某次发布的十分钟里假装自愈。假装自愈会让人停止查找。停止查找时,策略段可能根本没在跑。没在跑的策略段,比跑错更安静。安静不是安全。安全是每段都有计数,计数能告诉你安静来自空操作还是来自空洞。
\n\n## 课堂补充\n\n一段一个职责。跳错靠计数。先放后切。上限来自预算。子程序要短。不要模拟返回跳回。按段测试。图表随改。空操作当保险。解析不完就放弃。可关比可炫重要。不能分段回滚就是整包赌。\n\n\n\n## 生产验收条\n\n1. 围绕「尾调用子程序与有界循环」,生产验收只认能关掉、能计数、能对账,不认口头保证。\n2. 围绕「尾调用子程序与有界循环」,把所有者、版本、卸载方式写成清单三件套,缺一视为幽灵。\n3. 围绕「尾调用子程序与有界循环」,对照实验必须能回答开关前后业务指标动了没有。\n4. 围绕「尾调用子程序与有界循环」,失败要分类到验证、额度、挂载、未触发、丢失,禁止只丢一句笼统错误。\n5. 围绕「尾调用子程序与有界循环」,热路径默认克制,阈值之后才出栈,出栈之前先证明钩子活着。\n6. 围绕「尾调用子程序与有界循环」,和平台已有挂钩点冲突时先登记再加载,禁止手工抢挂。\n7. 围绕「尾调用子程序与有界循环」,内核版本矩阵没跑绿就不能把功能写成必成功路径。\n8. 围绕「尾调用子程序与有界循环」,回滚必须碰到内核对象,心跳停止才算撤回成功。\n\n## 温故知新
下一节把镜头拉开:内核还在给 eBPF 加哪些能力,哪些配写进设计,哪些只配当风险备注。