一个不稳定的测试暴露了 Redis 客户端的 use-after-free 漏洞
摘要
一篇来自 Buildkite 的工程博客文章详细描述了一个不稳定的测试如何导致在 redis-client Ruby 库中发现了一个 use-after-free 漏洞,并介绍了调试过程和根本原因分析。
暂无内容
查看缓存全文
缓存时间: 2026/07/22 02:17
# 一个不稳定测试如何暴露了 Redis 的释放后使用问题
来源:https://buildkite.engineering/how-a-flaky-test-exposed-a-redis-use-after-free
即使一个 bug 是确定性的,修复起来也可能很困难;复制触发它的条件往往是最难的步骤。当 bug 是非确定性的时,难度更大:我们需要多次执行能重现它的代码来观察行为,这大大增加了反馈循环的时间。更困难的是当 bug 的原因发生在它显现之前——数据被破坏,但我们没有立即注意到。我们常用的工具侧重于在崩溃发生时捕获系统状态,这并不能告诉我们破坏最初是如何发生的,只知道它发生了。
这是一个关于我们在 `redis-client` 库 (https://github.com/redis-rb/redis-client) 中发现的内存破坏 bug 的故事,以及是什么帮助我们找到了它。
## 第一章:**弹跳**
*跳!弹!下!上!* (https://genius.com/2817913/System-of-a-down-bounce/Jump-bounce-down-up)
故事始于 David,我们可扩展性团队的工程经理。在一个悠闲的周二,David 发现他的一个构建失败了,原因是出现了许多不稳定的测试。他在 Slack 中发起了一个话题来提醒其他人,并附上了来自 Test Engine (https://buildkite.com/docs/pipelines/configure/tests) 仪表盘的截图。
David,我们可扩展性团队的工程经理。
Test Engine 可靠性趋势显示测试的可靠性从约 100% 下降。
作为一家持续集成公司,我们对不稳定测试非常熟悉——它们是任何大型代码库中不可避免的一部分。15 分钟内,团队拉下了安藤绳;这些测试太不稳定了,以至于阻止我们将代码部署到生产环境。正如 David 总结的那样,“当务之急是让主分支不再受阻”,因此我们暂时将这些测试从套件中排除。
随着紧迫感的降低,David 和其他被这个小插曲阻塞的开发人员可以自由地回到原来的任务中。不过,David 决定稍微深入一点,使用 Buildkite 的 Test Engine 可靠性分数检查这些测试的可靠性,看看是否能找出它们突然变得不稳定的时间点关联。
> 这三个最不稳定的测试似乎从约 100% 的可靠性变成了……大约 [2-4 天前] 就不行了。我浏览了合并的 PR,想看看是否能发现可能导致这种变化的东西,但没有发现明显的问题……唯一突出的是 Redis 升级?
Patrick Robinson,Staff 工程师兼本博文兼职叙述者。
那次 Redis 升级是在前一个周五下午由一位 Staff 工程师兼本博文兼职叙述者 Patrick Robinson 完成的。Patrick 最喜欢的事情除了以第三人称谈论自己之外,就是深入处理棘手的 bug。
这是我们的第一条线索:Redis gem 升级进行得很顺利,没有任何生产问题的迹象。然而,此时出现了第二个假设:这些测试不稳定是因为它们依赖于特定的事件序列。你看,这些不稳定测试是我们功能测试套件的一部分;它们不仅使用了 RSpec,还使用了 Selenium 无头浏览器和一个包含 web/API 服务器、数据库和 Redis 服务器的测试环境。在这种情况下,不同组件之间的竞态条件是不稳定测试的常见原因。不幸的是,这会让我们的调查人员偏离方向。
所以有人建议在测试设置和断言之间放一个 sleep,看看问题是否消失。这不是一个解决方案,而是一种确定不稳定性是否是 WebSocket 消息花费稍长时间到达的结果的方法。
## 第二章:**消失于黑暗**
*生命,似乎将逐渐消逝 每天越漂越远 迷失于自我 没有什么重要,没有别人* (https://open.spotify.com/track/12oSzrBIyirNs0aCwk6elL)
接下来的周一,更多的测试在同一个文件中零星失败,而这些文件之前已经被跳过。Patrick 与另一位 Staff 工程师合作,这位工程师与 Patrick 不同,能编写实际的前端代码。他们一起设法构建了一个可靠的 bug 复现,尽管需要长达 30 分钟才能产生一次失败。在排除了消息在测试套件运行后送达的竞态条件可能性后,团队将焦点缩小到了 ActionCable 和 Redis 之间的交互。
通过添加额外的日志,他们发现 `subscribe` 命令已经发送到 ActionCable,但没有被 Redis 接收到。其他开发者也报告说 WebSocket 经常在开发中失败,这表明不仅仅是测试环境受到影响。
在调试的第三天结束时,似乎毫无进展,Patrick 收到了一条非常不寻常的错误消息,他将其发布到 Slack 上,并留下了“我想今晚该放弃了……”的话:
``
ruby(29632,0x2a9f1f000) malloc: Double free of object 0x2a9bb6740
ruby(29632,0x2a9f1f000) malloc: *** set a breakpoint in malloc_error_break to debug
``
这种异常非常不寻常,暗示了这个 bug 有多深。但团队还没有收集到足够的信息来认识到它是如何发生的,甚至是否与失败有关。就像电影系列《利刃出鞘》的续集一样,即使拥有了所有信息,事情仍然说不通。
> 然而,所有线索都摆在桌面上,这桩罪行仍然看起来不可能。*~ 贝努瓦·布兰克*
## 第三章:**天长地久**
*你好,我在这里等你 天长地久* (https://open.spotify.com/track/5UWwZ5lm5PKu6eKsHAGxOk)
成功的调试需要大量的技巧和一点运气。下一个周四下午晚些时候,一条消息发布在我们的工程 Slack 频道中,说一个构建触发了段错误。该构建附带了一个核心转储。
核心转储之所以被附加,是因为我们的一位开发人员之前曾试图调试测试套件中的崩溃,并编写了一个插件来查找核心转储的存在,如果找到,则将其作为工件上传到相关的构建中。
这意味着是时候介绍我们的第三个角色了,Rian,他在 Buildkite 任职期间以发现和修复极其晦涩的 bug 而闻名(以及臭名昭著的 Doom 管道 (https://buildkite.com/platform/pipelines/doom/))。这些故事非常精彩,以至于尽管 Rian 已经离开,我们仍然会向新员工讲述,然后他们再告诉其他人,如此循环。
Rian,我们的第三个角色,以追查晦涩的 bug 而闻名。
核心转储使 Rian 能够检查进程在崩溃时的状态,类似于飞机失事调查中的黑匣子。崩溃消息本身表明它发生在 hiredis 代码内部,这是打包在 redis-client gem 中的 C 扩展。由此,他确定异常是由对 C 函数 `memmove` 的调用触发的。虽然这个调用的源地址和目标地址看起来正常,但大小字段被设置为 `0x00a0ffffffffffb6`,大约是 45 PB。基于这一点,以及自从升级 Redis gem 以来我们一直看到其他意外行为的事实,我们合乎逻辑地得出结论:最新版本中存在一个内存破坏 bug。
又一个运气,Rian 之前参加过今年的一个会议演讲,题为《使用 ASAN 查找和修复 C 语言中的内存安全错误》 (https://www.youtube.com/watch?v=yM1us32z-9I)。
有了这些信息,Rian 开始工作,让可重现的代码在编译了 ASAN 的 Ruby 版本中运行。只花了几个小时就运行起来了,30 分钟内 ASAN 报告了一个堆释放后使用。为了提供并发执行,hiredis 为每个连接有两个线程,一个用于读取,一个用于写入。释放后使用之所以发生,是因为在某些场景下,读取线程会释放写入缓冲区 (https://github.com/redis-rb/redis-client/blob/6d55f61cac62af91aebbd5a1d00eae7a8d940b9e/hiredis-client/ext/redis_client/hiredis/hiredis_connection.c#L741-L761)。
## 尾声:**最终**
*我很惊讶它走到这么远 事情已不再是从前的样子 你甚至再也认不出我 不是说你当时认识我,但最终一切都回到了我身边* (https://open.spotify.com/track/60a0Rd6pjrkxjPbaKzXjfq)
一旦我们知道存在内存破坏,更重要的是知道它来自哪里,修复它就相对简单了。Rian 禁用了在测试、开发和生产环境中使用 C 扩展的 Redis 连接,并开发了一个可重现的测试用例,已向上游报告 (https://github.com/redis-rb/redis-client/issues/208)。
最初能够发现它的原因才是有趣的部分。我们有意识地运用技能构建必要的工具、团队合作以及恰到好处的运气的结合。我们的工具,如 Test Engine (https://buildkite.com/docs/pipelines/configure/tests) 和我们的 coredump-artifact (https://github.com/buildkite-plugins/coredump-artifact-buildkite-plugin) 插件,使我们能够识别 bug 的性质,并结合使用 ASAN,找到它显现的位置。像这样的内存破坏 bug 通常可能需要数月 (https://web.archive.org/web/20211129172859/https://webuild.envato.com/blog/tracking-down-ruby-heap-corruption/) 才能修复,因为由段错误生成的核心转储只能告诉你内存过去某个时候被破坏过,但几乎无法提示它是如何变得损坏的。
*本故事在制作过程中没有生产数据受到伤害。*
相似文章
Kimi K3 利用最新 Redis 服务器漏洞
一个概念验证漏洞利用仓库展示了多个 Redis 版本(6.2.22 至 8.8.1)中通过流 NACK 双重释放和 RedisBloom 模块漏洞实现远程代码执行的安全问题。这些利用方法绕过了最新的补丁,且需要特定条件。
核心转储流行病学:修复一个18年的旧bug
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
@jedisct1: epoll UAF
对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。
@ayushagarwal027: Cloudflare 花了 6 周追踪 hyper Rust HTTP 库中的一个 bug。修复只需 4 行代码。症状:图片响应……
Cloudflare 工程师花了六周时间调试 hyper Rust HTTP 库中的一个竞态条件,该问题导致大图片响应中出现静默数据截断。根本原因是在 flush 期间,一个 `let _ =` 丢弃了 `Poll::Pending` 信号;修复方式是一处四行代码的修改,现已合并到上游。
CVE-2026-42530: nginx HTTP/3 QUIC模块中的释放后使用漏洞
CVE-2026-42530 披露了 nginx 的 HTTP/3 QUIC 模块中的一个释放后使用漏洞。