OpenAI 以「流行病学调试」发现并修复 GNU libunwind 中 18 年漏洞
OpenAI 工程师在排查 Rockset 崩溃时,通过批量分析核心转储发现并修复 GNU libunwind 中存在…
OpenAI 的工程师在排查 Rockset(为 ChatGPT 的搜索和数据插件提供支持的 C++ 数据基础设施服务)频繁出现的崩溃问题时,发现这些看似神秘的崩溃实际上是由两个互不相关的 Bug 巧合地叠加在一起造成的。他们没有对单个崩溃进行深入排查,而是构建了一条自动化管道,将过去一年生产环境中的每一个核心转储文件批量分析,按「流行病学」的方式寻找整体规律——这种调试方法后来被他们称为「流行病学调试」。
从「一个 Bug」到「两个 Bug」
最初,工程师观察到函数会返回错误的内存地址,栈指针在执行过程中偏移 8 个字节,且团队对每一种假设都能找到有力的反证。为了厘清问题,他们让 ChatGPT 编写脚本,下载每个核心文件的开头部分,提取寄存器数据,过滤已知误报,并将每次崩溃归类为「返回空指针」「栈对齐错误」等类型。该脚本被并行应用于过去一年中所有的 Rockset 核心转储文件。
很快,他们发现原本看似同类的问题实际上对应两组特征完全不同的崩溃事件。
硬件问题:来自一台「数学运算默默出错」的物理主机
因栈对齐错误导致的崩溃均来自同一个 Azure 区域,有明确的起始日期,且从未在长期运行的节点上出现。工程师追踪发现,这些崩溃源自一台特定的物理主机——其 CPU 在悄无声息地产生错误的结果,既没有过热,也没有抛出机器检查异常,只是数学计算默默出了错。将该主机从服务中移除后,相关的栈对齐崩溃便完全消失。
软件漏洞:存在 18 年之久的竞争条件
在剔除硬件因素之后,剩余的「返回空指针」崩溃问题变得可控了。工程师此前曾排除 C++ 异常展开这一根因,但那些「反例」全部来自有硬件损坏的故障集群。一旦剔除干扰因素,所有剩余崩溃均发生在异常展开过程中。
根本原因在于 GNU libunwind 的 _Ux86_64_setcontext 函数中存在一个已经存在 18 年的竞争条件。在 C++ 异常展开过程中,libunwind 会在栈上合成一个 ucontext_t 结构体,填充寄存器状态,然后调用 _Ux86_64_setcontext 转移控制权。问题在于:_Ux86_64_setcontext 在读取旧结构体中的指令指针(%rip)尚未完成时,就将栈指针(%rsp)更新为指向新的栈帧。一旦 %rsp 改变,该结构体便不再属于活动栈,也不再受内核红区保护。如果信号恰好在这一窗口内到达,内核就会在该结构体之上构建信号帧,导致指令指针被破坏,函数跳转到 NULL 或垃圾地址。
该竞争窗口的宽度仅约一条指令,按现代处理器频率换算约 100 皮秒。大多数程序根本不会触发这个窗口。但 OpenAI 的 Rockset 使用 timer_create 函数,每隔几毫秒的 CPU 时间就发送一次 SIGUSR2 信号以实现按查询的轻量级记账,这种高频信号把「理论上的可能」转化成了「实际生产中的崩溃」。
修复方案与核心启示
OpenAI 团队向 GNU libunwind 提交了修复方案和自包含的复现示例。修复方法通过重新排序指令,确保在更新 %rsp 之前先读取 %rip,从而彻底消除时间窗口。同时验证显示,其他展开器(如 libgcc)并不存在此问题。
OpenAI 将这一发现的核心教训归纳为:最重要的步骤不是巧妙地解读汇编或深究细节,而是构建高质量的数据集。如果不把全部带标签的故障样本汇集起来分析,就很容易把两种截然不同的现象混为一谈;对单个案例做更深入的分析,往往不如获取完整、全量的故障数据来得有效。
