一切皆有可能出错:在 Mozilla 发布 rust-minidump

一切皆有可能出错:在 Mozilla 发布 rust-minidump – 第一部分

Avatar photo 作者:Aria Beingessner

发布于 2022 年 6 月 14 日,分类:Developer Tools, Featured Article, 和 Firefox

一切皆有可能出错:在 Mozilla 发布 rust-minidump

过去一年,我一直在领导 rust-minidump 的开发,这是一个纯 Rust 实现,用于替换 google-breakpad 中 minidump 处理的部分。

实际上,从某种意义上说,我已经_完成_了这项工作,因为 Mozilla 已经在 6 个月前部署了它,将其作为 Firefox 的崩溃处理后端,它的运行速度提高了一半,而且似乎更可靠。(而且你知道,它_不是_一个可怕的 C++ 代码球,用来解析和评估来自互联网的任意输入。我们尽了最大努力隔离 Breakpad,但仍然……太可怕了。)

这是一个非常棒的结果,但是总有更多的工作要做,因为_Minidump 是一个墨黑的深渊,你越深入,它就越深……_等等,我说得太快了。先是光明,然后是深渊。是的。先是光明。

我_可以_说的是,我们对于最大的平台(x86、x64、ARM、ARM64;Windows、MacOS、Linux、Android)的 minidump 解析 + 分析的核心功能,有一个非常可靠的实现。但是,如果你想读取在 PlayStation 3 上生成的 minidump 或者处理 Full Memory 转储,你就不会得到很好的服务。

我们已经投入了大量精力来记录和测试这个东西,所以我对此非常有信心!

不幸的是!信心!一文不值!

这就是为什么我要讲述我们如何尽最大努力使这个噩梦尽可能健壮,但仍然被 @5225225 突然而_令人难以置信_的模糊测试所彻底击败的故事。

这篇文章分为两个部分:

  1. 什么是 minidump,以及我们如何制作 rust-minidump
  2. 我们是如何被简单的模糊测试彻底击败的

你正在阅读第一部分,我们将在这里建立我们的傲慢。

背景:什么是 Minidump,以及为什么要编写 rust-minidump?

你的程序崩溃了。你想知道你的程序为什么崩溃,但它发生在世界另一端的用户机器上。一个完整的 coredump(程序分配的所有内存)非常庞大——我们不能让用户向我们发送 4GB 的文件!好的,让我们收集最重要的内存区域,比如堆栈和程序崩溃的地方。哦,我想如果我们花时间,也把关于系统和进程的一些元数据塞进去。

恭喜你,你发明了 Minidump。现在你可以把一个原本 4GB 的 100 线程 coredump 变成一个漂亮的 2MB 文件,你可以通过互联网发送它,并对其进行事后分析。

或者更具体地说,是 Microsoft 做的。很久以前,他们的文档甚至没有讨论平台支持。MiniDumpWriteDump 支持的版本只是“Windows”。Microsoft Research 大概已经开发了一种时间机器来保证这一点。

然后 Google 来了(大约在 2006-2007 年),说“如果我们能在_任何_平台上制作 minidump,那岂不是很好?”。值得庆幸的是,Microsoft 实际上构建的格式非常可扩展,因此为 Linux、MacOS、BSD、Solaris 等扩展该格式并不太难。这些扩展变成了 google-breakpad(或简称 Breakpad),其中包括大量不同的工具,用于生成、解析和分析其扩展的 minidump 格式(以及原生的 Microsoft 格式)。

Mozilla 对此提供了很多帮助,因为显然,我们在 2007 年左右的崩溃报告基础设施(“Talkback”)非常糟糕,而这似乎是一个不错的改进。不用说,我们现在对 Breakpad 的 minidump 投入了很多。

快进到今天,出现了一个非常滑稽的命运转折,像 VSCode 这样的产品意味着 Microsoft 现在支持在 Linux 和 MacOS 上运行的应用程序,因此它在生产环境中运行 Breakpad,并且必须在其崩溃报告基础设施中的某个地方处理非 Microsoft minidump,因此其他人对其自身格式的扩展不知何故变成了他们的问题!

与此同时,Google 已经有点转向 Crashpad。我说有点,因为其中仍然有很多 Breakpad,但是他们对在它之上构建工具比改进 Breakpad 本身更感兴趣。对 Breakpad 进行了一些更改后:说实话很公平,我也不想在它上面工作。尽管如此,这对我们来说有点问题,因为这意味着该项目的员工越来越少。

在我开始从事崩溃报告工作时,Mozilla 基本上已经放弃了将修复/改进提交到 Breakpad 上游,而只是使用自己的修补后的分支。但是,即使_没有_将补丁提交到上游的需求,对 Breakpad 的每一次更改都让我们感到恐惧:许多对我们的崩溃报告基础设施的拟议改进都停滞在“用 Breakpad 实现它”上。

你可能会问,为什么在 Breakpad 上工作如此痛苦?

解析和分析 minidump 基本上是编写特定于平台的格式嵌套在格式嵌套在格式中的分形解析器的练习。适用于许多操作系统。适用于许多硬件架构。而且你正在解析和分析的所有输入都是可怕的且有错误的,所以你_必须_编写一个非常宽松的解析器,并尽可能地向前爬行。

Windows XP 的某个特定 MSVC 工具链在其调试信息格式中存在一个错误?太糟糕了,仍然要对该堆栈帧进行符号化!

该程序由于严重损坏了自己的堆栈而崩溃?太糟糕了,仍然要生成一个回溯!

minidump 编写器本身完全崩溃,并将一堆垃圾写入到一个流中?太糟糕了,仍然要生成你可以生成的任何输出!

嘿,你知道谁在处理用 C++ 编写的非常复杂的宽松解析器方面有很多经验吗?Mozilla!这就像网络浏览器的_核心功能_。

你知道 Mozilla 在 C++ 中编写非常复杂的宽松解析器的秘密解决方案是什么吗?

我们不再这样做了。

我们开发了 Rust,并将我们最糟糕的解析器移植到它。

我们已经做了很多次了,而且 当我们这样做时,我们总是像 “哇,这可靠得多,而且更容易维护,而且现在甚至更快了”。Rust 是一种非常适合编写解析器的语言。C++ 真的不是。

所以我们用 Rust 重写了它(或者像孩子们所说的那样,“Oxidized It”)。Breakpad 很大,所以我们实际上并没有涵盖它的所有功能。我们专门编写和部署了:

值得注意的是,此图中缺少 minidump writing,或者 google-breakpad 所谓的 client(因为它在客户端的机器上运行)。我们_正在_ 开发一个基于 Rust 的 minidump 编写器,但我们还不能推荐使用它(尽管由于 Embark Studios 的帮助,它的速度已经加快了很多)。

这可以说是最混乱和最困难的工作,因为它有一项可怕的工作:使用一堆本机系统 API 来收集一堆特定于操作系统和硬件的信息,并为刚刚崩溃的程序执行此操作,在_导致_该程序崩溃的机器上。

我们还有很长的路要走,但是每次我们到达其中一个项目的另一边时,都_非常棒_。

背景:Stackwalking 和调用约定

rust-minidump 的 (minidump-stackwalk) 最重要的工作之一是获取线程的状态(通用寄存器和堆栈内存),并为该线程创建一个回溯(unwind/stackwalk)。这是一项非常复杂和混乱的工作,而_我们正在尝试分析一个进程的内存,该进程已经混乱到足以崩溃_这一事实只会使这项工作更加复杂。

这意味着我们的堆栈遍历器本质上是在处理可疑数据,而且我们所有的堆栈遍历技术都基于可能出错的启发式方法,而且我们很容易发现自己处于堆栈遍历向后、向侧面或无限延伸的情况,而我们只需要尝试处理它!

看到一个堆栈遍历器开始_产生幻觉_也很常见,这是我对“堆栈遍历器找到了一些看起来足够合理的东西,并开始通过堆栈进行古怪的冒险,并编造了一堆无用的垃圾帧”的术语。幻觉在堆栈底部最常见,在那里它的攻击性也最小。这是因为你步行的每一帧都有可能出错,但同时也越来越无趣,因为你很少有兴趣确认线程是否在所有线程都开始的同一函数中开始。

如果每个人都同意正确地保留其 CPU 的 PERFECTLY GOOD DEDICATED FRAME POINTER REGISTER,所有这些问题基本上都会消失。开玩笑的,打开帧指针实际上也行不通,因为 Microsoft 发明了混乱帧指针,它不能用于展开!我假设发生这种情况是因为他们在穿越时空发明 minidump 时不小心踩到了错误的蝴蝶。(我确信这是一个 20 年前更有意义的决定,但它并没有很好地老化。)

如果你想了解更多关于展开的不同技术,我在这里写过它们,在我 关于 Apple 的 Compact Unwind Info 的文章 中。我也尝试 在此处记录 breakpad 的 STACK WIN 和 STACK CFI unwind info 格式,它们更类似于 DWARF 和 PE32 unwind 表(它们基本上是微型编程语言)。

如果你想了解更多关于 ABI 的信息,我在这里写了一整篇文章关于它们。那篇文章的结尾还包括 调用约定的工作原理的介绍。了解调用约定是实现展开器的关键。

你到底测试了多长时间? 希望你现在对分析 minidump 为什么如此令人头疼有一点了解。当然,你也知道故事的结局:模糊器踢了我们的屁股!但是当然,为了真正品尝我们的失败,你必须看看我们为了做好工作付出了多少努力!现在是时候建立我们的傲慢并拍拍我们的后背了。

那么在模糊器开始工作之前,_实际_有多少工作投入到使 rust-minidump 变得健壮?

相当多!

我永远不会争辩我们所做的所有工作都是_完美的_,但我们确实在这里做了一些出色的工作,无论是对于合成输入还是真实世界的输入。我们的方法中可能最大的“缺陷”是我们只专注于让 Firefox 的用例工作。Firefox 在许多平台上运行,并且看到很多搞砸的东西,但它仍然是一个相当连贯的产品,只使用 minidump 的那么多功能。

这是我们最近与 Sentry 合作的好处之一,它基本上是一家崩溃报告即服务公司。他们_更有可能_压力测试 Firefox 没有的格式的所有类型的奇怪角落,而且他们肯定已经找到(并修复!)一些地方存在错误或遗漏! (而且他们最近也将其部署到生产环境中了!🎉)

但是嘿,不要相信我的话,看看我们所做的所有不同的测试:

用于单元测试的合成 Minidump

rust-minidump 包括一个 合成 minidump 生成器,它可以让你提出 minidump 内容的高级描述,然后生成一个实际的 minidump 二进制文件,我们可以将其馈送到完整的解析器中: // 让我们使用这个特定的 Crashpad Info 制作一个 synth minidump…

let module = ModuleCrashpadInfo::new(42, Endian::Little)
  .add_list_annotation("annotation")
  .add_simple_annotation("simple", "module")
  .add_annotation_object("string", AnnotationValue::String("value".to_owned()))
  .add_annotation_object("invalid", AnnotationValue::Invalid)
  .add_annotation_object("custom", AnnotationValue::Custom(0x8001, vec![42]));
let crashpad_info = CrashpadInfo::new(Endian::Little)
  .add_module(module)
  .add_simple_annotation("simple", "info");
let dump = SynthMinidump::with_endian(Endian::Little).add_crashpad_info(crashpad_info);
// 将 synth minidump 转换为二进制文件,并像正常的 minidump 一样读取它
let dump = read_synth_dump(dump).unwrap();

// 现在检查 minidump 是否报告了我们期望的值… minidump-synth 有意避免与实际实现共享布局代码,这样对布局的不正确更改就不会“意外地”通过测试。 简要介绍一些历史:这个测试框架是由这个项目的最初负责人 Ted Mielczarek 发起的。当 1.0 发布时,他启动了 rust-minidump 作为一个学习 Rust 的副项目,但一直没有时间完成它。那时他在 Mozilla 工作,也是 Breakpad 的主要贡献者,这就是为什么 rust-minidump 有很多相似的设计选择和术语。

这种情况也不例外:我们的 minidump-synth 是 breakpad 代码中的 synth-minidump 实用程序 的一个厚颜无耻的副本,它最初是由我们的_其他_同事 Jim Blandy 编写的。Jim 是世界上我唯一会承认写了非常好的测试和文档的人之一,所以我很高兴在这里公然复制他的作品。

由于这都是一个学习实验,Ted 比平常更不严格地进行测试是可以理解的。这意味着当我来的时候,很多 minidump-synth 都没有实现,这也意味着很多 minidump 功能都没有经过测试。(他构建了一个非常棒的骨架,只是没有时间全部填充!)

我们花费了_大量_时间来填充 minidump-synth 的更多实现,以便我们可以编写更多的测试并捕获更多的问题,但这_绝对_是我们测试中最薄弱的部分。有些东西在我来之前就已经实现了,所以我甚至不_知道_缺少哪些测试!

这是代码覆盖率检查的一个很好的论据,但它可能会返回“哇,你应该编写更多的测试”,然后我们都会看着它说“哇,我们当然应该”,然后我们可能永远不会去实现它,因为有很多我们_应该_做的事情。

另一方面,Sentry 在这方面非常有用,因为他们已经_拥有_一套成熟的测试套件,其中包含他们随着时间的推移建立起来的各种奇怪的角落案例,因此他们可以轻松地识别真正重要的事情,知道修复应该大致是什么,并且可以贡献现有的测试用例!

集成和快照测试

我们通过添加更全面的测试,尽力弥补单元测试中的覆盖率问题。有一些已签入的真实 Minidump,我们有一些 集成测试 用于确保我们正确处理真实输入。

我们甚至为 CLI 应用程序编写了一堆 集成测试,这些测试快照了它的输出,以确认我们永远不会_意外地_更改结果。

这样做的部分动机是确保我们不会破坏 JSON 输出,我们还为此编写了一个 非常详细的模式文档,并且正在努力保持稳定,以便人们可以在实际实现细节仍在变化时实际依赖它。

是的,minidump-stackwalk 应该稳定并且可以在生产中使用!

对于我们的快照测试,我们使用 insta,我认为它非常棒,应该有更多人使用它。你所需要做的就是 assert_snapshot! 你想要跟踪的任何输出,它就会神奇地处理存储、加载和差异。

这是我们调用 CLI 接口并快照 stdout 的快照测试之一:

#[test]
fn test_evil_json() {
  // For a while this didn't parse right
  let bin = env!("CARGO_BIN_EXE_minidump-stackwalk");
  let output = Command::new(bin)
    .arg("--json")
    .arg("--pretty")
    .arg("--raw-json")
    .arg("../testdata/evil.json")
    .arg("../testdata/test.dmp")
    .arg("../testdata/symbols/")
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .output()
    .unwrap();
  let stdout = String::from_utf8(output.stdout).unwrap();
  let stderr = String::from_utf8(output.stderr).unwrap();
  assert!(output.status.success());
  insta::assert_snapshot!("json-pretty-evil-symbols", stdout);
  assert_eq!(stderr, "");
}

Stackwalker 单元测试

堆栈遍历器很容易成为新实现中最复杂和最微妙的部分,因为每个平台都可能有_轻微的_怪癖,你需要实现几种不同的展开策略,并仔细调整所有内容以在_实践中_良好地工作。

这方面最可怕的部分是调用帧信息 (CFI) 展开器,因为它们基本上是我们需要在运行时解析和执行的小型虚拟机。值得庆幸的是,breakpad 很久以前就通过定义一个简化和统一的 CFI 格式 STACK CFI 解决了这个问题(嗯,几乎统一了,x86 Windows 仍然是一个特例,即 STACK WIN)。因此,即使 DWARF CFI 有大量复杂的功能,我们主要需要实现一个 Reverse Polish Notation Calculator,除了它可以读取寄存器并从它计算的地址加载内存(并且对于 STACK WIN,它可以访问它可以声明和改变的命名变量)。

不幸的是,Breakpad 对这种格式的描述非常不明确,所以我基本上不得不选择一些我认为有意义的语义并继续使用它。这让我对实现_非常_偏执。(是的,我将在本部分中更加以第一人称,因为这部分确实是我个人花费了大部分时间并从头开始做了很多事情的地方。所有的责任都属于我!)

STACK WIN / STACK CFI 解析器+评估器 是 1700 行。其中 500 行是对该格式的详细文档和讨论,其中 700 行是大量约 80 个测试用例,我在其中尝试提出我可以想到的每一个角落案例。

我甚至签入了两个我_知道_失败的测试,只是为了诚实地说明有几个案例需要修复!其中一个是涉及除以负数的角落案例,几乎可以肯定无关紧要。另一个是一个错误的输入,旧的 x86 Microsoft 工具链实际上会生成并需要解析器来处理。后者在模糊测试开始之前就已修复。

5225225 _仍然_在 STACK WIN 预处理步骤中发现了一个整数溢出! (实际上并没有那么令人惊讶,这是一个 hacky 的混乱,试图掩盖 x86 Windows 展开表有多混乱。)

(这里的代码并不是很有趣,只是一堆断言,即给定的输入字符串产生给定的输出/错误。)

当然,我不满足于仅仅提出我自己的语义并测试它们:我还 将 breakpad 的大多数堆栈遍历器测试移植到 rust-minidump!这绝对发现了我的一些错误,但也教会了我 Breakpad 的堆栈遍历器的一些奇怪的怪癖,我不确定我_实际_同意。但在这种情况下,我飞得很盲目,即使与 Breakpad 兼容也是一种安慰。

这些测试还包括对非 CFI 路径的几个测试,这些路径同样不稳定和古怪。我仍然非常讨厌他们为堆栈扫描设置的许多特定于平台的奇怪规则,但我被迫假设它们可能是承重的。(我确实有好几个案例,我禁用了 breakpad 测试,因为它“显然是胡说八道”,然后在测试时在野外击中了它。我很快就学会了接受 胡说八道会发生并且不能被忽略。)

我_没有_复制的一个主要事情是 STACK WIN 的一些非常棘手的 hack。比如,他们在几个地方引入了额外的堆栈扫描,试图处理堆栈帧可能具有神秘的额外对齐方式,而 windows 展开表只是没有告诉你?我猜?

几乎肯定有一些异国情调的情况 rust-minidump 在这方面做得更差,但它可能也意味着我们在一些随机的其他情况下做得更好。我从未让两者完全一致,但在某个时候,分歧都发生在足够奇怪的情况下,而且就我而言,两个堆栈遍历器在糟糕的情况下都产生了同样糟糕的结果。在没有任何理由偏爱一个而不是另一个的情况下,分歧似乎可以接受,以保持实现更清洁。

如果你好奇,这里是一个简化的移植 breakpad 测试版本(值得庆幸的是,minidump-synth 是基于这些测试使用的相同二进制数据模拟框架):

#[test]
fn test_x86_frame_pointer() {
  let mut f = TestFixture::new();
  let frame0_ebp = Label::new();
  let frame1_ebp = Label::new();
  let mut stack = Section::new();
  // Setup the stack and registers so frame pointers will work
  stack.start().set_const(0x80000000);
  stack = stack
    .append_repeated(12, 0) // frame 0: space
    .mark(&frame0_ebp)   // frame 0 %ebp points here
    .D32(&frame1_ebp)    // frame 0: saved %ebp
    .D32(0x40008679)    // frame 0: return address
    .append_repeated(8, 0) // frame 1: space
    .mark(&frame1_ebp)   // frame 1 %ebp points here
    .D32(0)         // frame 1: saved %ebp (stack end)
    .D32(0);        // frame 1: return address (stack end)
  f.raw.eip = 0x4000c7a5;
  f.raw.esp = stack.start().value().unwrap() as u32;
  f.raw.ebp = frame0_ebp.value().unwrap() as u32;
  // Check the stackwalker's output:
  let s = f.walk_stack(stack).await;
  assert_eq!(s.frames.len(), 2);
  {
    let f0 = &s.frames[0];
    assert_eq!(f0.trust, FrameTrust::Context);
    assert_eq!(f0.context.valid, MinidumpContextValidity::All);
    assert_eq!(f0.instruction, 0x4000c7a5);
  }
  {
    let f1 = &s.frames[1];
    assert_eq!(f1.trust, FrameTrust::FramePointer);
    assert_eq!(f1.instruction, 0x40008678);
  }
}

一个专用的生产差异、模拟和调试工具

由于 minidump 是如此可怕的分形和角落案例,我花了_大量_时间害怕微妙的问题,如果我们曾经尝试部署到生产环境,这些问题将变成巨大的灾难。所以我还花了很多时间构建 socc-pair,它从 Mozilla 的 崩溃报告系统 中获取崩溃报告的 ID,并下拉 minidump、旧的基于 breakpad 的实现的输出和额外的元数据。

然后,它在 minidump 上运行一个本地 rust-minidump (minidump-stackwalk) 实现,并对两个输入执行特定于域的差异。其中最实质性的部分是对堆栈遍历进行模糊差异,试图更好地处理诸如一个实现添加了一个额外的帧但两个实现以其他方式达成一致的情况。它还使用每个实现报告的技术来尝试识别当它们完全分歧时哪个输出更值得信赖。

我还最终向它添加了一堆模拟和基准测试功能,因为我发现越来越多我想模拟生产环境的地方。

哦,我还添加了 用于堆栈遍历器的非常详细的跟踪日志,这样我就可以轻松地事后调试它为什么做出它所做的决定。

这个工具发现了很多问题,更重要的是帮助我快速隔离了它们的原因。我很高兴我做了它。因此,我们知道我们实际上_修复_了旧的 breakpad 实现中发生的几个问题,这很棒!

这是一个经过精简的 socc-pair 将生成的报告类型(是的,我滥用 diff 语法来获得错误突出显示。这是一个很棒的 hack,我喜欢它像个孩子一样):

comparing json...
: {
  crash_info: {
    address: 0x7fff1760aca0
    crashing_thread: 8
    type: EXCEPTION_BREAKPOINT
  }
  crashing_thread: {
    frames: [
      0: {
        file: wrappers.cpp:1750da2d7f9db490b9d15b3ee696e89e6aa6