一个偶发性测试暴露了 Redis 客户端的释放后使用(use-after-free)漏洞。
A flaky test exposed a Redis client use-after-free

原始链接: https://buildkite.engineering/how-a-flaky-test-exposed-a-redis-use-after-free/

这个故事讲述了对一个复杂、非确定性内存损坏问题的调查过程,该问题由 Redis gem 升级触发。起初看似简单的“不稳定性测试”或竞态条件,在工程师遇到无法解释的崩溃和“双重释放(double free)”错误时,演变成了一场深度的调查。 突破口出现在一位开发者的自定义插件自动捕获了一次段错误(segmentation fault)的内存转储(core dump)时。对转储文件的分析揭示了一个大小异常庞大的 `memmove` 操作,指向了 `hiredis` C 扩展内的内存损坏。 团队利用之前对地址消毒器(AddressSanitizer, ASAN)的了解,使用这一内存安全工具重新编译了 Ruby,并很快定位到了一个“堆释放后使用(heap use-after-free)”错误。他们发现该 Bug 是由同步问题引起的,即读取线程错误地释放了写入缓冲区。 最终,团队通过禁用有问题的 C 扩展并向上游报告了该 Bug 解决了问题。这次成功凸显了强大的可观测性工具(如自动化测试可靠性跟踪和内存转储捕获)结合内存安全调试工具的重要性,正是它们将一个难以捉摸的长期稳定性问题转化为了一个可修复的技术故障。

来自 *Buildkite Engineering* 的这篇链接文章详细介绍了对 Redis 客户端中“释放后使用”(use-after-free)内存错误的调查过程,该错误最初是通过一个不稳定的测试(flaky test)被发现的。 Hacker News 上的讨论聚焦于调查流程,用户们对诊断 `memmove` 异常所使用的具体工具(如 GDB 或 Valgrind)表现出了浓厚兴趣。评论者还称赞了该网站交互式的流体模拟 UI,并将其与开发者远藤侑介(Yusuke Endoh)的作品相提并论。除了技术主题外,该讨论串还简要涉及了关于原文标题清晰度的元讨论,以及一些关于语法解读的轻松调侃。
相关文章

原文

It can be hard to fix a bug even when it happens deterministically; replicating the circumstances that trigger it is often the hardest step. When a bug is non-deterministic this becomes harder: we need to execute the code that reproduces it many times in order to observe the behaviour, which adds significant time to our feedback loop. Harder still is when the cause of the bug happens prior to its manifestation —when data is corrupted but we don’t immediately notice it. Our usual tools focus on capturing the state of the system at the time a crash happens, which doesn’t tell us why the corruption happened in the first place, just that it happened.

This is the tale of a memory corruption bug we found in the redis-client library and what helped us get there.

Chapter 1: Bounce

Jump! Bounce! Down! Up!

Our story starts with David, an engineering manager on our Scalability team. One idle Tuesday, David saw a build of his had failed due to what appeared to be a number of flaky tests. He started a thread in Slack to alert others and included a screenshot from the Test Engine dashboard.

David, an engineering manager on our Scalability team.

Test Engine reliability trend showing the tests dropping from ~100% reliable.

As a continuous integration company, we are intimately familiar with flaky tests —they are an inevitable part of any large codebase. Within 15 minutes, the team pulled the Andon Cord; these tests were so flaky they were preventing us deploying code to production. As David summarised it, “[the] priority is getting main unblocked”, so we worked to exclude the tests from our suite temporarily.

With the urgency reduced, David and the many other developers blocked by this hiccup were free to go back to their original tasks. David, though, decided to take a slightly deeper look by inspecting the reliability of these tests using Buildkite’s Test Engine reliability score, to see if he could correlate when they suddenly became unstable.

It seems the three worst flakeys went from ~100% reliable to… not around [2-4 days prior]. I’ve had a look through merged PRs to see if I can spot what might have contributed to that change, but can’t see anything obvious… Only thing that jumps out is the redis upgrade?

Patrick Robinson, staff engineer and part-time blog post narrator.

That Redis upgrade was done on the prior Friday afternoon by a staff engineer, and part time blog post narrator, Patrick Robinson. Patrick loves few things more than talking about himself in the third person; one of those things is getting deep into gnarly bugs.

Here’s our first clue: the Redis gem upgrade had gone smoothly with no hint of any production issues. At this point, though, a second hypothesis appeared: the tests were flaky because they depended on a certain sequence of events. See, the flaky tests were part of our feature test suite; they utilised not just RSpec but also a Selenium headless browser and a test environment comprising of a web/API server, database and Redis server. In these situations, race conditions between the different components are a common cause of flaky tests. Unfortunately, this would lead our investigators off track.

So the suggestion was to slap a sleep in between the test setup and assertions to see if it made the problem go away. This wasn’t a solution, but a way of establishing if the flakiness was the result of a WebSocket message taking slightly too long to arrive.

Chapter 2: Fade to Black

Life, it seems, will fade away
Drifting further every day
Getting lost within myself
Nothing matters, no one else

The following Monday, more tests were failing sporadically in the same file as those that had been skipped. Patrick worked with another of our staff engineers who could, unlike Patrick, write actual frontend code. Together they managed to build a reliable reproduction of the bug, although it took up to 30 minutes to produce a failure. After eliminating the possibility of a race condition where messages were delivered after the test suite had run, the team narrowed in on the interactions between ActionCable and Redis.

By adding additional logging, they found the subscribe command was being delivered to ActionCable but wasn’t being received by Redis. Other developers also reported WebSockets often failing in development, indicating it was not only the test environment that was impacted.

At the end of the third day of debugging, seemingly getting nowhere, Patrick got a very unusual error message, which he posted to Slack with the departing words “I think it’s time to give up for the night…”:

ruby(29632,0x2a9f1f000) malloc: Double free of object 0x2a9bb6740
ruby(29632,0x2a9f1f000) malloc: *** set a breakpoint in malloc_error_break to debug

This kind of exception is quite unusual and hints at how deep this bug goes. But the team hadn’t quite gathered enough information to recognise how or even if it related to the failure. Like an installment of the blockbuster movie series Knives Out, even with all the information, it still didn’t make sense.

And yet, with all the pieces on the table, this crime still truly appears impossible. ~ Benoit Blanc

Chapter 3: Everlong

Hello
I’ve waited here for you
Everlong

Successful debugging requires a good dose of skill and a pinch of luck. Late on the following Thursday afternoon, a message was posted in our engineering Slack channel that a build had triggered a segmentation fault. Attached to the build was a core dump.

The core dump was attached because one of our developers was previously trying to debug a crash in the test suite, and had written a plugin to look for the presence of a core dump and, if one was found, upload it to the relevant build as an artifact.

That means it’s time to introduce our third character, Rian, who during his tenure at Buildkite developed a reputation for finding and fixing incredibly obscure bugs (along with the infamous Doom pipeline). The stories are so great that despite Rian’s departure we tell them to new hires, who retell them to others and so on and so forth.

Rian, our third character, renowned for hunting down obscure bugs.

The core dump enabled Rian to inspect the state of the process at the time it crashed, akin to a black box in an aircraft crash investigation. The crash message itself indicated it happened inside the hiredis code, which is a C extension bundled into the redis-client gem. From this, he ascertained that the exception was triggered by a call to the C function memmove. While the source and destination addresses for this call looked sane, the size field was set to 0x00a0ffffffffffb6 , which is about 45 petabytes. Based on this and the fact we’d been seeing other unexpected behaviour since upgrading the Redis gem, it was logical to conclude a memory corruption bug was present in the newest version.

By another stroke of luck, Rian had attended a conference talk earlier in the year called Finding and fixing memory safety bugs in C with ASAN.

With this information, Rian set to work getting the reproducible code running in a version of Ruby compiled with ASAN. It only took a couple of hours to get this running, and within 30 minutes ASAN reported a heap use-after-free. In order to provide concurrent execution, hiredis has two threads for each connection, one for reading and one for writing. The use-after-free was happening because in some scenarios the reader thread would free the writer buffer.

Epilogue: In the End

I’m surprised it got so far
Things aren’t the way they were before
You wouldn’t even recognize me anymore
Not that you knew me back then, but it all comes back to me in the end

Once we knew that there was memory corruption and, more importantly, where it was coming from, fixing it was relatively straightforward. Rian disabled using the C extension for Redis connections in test, development and production, and developed a reproducible test case that was reported upstream.

What enabled us to find it in the first place is the interesting part. A combination of deliberate application of our skills in building the necessary tooling, teamwork, and just the right amount of luck. Our tools, such as Test Engine and our coredump-artifact plugin, enabled us to identify the nature of the bug and, combined with using ASAN, where it manifested. Memory corruption bugs such as these can often take months to fix, because having a core dump generated by a segfault only tells you that memory was corrupted at some point in the past, but gives you few clues as to how it became corrupt.

No production data was harmed in the making of this story.

联系我们 contact @ memedata.com