某天,在内核 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 里的关键信息(省略无关日志):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
BUG: unable to handle page fault for address: ffffffff7ff92442
#PF: supervisor instruction fetch in kernel mode
#PF: error_code(0x0010) - not-present page
RIP: 0010:0xffffffff7ff92442
...
Call Trace:
 ...
 bpf_trampoline_6442570808+0x81/0x128
 __sys_connect+0x5/0xe0
 __x64_sys_connect+0x18/0x30
 x64_sys_call+0x251b/0x25c0
 do_syscall_64+0x7f/0x180
 ...

从调用栈可以看出,crash 发生在 __sys_connect() 的执行路径上。不过,__sys_connect+0x5 并不是被破坏的指令所在的位置;这需要结合 dump 里的反汇编来看。

跳到了哪里?

先看 dump 里的 BPF trampoline,摘出调用原函数的位置:

1
2
0xffffffffc03bb3fc: call 0xffffffff962c2fa5 <__sys_connect+5>
0xffffffffc03bb401: mov  %rax,-0x8(%rbp)

trampoline 调用的是 __sys_connect+5,返回地址是 0xffffffffc03bb401,也就是调用栈里的 bpf_trampoline_6442570808+0x81。

继续往下看 __sys_connect(),真正异常的是 +20 处:

1
2
3
# dump 中的指令字节与反汇编,按地址整理
0xffffffff962c2fb4 <__sys_connect+20>:
    e9 89 f4 cc e9    jmp 0xffffffff7ff92442

这个跳转目标,正好就是 dmesg 里的异常地址 0xffffffff7ff92442。

x86 的 e9 是一个带 32 位相对偏移的 jmp 指令,总共占 5 个字节。它的目标地址按下面的方式计算:

1
2
3
目标地址 = 当前指令地址 + 5 + sign_extend_32(相对偏移)
         = 0xffffffff962c2fb4 + 5 + sign_extend_32(0xe9ccf489)
         = 0xffffffff7ff92442

其中,89 f4 cc e9 按小端序解释,就是 0xe9ccf489。

而对应版本的 vmlinux 里,这里原本是:

1
2
3
4
# 原始指令
__sys_connect+20: 49 89 f4       mov %rsi,%r12
__sys_connect+23: 53             push %rbx
__sys_connect+24: 48 81 ec ...    sub $0x88,%rsp

原本的 mov 怎么变成 jmp 了?而且跳到了一个根本不能执行的地址。

在运行中的内核上,可以用 bpfsnoop 查看指令:

1
2
sudo bpfsnoop -d -k __sys_connect
sudo bpfsnoop -d -k icmp_rcv

这里的 -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 的优化,还原对应字节。

内核确实有这段逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// kernel/kprobes.c,省略锁检查等代码
static void __arm_kprobe(struct kprobe *p)
{
    struct kprobe *_p;

    _p = get_optimized_kprobe(p->addr);
    if (unlikely(_p))
        unoptimize_kprobe(_p, true);

    arch_arm_kprobe(p);
    optimize_kprobe(p);
}

先找覆盖当前位置的 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 里最后一个字节已经变了:

1
2
3
4
5
# 根据 detour 地址计算出的正确指令
icmp_rcv+15: e9 45 3e 2c 0b    jmp 0xffffffffc0cd9489

# dump 中实际保存的指令
icmp_rcv+15: e9 45 3e 2c cc    jmp 0xffffffff81cd9489

最后一个字节由 0x0b 变成了 0xcc,正好位于 icmp_rcv+19,也就是 C 的位置。

CPU 执行到 A 时,会把这个 0xcc 当成跳转偏移的一部分,而不会把它当成一条独立的 INT3 指令。于是,跳转目标从 detour buffer 变成了 0xffffffff81cd9489,与这次 crash 的异常地址完全一致。

把地址简化一下,就更容易看清了:

1
2
3
4
位置             A    A+1  A+2  A+3  A+4
probe            A         B         C
A 优化后       | e9 | d0 | d1 | d2 | d3 |
C 写入 INT3 后 | e9 | d0 | d1 | d2 | cc |

为什么安装 C 时,没有先撤销 A 的优化?看看修复前的查找逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static struct kprobe *get_optimized_kprobe(kprobe_opcode_t *addr)
{
    int i;
    struct kprobe *p = NULL;
    struct optimized_kprobe *op;

    /* Don't check i == 0, since that is a breakpoint case. */
    for (i = 1; !p && i < MAX_OPTIMIZED_LENGTH / sizeof(kprobe_opcode_t); i++)
        p = get_kprobe(addr - i);

    if (p && kprobe_optready(p)) {
        op = container_of(p, struct optimized_kprobe, kp);
        if (arch_within_optimized_kprobe(op, addr))
            return p;
    }

    return NULL;
}

关键是循环条件里的 !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,让指令布局足够简单:

1
2
3
4
5
6
asm volatile("leaq 1f(%%rip), %0\n"
             "1:\n"
             ".rept 16\n"
             ".byte 0x66, 0x90\n"
             ".endr\n"
             : "=r"(first));

66 90 是一条 2 字节的 NOP。这样,A、A+2、A+4 都是原始指令的起始位置;同时,A+4 又恰好落在 A 的 5 字节跳转的最后一个字节上。

用例先执行一次目标函数,记录第一条 NOP 相对函数入口的偏移,避免把编译器生成的函数前导指令长度写死。之后按以下顺序构造布局:

  1. 通过 tracefs 注册 A+2 处的 B,但不启用它,让它保持 registered、disabled 状态。
  2. 在 A 处 attach BPF kprobe。
  3. 等待 A 的首字节变为 0xe9,确认优化后的跳转已经写入。
  4. 在 A+4 处 attach BPF kprobe C。
  5. 再次执行目标函数,触发被改坏的跳转。

第 3 步很重要。KPROBE_FLAG_OPTIMIZED 可以在后台优化任务真正写入 jmp 前就被设置,所以用例检查的是实际指令字节,而不是只看这个 flag。

这里的 disabled B 是主动构造出来的,不需要碰运气等某次 detach 恰好留下这个状态。这个用例针对开启 kprobe optimization 的 x86_64 内核。

修复后的预期行为是:安装 C 时找到 A,先撤销 A 的优化,再写入 C 的断点。这样再次执行目标函数时,就不会沿着被改坏的相对偏移跳走。

修复

修复的思路很直接:向前查找时,跳过已经 disarmed 或没有准备好 optinsns 的 probe,继续寻找覆盖目标地址的 probe。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@@
-    struct kprobe *p = NULL;
+    struct kprobe *p;
     struct optimized_kprobe *op;

     /* Don't check i == 0, since that is a breakpoint case. */
-    for (i = 1; !p && i < MAX_OPTIMIZED_LENGTH / sizeof(kprobe_opcode_t); i++)
+    for (i = 1; i < MAX_OPTIMIZED_LENGTH / sizeof(kprobe_opcode_t); i++) {
         p = get_kprobe(addr - i);
+        /* A disabled probe can have prepared, but inactive, optinsns. */
+        if (!p || !kprobe_optready(p) || kprobe_disarmed(p))
+            continue;

-    if (p && kprobe_optready(p)) {
         op = container_of(p, struct optimized_kprobe, kp);
         if (arch_within_optimized_kprobe(op, addr))
             return p;

核心改动如上。完整补丁: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,就在这段追踪期间把它关闭:

1
2
3
# 内核提供的全局开关;bpfsnoop 在 fninsn 路径中自动管理它
cat /proc/sys/debug/kprobes-optimization
echo 0 > /proc/sys/debug/kprobes-optimization

关闭后,现有 optkprobe 会撤销优化,新注册的 probe 也不会再被优化成跳转。这会影响整台机器上的 kprobe optimization,并不只影响 bpfsnoop 自己的 probe。

对应修改:fninsn: Disable kprobes-optimization。处理顺序是:

  1. 发现本次需要函数指令追踪时,读取并保存原来的开关值。
  2. 原来开启时,先关闭 optimization,再安装指令 probe。
  3. 结束追踪时,先卸载所有 tracing attachment,再恢复原来的开关值。

原来就是关闭状态,就保持关闭。安装中途出错,也要等正在进行的 attach 完成并清理已有 attachment,之后才能恢复开关。

测试程序也改成优先通过 SIGTERM 结束 bpfsnoop,让清理和恢复逻辑有机会执行。直接 SIGKILL 不会执行 Go 的 defer,可能留下未恢复的开关状态。

这里最重要的是顺序:先卸载 probe,再恢复 optimization。只在启动时关一下、退出时随手打开,还不够。

小结

这次问题里,一个 disabled probe 本身没有正在使用的优化跳转,却挡住了前面真正需要撤销优化的 probe。最终,新写入的一个 0xcc,变成了另一条指令的跳转偏移。

排查这类问题,除了看调用栈,还得对照原始指令、dump 里的实际字节和 probe 状态。optinsns 准备好了、probe 已经启用、优化跳转已经写入,是不同的状态。

研究内核,是为了知道工具背后发生了什么,也为了在工具里规避这些问题,避免将内核搞挂。