模糊测试 rust-minidump 以找出尴尬和崩溃 - 第 2 部分

这是关于 rust-minidump 的系列文章的第 2 部分。第 1 部分,请参阅 此处

所以,回顾一下,我们用 Rust 重写了 breakpad 的 minidump 处理器,编写了大量的测试,并在没有任何问题的情况下部署到生产环境中。我们干掉了它,完美的工作。

而且我们仍然被模糊测试器彻底摧毁。

我开始转向 rust-minidump 工作之外,因为我需要一点调色板清洁剂,然后再开始第 2 轮(处理本机调试信息、为对 rust-minidump 感兴趣的其他团队补充功能、添加我们一直想要的额外分析,但这些分析在 Breakpad 中做起来工作量太大,等等等等)。

我仍然收到一些来自人们填补他们需要的角落的 PR,但没有什么需要太多关注的,然后 @5225225 砸碎了窗户,向我的办公室释放了一堆爆炸的模糊兔子。

我不知道他们是谁,也不知道他们为什么在那里。当我问的时候,他们只是摘下七副墨镜中的一副,说“因为我能。现在拿着这只兔子”。我照办了,拿着兔子。这真是一只好兔子。我敢说,这真是一只bnnuy:它是 libfuzzer。(嗯?你以为会是 AFL?奇怪。)

事实证明,有几个人构建了一些非常棒的基础设施,可以快速为一些 Rust 代码设置一个不错的模糊测试器: cargo-fuzz。他们甚至写了 一本关于这个过程的小书

显然,这些人做得太好,以至于 5225225 认为选择一个随机的 Rust 项目并为它实现模糊测试会是一个非常棒的爱好。然后对其进行模糊测试。并提交问题。以及修复这些问题的 PR。然后为它实现更多模糊测试。

请帮帮我,我的办公室快被兔子淹没了,我已经好几个星期没见到我妻子了。

据我所知,这个过程看起来真的很容易!我认为他们 第一个用于 rust-minidump 的模糊测试器基本上是这样的:

  • 检出项目
  • 运行 cargo fuzz init(它会自动生成一堆配置文件)
  • 用这个文件写入
#![no_main]

use libfuzzer_sys::fuzz_target;
use minidump::*;

fuzz_target!(|data: &[u8]| {
    // Parse a minidump like a normal user of the library
    if let Ok(dump) = minidump::Minidump::read(data) {
        // Ask the library to get+parse several streams like a normal user.

        let _ = dump.get_stream::<MinidumpAssertion>();
        let _ = dump.get_stream::<MinidumpBreakpadInfo>();
        let _ = dump.get_stream::<MinidumpCrashpadInfo>();
        let _ = dump.get_stream::<MinidumpException>();
        let _ = dump.get_stream::<MinidumpLinuxCpuInfo>();
        let _ = dump.get_stream::<MinidumpLinuxEnviron>();
        let _ = dump.get_stream::<MinidumpLinuxLsbRelease>();
        let _ = dump.get_stream::<MinidumpLinuxMaps>();
        let _ = dump.get_stream::<MinidumpLinuxProcStatus>();
        let _ = dump.get_stream::<MinidumpMacCrashInfo>();
        let _ = dump.get_stream::<MinidumpMemoryInfoList>();
        let _ = dump.get_stream::<MinidumpMemoryList>();
        let _ = dump.get_stream::<MinidumpMiscInfo>();
        let _ = dump.get_stream::<MinidumpModuleList>();
        let _ = dump.get_stream::<MinidumpSystemInfo>();
        let _ = dump.get_stream::<MinidumpThreadNames>();
        let _ = dump.get_stream::<MinidumpThreadList>();
        let _ = dump.get_stream::<MinidumpUnloadedModuleList>();
    }
});

就这样?你只需要输入cargo fuzz run,它就会下载、构建并启动一个 libfuzzer 实例,并在你项目中通宵查找 bug?

当然,它不会找到什么有趣的东西。哦,它找到了?基本上都是我写的代码中的 bug?太好了。

cargo fuzz 显然很棒,但我们不能低估 5225225 在此所做的令人难以置信的工作量!模糊测试器、消毒器和其他代码分析工具的非常糟糕的声誉,因为它们是偶然的贡献。

我想我们都听过这样的故事:有人在一个他们一无所知的庞大项目上运行了一个闪亮的新工具,大量地提交了一堆问题,这些问题只是说“这个工具说你的代码有问题,修复它”,然后消失在迷雾中,宣称胜利。

对于试图维护项目的某人来说,这并不是一个令人愉快的经历。如果你不了解这个工具,难以运行这个工具,不知道你到底是怎么运行它的等等,那么你会给我带来很多麻烦。

也很容易产生一堆毫无意义的问题。

有些东西只是模棱两可,而另一些东西却是可怕的漏洞。我们只有那么多时间来做事情,你必须帮我们!

在这方面,5225225 的贡献只是,太美妙了。

就像,令人震惊的棒。

他们写了非常清晰详细的问题。当我浏览这些问题并误解了它们时,他们迅速澄清,让我站在了同一立场。然后,他们在我甚至考虑修复之前提交了问题的修复方案。并迅速回复了审查意见。我甚至没有费心让他们压缩他们的提交,因为他们赢得了树中这 3 个提交,来修复一个溢出。

然后,他们提交了一个 PR 来合并模糊测试器。他们帮助我理解如何使用它以及调试问题。然后,他们开始询问有关项目的问题,并开始为项目的其他部分编写更多模糊测试器。现在有 5 个模糊测试器和一堆已修复的问题!

我不在乎 cargo fuzz 有多好,这真是太棒了!我都要哭了!太有帮助了!😭

也就是说,我还是要为事情进展得如此顺利而稍微承担一些责任:Rust 本身和 rust-minidump 都是以对模糊测试非常友好的方式编写的。具体来说,rust-minidump 中充斥着断言,用于“嗯,这似乎不对劲,不应该发生,但也许?”,Rust 将整数溢出转换为调试构建中的 panic(崩溃)(并且数组越界始终是 panic)。

在所有地方都添加大量断言,使它更容易检测到出错的情况。当你确实检测到这种情况时,崩溃通常会指向出错位置的附近。

作为一个使用消毒器和模糊测试人员在 Firefox 中检测 bug 的人,让我告诉你真正令人沮丧的是什么:“嘿,所以在我的机器上,这个巨大的复杂机器生成的输入导致 Firefox 崩溃了某个地方只有一次。不,我无法重现它。你也无法重现它。无论如何,尝试修复它?”

这不是我在贬低任何人的意思。我就是那个对话中的所有人。对 Firefox 进行模糊测试的斗争是真实存在的,我修复此类 bug 的记录并不好。

相比之下,我绝对很擅长“是的,你可以用这个可以作为单元测试签入的微小输入来确定地触发这个断言”。

我们搞砸了什么?一些真正重要的东西!它是 Rust 代码,所以我很有信心没有问题是安全问题,但它们绝对是实现质量问题,至少可以用来拒绝服务 minidump 处理器。

现在让我们深入研究他们发现的问题!

#428:损坏的堆栈会导致 ARM64 上的无限循环,直到出现 OOM

问题

如背景中所述,堆栈回溯是一个巨大的启发式混乱,你可能会发现自己倒退或陷入无限循环。为了控制这种情况,堆栈回溯通常需要向前进展

具体来说,它们要求堆栈指针向下移动堆栈。如果堆栈指针曾经向后移动或保持不变,我们只需放弃并在那里结束堆栈回溯。

但是,你不能对 ARM 太严格,因为叶子函数可能根本不会改变堆栈大小。通常情况下这是不可能的,因为每次函数调用至少必须将返回地址压入堆栈,但 ARM 有链接寄存器,它基本上是返回地址的额外缓冲区。

链接寄存器的存在,加上 ABI 要求被调用者负责保存和恢复它,这意味着叶子函数可以具有 0 大小的堆栈帧!

为了处理这种情况,ARM 堆栈遍历器必须允许堆栈遍历的第一个帧没有向前进度,然后变得更加严格。不幸的是,我对第二部分进行了手势处理,最终导致了无休止的循环,没有向前进度:

// If the new stack pointer is at a lower address than the old,
// then that's clearly incorrect. Treat this as end-of-stack to
// enforce progress and avoid infinite loops.
//
// NOTE: this check allows for equality because arm leaf functions
// may not actually touch the stack (thanks to the link register
// allowing you to "push" the return address to a register).
if frame.context.get_stack_pointer() < self.get_register_always("sp") as u64 {
    trace!("unwind: stack pointer went backwards, assuming unwind complete");
    return None;
}

所以,如果 ARM64 堆栈遍历器在一个帧上陷入无限循环,它只会构建一个无限的回溯,直到被 OOM 杀死。这非常糟糕,因为它是一个潜在的非常缓慢的拒绝服务攻击,它会吞噬机器上的所有内存!

这个问题实际上最初是在 #300 没有模糊器的情况下发现并修复的,但当我为 ARM(32 位)修复它时,我完全忘记了为 ARM64 做同样的事情。谢天谢地,模糊器足够邪恶,可以自行发现这种无限循环情况,修复方法只是“复制粘贴 32 位实现中的逻辑”。

由于这个问题实际上是在野外遇到的,我们知道这是一个严重的问题!干得好,模糊器!

(此问题专门影响 minidump-processor 和 minidump-stackwalk)

#407:MinidumpLinuxMaps 基于地址的查询根本不起作用

问题

MinidumpLinuxMaps 是一个用于查询 Linux 的 /proc/self/maps 文件转储内容的接口。这提供了有关崩溃进程中内存映射范围的权限和分配状态的元数据。

这有两个用例:获取所有进程状态的完整转储,以及专门查询特定地址的内存属性(“嘿,这个地址是否可执行?”)。转储用例通过将所有内容塞入 Vec 来处理。地址用例要求我们对条目创建 RangeMap。

不幸的是,在创建 RangeMap 的键的代码中,一个比较被翻转了,这导致每个正确内存范围都被丢弃,并且接受了无效的内存范围。模糊器能够捕获这一点,因为无效的范围在被馈送到 RangeMap 时触发了一个断言(对冗余检查欢呼!)。

// OOPS
if self.base_address < self.final_address { 
 return None; 
}

虽然为 MinidumpLinuxMaps 编写了测试,但它们不包含任何无效范围,并且只使用了转储接口,因此 RangeMap 为空的事实没有引起注意!

可能会在有人尝试在实践中实际使用此 API 时立即被发现,但很高兴我们在之前就发现了它!对模糊器欢呼!

(此问题专门影响 minidump 箱子,这在技术上可能会影响 minidump-processor 和 minidump-stackwalk。虽然它们还没有真正执行地址查询,但它们可能在被馈送无效范围时崩溃。)

#381:根据不可信列表长度预留内存导致 OOM。

问题

Minidumps 有很多列表,我们最终会将它们收集到 Vec 或其他集合中。从类似Vec::with_capacity(list_length)开始这个过程是相当自然和高效的。通常情况下这很好,但如果 minidump 被破坏(或恶意),那么这个长度可能大得离谱,导致我们立即 OOM。

我们广泛意识到这是一个问题,并在 #326中讨论了这个问题,但随后每个人都去度假了。#381 是一记不错的耳光,让我们真正修复了它,并给了我们一个免费的简单测试用例来检查。

虽然简单的解决方案是通过删除预留来解决这个问题,但我们选择了通过保护明显错误的数组长度来解决问题的方案。这使我们能够在保留性能优势的同时,使 rust-minidump 快速失败,而不是含糊地尝试做一些事情并幻想着一团糟。

具体来说,@Swatinem 引入了一个函数来检查我们正在解析的节段中剩余的内存足够大到足以容纳声称的数量的项目(基于它们已知的序列化大小)。这意味着 minidump 箱子只能被诱导预留 O(n) 内存,其中 n 是 minidump 本身的大小。

对于某些规模

  • Firefox 主进程的 minidump,大约有 100 个线程,大约 3MB。
  • 无限递归(8MB 堆栈,9000 次调用)导致的堆栈溢出的 minidump 大约 8MB。
  • Firefox 主模块的 breakpad 符号文件大约200MB

如果你正在进行符号化,Minidumps 可能不会成为你的内存瓶颈。😹

(此问题专门影响 minidump 箱子,因此也影响 minidump-processor 和 minidump-stackwalk。)

许多整数溢出和我最大的失败

发现的其他问题是相对良性的整数溢出。我声称它们是良性的,因为 rust-minidump 应该已经在假设它从 minidump 中读取的所有值都可能是损坏的垃圾的情况下工作。这意味着它的代码充满了“这是胡说八道”的检查,这些检查通常会非常快地捕获溢出(或者最坏的情况下,为某个指针打印一个胡说八道的值)。

我们仍然修复了它们,因为这逻辑很糟糕,我们希望它健壮。但是的,据我所知,这些甚至不是拒绝服务问题。

为了证明这一点,让我们讨论一下最邪恶和最令人尴尬的溢出,这绝对是我的错,我仍然对此感到愤怒,但感觉像是“怎么会这样”?!

溢出回到了我们的老朋友堆栈遍历器中。具体来说,是在尝试使用帧指针进行展开的代码中。更具体地说,是在偏移假定的帧指针以获取假定的返回地址的位置时

let caller_ip = stack_memory.get_memory_at_address(last_bp + POINTER_WIDTH)?;
let caller_bp = stack_memory.get_memory_at_address(last_bp)?;
let caller_sp = last_bp + POINTER_WIDTH * 2;

如果帧指针(last_bp)大约是u64::MAX,第一行上的偏移会溢出,我们将会尝试加载大约为 null 的值。我们所有的加载都是明确可失败的(我们假设所有东西都是损坏的垃圾!),并且在正常应用程序中,永远不会将任何东西映射到空页面,因此此加载会可靠地失败,就像我们已经保护了溢出一样。万岁!

…但在调试版本中,溢出会导致 panic,因为这就是 Rust 中调试版本的运作方式!

这个问题实际上是在没有模糊器的情况下发现、报告和修复的,在 #251中。所需要的只是一个简单的保护:

(所有强制转换都是因为这段特定代码在 x86 实现 x64 实现中使用。)

if last_bp as u64 >= u64::MAX - POINTER_WIDTH as u64 * 2 {
    // Although this code generally works fine if the pointer math overflows,
    // debug builds will still panic, and this guard protects against it without
    // drowning the rest of the code in checked_add.
    return None;
}

let caller_ip = stack_memory.get_memory_at_address(last_bp as u64 + POINTER_WIDTH as u64)?;
let caller_bp = stack_memory.get_memory_at_address(last_bp as u64)?;
let caller_sp = last_bp + POINTER_WIDTH * 2;

然后它被发现、报告和修复了再次 使用模糊器 #422中。

等等,什么?

与无限循环错误不同,我确实记得为所有展开器添加了针对此问题的保护… 但我在 64 位中执行了溢出检查即使是在 32 位平台上也是如此

拍拍额头

这使得错误报告一开始特别令人困惑,因为溢出距离针对该溢出的保护只有 3 行。事实证明,错误并不像听起来那样明显!为了理解出了什么问题,让我们更详细地讨论一下 minidumps 中的指针宽度。

单个 rust-minidump 实例必须能够处理来自任何平台的崩溃报告,即使它不是本地运行的平台。这意味着它需要能够在一个二进制文件中处理 32 位和 64 位平台。为了避免复制粘贴所有内容或使所有内容都对指针大小进行泛化的痛苦,rust-minidump 倾向于尽可能使用 64 位值,即使在 32 位平台上也是如此。

这不仅仅是我们懒惰:minidump 格式本身也是这样做的!无论平台如何,minidump 都将使用 MINIDUMP_MEMORY_DESCRIPTOR来引用内存范围,即使在 32 位平台上,其基地址也是一个 64 位值!

typedef struct _MINIDUMP_MEMORY_DESCRIPTOR {
  ULONG64                      StartOfMemoryRange;
  MINIDUMP_LOCATION_DESCRIPTOR Memory;
} MINIDUMP_MEMORY_DESCRIPTOR, *PMINIDUMP_MEMORY_DESCRIPTOR;

因此,很自然地,rust-minidump 的查询保存的内存区域的接口只是无条件地对 64 位(u64)地址进行操作,而 32 位特定代码在查询内存之前将其 u32 地址强制转换为 u64。

这意味着带有溢出保护的代码是在x86 上将这些值作为 u64 进行操作!问题在于,在所有内存加载完成后,我们将回到“本地”大小并计算caller_sp = last_bp + POINTER_WIDTH * 2。这将导致 u32 溢出,并在调试版本中崩溃。😿

但真正令人沮丧的是:到达这一点意味着我们成功地加载了该地址之前的内存。我们计算 caller_ip 的第一行读取了它!所以这个溢出意味着… 我们… 加载了… 内存… 来自一个超过 u32::MAX 的地址!

是的!

模糊器发现了绝对非常邪恶的输入

它利用了 MINIDUMP_MEMORY_DESCRIPTOR 从技术上讲 允许 32 位 minidump 定义超出 u32::MAX 的内存范围,即使它们实际上无法访问该内存! 然后,它可以让基于 u64 的内存访问成功,但仍然让“本机”的 32 位操作溢出!

这太乱了,我甚至没有 理解 它是怎么做到的,直到我自己写了测试,才意识到它并没有真正失败,因为我 愚蠢地 将有效内存范围限制在正常 x86 进程限制的 4GB 内。

我的意思是,这确实是导致 超级马里奥 64 中出现平行宇宙 的问题。

但是,我的代码可能就是写错了。我知道谷歌喜欢使用消毒剂和模糊器,所以我敢肯定谷歌的 breakpad 很早就发现了这个溢出并修复了它。

uint32_t last_esp = last_frame->context.esp;
uint32_t last_ebp = last_frame->context.ebp;
uint32_t caller_eip, caller_esp, caller_ebp;

if (memory_->GetMemoryAtAddress(last_ebp + 4, &caller_eip) &&
    memory_->GetMemoryAtAddress(last_ebp, &caller_ebp)) {
    caller_esp = last_ebp + 8;
    trust = StackFrame::FRAME_TRUST_FP;
} else {
    ...

啊。嗯。他们没有对任何 uint32_t(或 x64 实现中的 uint64_t)进行溢出保护。

好吧,GetMemoryAtAddress 确实进行了实际的边界检查,因此从 ~null 加载通常会像在 rust-minidump 中一样失败。但是,让 GetMemoryAtAddress 成功的平行宇宙溢出怎么办?

好吧,breakpad 肯定比我更注重整数宽度——

virtual bool GetMemoryAtAddress(uint64_t address, uint8_t*  value) const = 0;
virtual bool GetMemoryAtAddress(uint64_t address, uint16_t* value) const = 0;
virtual bool GetMemoryAtAddress(uint64_t address, uint32_t* value) const = 0;
virtual bool GetMemoryAtAddress(uint64_t address, uint64_t* value) const = 0;

恭喜 5225225 发现了一个在两种完全不同的语言的两种实现之间可移植的溢出,它是通过利用文件格式本身的特性实现的!

如果你想知道这个溢出的影响,它基本上是良性的。rust-minidump 和 google-breakpad 都将成功完成帧指针分析并生成一个具有 ~null 栈指针的帧。

然后,运行所有不同步骤的堆栈遍历器外部层将看到一些内容成功了,但帧指针却向后移动了。此时,它将丢弃堆栈帧并正常终止堆栈遍历,并平静地输出当时的回溯信息。完全正常且合理的运行。

我认为这就是为什么即使在 breakpad 上运行模糊器和消毒剂也不会有人注意到这一点:代码中没有任何东西实际上做错了 。无符号整数被定义为循环,程序的行为合理,一切 都还好。我们只在 rust-minidump 中注意到这一点,因为在 Rust 调试版本中, 所有 整数溢出都会导致 panic。

然而,这种“良性”行为 与正确保护溢出略有不同。两种实现通常会在帧指针分析失败时尝试继续进行 堆栈扫描,但在这种情况下,它们会立即放弃。帧指针分析正确识别失败非常 重要,以便可以发生这种级联。未能做到这一点绝对是一个错误!

但是,在这种情况下,堆栈部分处于平行宇宙中,因此从它获得任何有用的回溯信息都是……至少可以说很可疑。

所以我完全支持“这完全是良性的,实际上不是问题”,但同时我也支持“这很可疑,我们应该进行边界检查,以便对代码的健壮性和正确性充满信心”。

Minidump 都是 特殊情况——它们实际上是在 程序遇到意外情况时 生成的!这 容易让人一直耸耸肩,认为“好吧,没有哪个合理的程序会这样做,所以我们可以忽略它”……但是,你 不能

如果程序的行为合理,你不会收到 minidump!你试图检查 minidump 的事实意味着发生了错误,你需要处理它!

这就是我们投入如此多的精力来测试这个东西的原因,它简直是一场噩梦!

我对这些东西感到 极其 担忧,但这种担忧是基于我所见过的恐怖。总有更多的特殊情况。

总有 更多 的特殊情况。 总是

 

关于 Aria Beingessner

更多 Aria Beingessner 的文章…


2 评论

  1. Sora2555

    感谢您的撰写 - 我很欣赏幽默。:)

    2022 年 6 月 23 日 下午 4:35

  2. U101

    感谢您带我参观超级马里奥 64 中的平行宇宙。

    2022 年 6 月 27 日 上午 2:30

此文章的评论已关闭。