0%

记一次 ingress-nginx segfault 的排查

前言

最近被拉去分析了 kubernetes ingress-nginx 的一个 segfault issue,最后发现是我自己多年前手残留下的低级 bug (果然出来混迟早是要还的)。

虽然是一个足以写进教科书的低级 bug(没有判断边界,数组越界),但是有一些排查方法,还是值得分享一下的。谁还没有个手残的时候呢,汗。

问题描述

所有信息都在 kubernetes ingress-nginx 的这个 issue 里:https://github.com/kubernetes/ingress-nginx/issues/6896

汇总下来,有这么几个信息:

  1. 从 0.43.0 升级到 0.44.0,会偶尔出现 Segmentation Fault
  2. 从调用栈看,是从 LuaJIT 通过 ffi 调用 C

segfault

对于 segfault 的错误,可以分两种情况:

  1. bug 代码,产生了一个非法的读/写,这个时候立即就产生了 segfault
  2. bug 代码,产生了一个错误的写,但是并没有发生 sefault,而是后续没有 bug 的代码,在执行过程中,因为之前写入的错误数据,从而触发了 segfault

对于 1,容易复现,也很好搞定,直接根据调用栈查代码就行,多见于开发环境。
对于 2,其中包含了两个时间点:
1> bug 代码产生了错误的写
2> 正常代码使用了错误的数据,产生了 segfault

如果运气好的话,这两个时间点,离得不远,还是比较好排查的。
但是,这两个时间点离得特别远的话,那就很难查了,特别还有可能是连环 bug。因为理论上来而言,所有其他所有代码都值得怀疑,只要是写的进程内的合法地址,操作系统就不会抱怨。

不过,好在,实际上而言,也还是有一些规律可循。

分析过程

一开始,尝试让反馈问题的人提供 core 文件,但是争取了一两周,包括提供了分析思路以表诚意,甚至愿意签 NDA,并没有人愿意提供。

缺失的调用栈

回到问题本身,issue 中有提供调用栈,其中关键的是这两帧:

1
2
#2  __libc_free (p=0x7f03273ec4d0) at src/malloc/mallocng/free.c:110
#3 0x00007f0327811f7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

直接从调用栈来看,这个表示 ffi 在调用 free 函数,但是以我的个人经验而言,通常我们并不会通过 ffi 直接调用 free,所以怀疑是中间有调用栈缺失了。

lj_vm_ffi_call 是 LuaJIT 中手写汇编实现的一个函数,通过 disas 分析其中的机器指令,可以看到这个关键指令:

1
call   QWORD PTR [rbx]

同时由于 rbx 是 callee-saved 寄存器,我们通过 frame 3,gdb 会帮我们恢复出当时 rbx 的值,然后我们通过 info symbol *(long *)$rbx 就可以知道 ffi 当时在调用哪个函数。当时得到的反馈信息:

1
2
3
4
5
(gdb) frame 3
#3 0x00007f260c3cdf7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

(gdb) info symbol *(long *)$rbx
chash_point_sort in section .text of /usr/local/lib/lua/librestychash.so

好家伙,chash_point_sort 不是我写的 resty.chash 里的函数么,更得抓紧修复了。

分析代码

通过看代码, chash_point_sort 中有且仅有一处调用 free,所以 gdb 显示的调用栈也是对的,只是少了中间关键的一帧。
同时,发生错误的范围,基本可以锁定在 chash_point_sort 的内部了。这个函数内申请了一块临时内存,又在最后阶段完成了释放。

这个属于运气比较好,写坏内存的点,与发生 segfault 的点,离得不远,发生在同一个函数内。同时因为是我自己写的代码,虽然年代有些久远,不过也还是能想得起来,所以最终没花多久就修复了,倒是为了构造可以在 ci 上跑的测试,花了不少时间。

具体的 bug 其实很简单,没有判断边界,数组越界了,写坏了内存。

细究根源

至此,肯定是修了一个 bug,只是还需要搞清楚,为什么以前的版本没有发生 segfault,因为 resty.chash 这期间也没有变更。

通过追踪 musl-libc 的变更历史,发现 musl-libc 去年搞了一个新的 malloc 实现,在 chunk 的末尾放了一些元数据,这个会更容易让 chash_point_sort 触发越界的条件 ,而且因为是新实现的,加了非常多的 assert 检查,所以就暴露了这个 bug。

仔细想想,其实有些时候系统的稳定性,并没有我们以为的那么好。这么低级的 bug,竟然在那么多的生产环境跑了这么多年了。

总结

有一些经验,还是值得记录一下的:

  1. 调用栈不全/准的时候,分析下机器指令,充分利用好寄存器的数据,尤其是那些 callee-saved
  2. 排查问题,多自己推理,多数时候盯着代码看,是更高效的笨办法。
    之前看过云风的一篇文章,他是推荐尽量不要依赖调试器,多让代码在脑子里运行,确实是有道理的。
  3. 写代码的时候,必要的 assert 还是需要的,这是个好习惯。
    之前分析 LuaJIT segfault 的时候,看 LuaJIT 代码中也充斥着很多关键的 assert。