eBPF Talk: 研究内核是为了避免将内核搞挂
文章目录
某天,在内核 6.8 的 Ubuntu 24.04 VM 上跑 bpfsnoop 的 e2e 测试时,VM 挂了。当场蒙逼,跑几个 BPF 程序,VM 为什么会挂?
幸亏,VM 重启后,生成了 dump 和 dmesg 文件;然后使用 Codex 对着 dump、dmesg 和源代码分析了之后,发现是 kprobe 的 BUG。遂发 patch 修之。
TLDR: 发 patch 修了一个存在了 16 年的 BUG,并被移植回所有 stable 分支。并在 bpfsnoop 里规避了该 BUG,避免将内核搞挂。
跑 bpfsnoop 搞挂内核
问题出现在 bpfsnoop 的函数指令追踪测试中。先看 dmesg 里的关键信息(省略无关日志):
|
|
从调用栈可以看出,crash 发生在 __sys_connect() 的执行路径上。不过,__sys_connect+0x5 并不是被破坏的指令所在的位置;这需要结合 dump 里的反汇编来看。
跳到了哪里?
先看 dump 里的 BPF trampoline,摘出调用原函数的位置:
|
|
trampoline 调用的是 __sys_connect+5,返回地址是 0xffffffffc03bb401,也就是调用栈里的 bpf_trampoline_6442570808+0x81。
继续往下看 __sys_connect(),真正异常的是 +20 处:
|
|
这个跳转目标,正好就是 dmesg 里的异常地址 0xffffffff7ff92442。
x86 的 e9 是一个带 32 位相对偏移的 jmp 指令,总共占 5 个字节。它的目标地址按下面的方式计算:
|
|
其中,89 f4 cc e9 按小端序解释,就是 0xe9ccf489。
而对应版本的 vmlinux 里,这里原本是:
|
|
原本的 mov 怎么变成 jmp 了?而且跳到了一个根本不能执行的地址。
在运行中的内核上,可以用 bpfsnoop 查看指令:
|
|
这里的 -d 是反汇编,-k 指定内核函数。不过,它读的是当前内核的指令;重启后再看,并不能还原 crash 前被改坏的现场。上面的异常指令来自保存下来的 dump。
kprobe 为什么会写入 jmp?
普通的 x86 kprobe 会把探测位置的指令首字节改成 0xcc,也就是 INT3。CPU 执行到这里时,进入异常处理流程,再执行 kprobe 对应的处理函数。
每次都经过异常处理,开销比较大。所以,kprobe 有一个 jump optimization:满足优化条件时,将探测位置的 5 个字节替换为 jmp,跳到内核准备好的 detour buffer;在里面执行探测处理和搬过去的原指令,再跳回原函数。
这就是 optkprobe。
问题在于,x86 的指令是变长的。一个 5 字节的 jmp,可能覆盖原来的好几条指令。如果此时又要在被覆盖的某条原指令上安装 kprobe,就必须先撤销前面那个 kprobe 的优化,还原对应字节。
内核确实有这段逻辑:
|
|
先找覆盖当前位置的 optkprobe,将它退回普通 kprobe,再往当前位置写 INT3。
此次修复的问题,就出在 get_optimized_kprobe() 没有找到真正需要撤销优化的那个 probe。
被 disabled probe 挡住了
另一次 icmp_rcv() crash 的 dump,把这个问题展示得更直接。这里与前面的 __sys_connect() 是两次不同的 crash,地址和被破坏的字节也不同。
icmp_rcv() 起始地址是 0xffffffffb5a15630,其中有 3 个值得关注的 probe:
| probe | 位置 | dump 中的状态 |
|---|---|---|
| A | icmp_rcv+15 |
已优化,detour 地址为 0xffffffffc0cd9489 |
| B | icmp_rcv+17 |
已 disabled,但仍保留准备好的 optinsns |
| C | icmp_rcv+19 |
已启用的普通 kprobe,写入 INT3 |
先看 A 处的指令字节。根据 A 的地址和 detour 地址,可以算出正确的跳转指令;但 dump 里最后一个字节已经变了:
|
|
最后一个字节由 0x0b 变成了 0xcc,正好位于 icmp_rcv+19,也就是 C 的位置。
CPU 执行到 A 时,会把这个 0xcc 当成跳转偏移的一部分,而不会把它当成一条独立的 INT3 指令。于是,跳转目标从 detour buffer 变成了 0xffffffff81cd9489,与这次 crash 的异常地址完全一致。
把地址简化一下,就更容易看清了:
|
|
为什么安装 C 时,没有先撤销 A 的优化?看看修复前的查找逻辑:
|
|
关键是循环条件里的 !p:往前找,只要找到一个已经注册的 probe,就停止搜索。
从 C 往前找,先遇到的是 B。虽然 B 已经 disabled,但它仍然注册在 kprobe 的 hash table 里,所以能被 get_kprobe() 找到。
而 kprobe_optready() 检查的是优化所需的 optinsns 是否准备好了,不代表当前位置真的装着一个优化后的 jmp。B 可以已经准备好 optinsns,却没有正在使用的跳转。
在这个布局下,查找返回 B,后续尝试撤销 B 的优化,却没有继续找到 A。接着,在 C 处写入 INT3,就把 A 的跳转偏移改坏了。
需要区分的是:dump 保存的是 crash 时的状态,并没有记录全部 attach、disable、detach 的先后顺序。上面的过程解释了这种布局如何破坏跳转;原始测试里具体在哪一步产生这个状态,不能只凭调用栈下结论。
用 3 个 probe 复现
为了把问题缩小,可以构造一个只包含 3 个 probe 的用例。测试函数中放入连续的双字节 NOP,让指令布局足够简单:
|
|
66 90 是一条 2 字节的 NOP。这样,A、A+2、A+4 都是原始指令的起始位置;同时,A+4 又恰好落在 A 的 5 字节跳转的最后一个字节上。
用例先执行一次目标函数,记录第一条 NOP 相对函数入口的偏移,避免把编译器生成的函数前导指令长度写死。之后按以下顺序构造布局:
- 通过 tracefs 注册 A+2 处的 B,但不启用它,让它保持 registered、disabled 状态。
- 在 A 处 attach BPF kprobe。
- 等待 A 的首字节变为
0xe9,确认优化后的跳转已经写入。 - 在 A+4 处 attach BPF kprobe C。
- 再次执行目标函数,触发被改坏的跳转。
第 3 步很重要。KPROBE_FLAG_OPTIMIZED 可以在后台优化任务真正写入 jmp 前就被设置,所以用例检查的是实际指令字节,而不是只看这个 flag。
这里的 disabled B 是主动构造出来的,不需要碰运气等某次 detach 恰好留下这个状态。这个用例针对开启 kprobe optimization 的 x86_64 内核。
修复后的预期行为是:安装 C 时找到 A,先撤销 A 的优化,再写入 C 的断点。这样再次执行目标函数时,就不会沿着被改坏的相对偏移跳走。
修复
修复的思路很直接:向前查找时,跳过已经 disarmed 或没有准备好 optinsns 的 probe,继续寻找覆盖目标地址的 probe。
|
|
核心改动如上。完整补丁:kprobes: Skip disarmed probes when checking optkprobe overlap。
如此,从 C 往前查找,遇到 B 时继续往前,最终找到 A,再由 __arm_kprobe() 撤销 A 的优化。
这里用的是 kprobe_disarmed(),没有简单地检查 disabled flag。对于聚合 probe,它还会检查优化相关的链表是否为空;如果仍处于异步优化或撤销优化的队列中,就不能仅凭 disabled flag 将它跳过。
补丁的 Fixes 指向 afd66255b9a4(kprobes: Introduce kprobes jump optimization),该 commit 的作者日期是 2010 年 2 月 25 日。到这次修复,已经过去了 16 年。
bpfsnoop 里绕过
修了内核里的问题,还得考虑正在使用旧内核的机器。
bpfsnoop 的函数指令追踪,会在同一个函数的多个指令位置安装 kprobe,容易遇到这种相邻 probe 的情况。既然出问题的是 jump optimization,就在这段追踪期间把它关闭:
|
|
关闭后,现有 optkprobe 会撤销优化,新注册的 probe 也不会再被优化成跳转。这会影响整台机器上的 kprobe optimization,并不只影响 bpfsnoop 自己的 probe。
对应修改:fninsn: Disable kprobes-optimization。处理顺序是:
- 发现本次需要函数指令追踪时,读取并保存原来的开关值。
- 原来开启时,先关闭 optimization,再安装指令 probe。
- 结束追踪时,先卸载所有 tracing attachment,再恢复原来的开关值。
原来就是关闭状态,就保持关闭。安装中途出错,也要等正在进行的 attach 完成并清理已有 attachment,之后才能恢复开关。
测试程序也改成优先通过 SIGTERM 结束 bpfsnoop,让清理和恢复逻辑有机会执行。直接 SIGKILL 不会执行 Go 的 defer,可能留下未恢复的开关状态。
这里最重要的是顺序:先卸载 probe,再恢复 optimization。只在启动时关一下、退出时随手打开,还不够。
小结
这次问题里,一个 disabled probe 本身没有正在使用的优化跳转,却挡住了前面真正需要撤销优化的 probe。最终,新写入的一个 0xcc,变成了另一条指令的跳转偏移。
排查这类问题,除了看调用栈,还得对照原始指令、dump 里的实际字节和 probe 状态。optinsns 准备好了、probe 已经启用、优化跳转已经写入,是不同的状态。
研究内核,是为了知道工具背后发生了什么,也为了在工具里规避这些问题,避免将内核搞挂。
文章作者 Leon Hwang
上次更新 2026-10-11