跳到主要内容

为C++改造时间内存安全

· 阅读需 11 分钟
Anton Bikineev, Michael Lippautz ([@mlippautz](https://twitter.com/mlippautz)), Hannes Payer ([@PayerHannes](https://twitter.com/PayerHannes))
备注

注意: 本文最初发布在Google安全博客

Chrome的内存安全是一个持续不断的努力,以保护我们的用户。我们不断尝试使用不同的技术以超越恶意行为者。在这种精神下,这篇文章介绍了我们使用堆扫描技术来改进C++的内存安全的旅程。

让我们从头开始。在应用程序的生命周期中,其状态通常以内存形式表示。时间内存安全指确保内存始终以其结构和类型的最新信息进行访问的问题。不幸的是,C++没有提供这样的保障。虽然我们对比C++内存安全性更强的语言有一些兴趣,但大型代码库如Chromium在可预见的未来仍将使用C++。

auto* foo = new Foo();
delete foo;
// 指向foo的内存位置已不再表示一个Foo对象,
// 因为此对象已被删除(释放)。
foo->Process();

在上述示例中,foo在其内存被归还给底层系统后仍被使用。过时的指针称为悬空指针,通过它的任何访问都会导致释放后使用(UAF)的情况。在最好的情况下,此类错误会导致定义明确的崩溃,在最坏的情况下,它们会引发恶意行为者可以利用的微妙问题。

在较大的代码库中,UAF通常很难发现,因为对象的所有权会在多个组件之间转移。这个一般性问题如此广泛,以至于至今业界和学术界仍在定期提出缓解策略。例子不胜枚举:使用各种类型的C++智能指针可以更好地在应用层定义和管理所有权;编译器中的静态分析会避免编译有问题的代码;在静态分析失败的情况下,动态工具如C++ Sanitizers可以拦截访问并在特定执行中捕获问题。

遗憾的是,Chrome使用C++也不例外,大多数高严重性安全漏洞是UAF问题。为了在问题到达生产环境之前捕获它们,我们使用了上述所有技术。除了常规测试以外,模糊测试还确保动态工具始终有新的输入可处理。Chrome甚至更进一步,采用了一种称为Oilpan的C++垃圾收集器,这种技术偏离了常规的C++语义,但提供了时间内存安全。在这种偏离不合理的情况下,最近引入了一种新型智能指针叫做MiraclePtr,可在使用时对悬空指针的访问进行确定性的崩溃。Oilpan、MiraclePtr和基于智能指针的解决方案需要应用代码的重大修改。

在过去十年中,另一种方法取得了一些成功:内存隔离。其基本思想是将显式释放的内存置于隔离区,仅在达到一定安全条件后才使其可用。Microsoft已经在其浏览器中实现了此缓解措施的不同版本:MemoryProtector在2014年的Internet Explorer中推出,其后继者MemGC在2015年的(非Chromium)Edge中推出。在Linux内核中采用了一种概率方法,其中内存最终只是被回收。近年来,这种方法在学术界也受到关注,例如MarkUs论文。本文的其余部分总结了我们在Chrome中试验隔离区和堆扫描的旅程。

(在这一点上,有人可能会问内存标记在这个场景中适合什么位置——继续阅读!)

隔离与堆扫描的基本知识

通过隔离和堆扫描来确保时间安全的主要思想是避免重复使用内存,直到证明没有任何(悬空的)指针引用它。为了避免改变 C++ 用户代码或其语义,会拦截提供 newdelete 的内存分配器。

图 1:隔离的基本知识

调用 delete 时,内存实际上被放入隔离区,在这个区域无法被应用程序的后续 new 调用重新使用。在某个时刻会触发堆扫描,类似垃圾回收器的行为,扫描整个堆以查找引用隔离内存块的内容。那些没有来自常规应用程序内存的输入引用的块将被转回分配器,可以用于后续的分配。

有各种增强选项,但会带来性能成本:

  • 用特殊值(例如零)覆盖隔离内存;
  • 在扫描运行时停止所有应用程序线程或并发扫描堆;
  • 拦截内存写入(例如通过页面保护)以捕获指针更新;
  • 按字扫描内存以查找可能的指针(保守处理)或为对象提供描述符(精确处理);
  • 将应用程序内存隔离为安全和不安全分区,以选择排除某些对性能敏感或能被静态证明为安全跳过的对象;
  • 除了扫描堆内存外,也扫描执行堆栈;

我们将这些算法的不同版本称为 StarScan [stɑː skæn],或简记为 *Scan

现实情况检验

我们将 *Scan 应用于渲染进程的非托管部分,并使用 Speedometer2 来评估性能影响。

我们对 *Scan 的不同版本进行了实验。为了尽可能最小化性能开销,我们测试了一种配置,该配置使用单独的线程扫描堆,避免在 delete 时急切清除隔离内存,而是在运行 *Scan 时清除隔离内存。我们选择使用 new 分配的所有内存,初始实现中未区分分配地点和类型以保持简单。

图 2:在独立线程中扫描

注意,所提出的 *Scan 版本并不完整。具体来说,恶意行为者可能通过将一个悬空指针从未扫描区域移动到已扫描内存区域,从而利用扫描线程中的竞争条件。修复这个竞争条件需要跟踪已扫描内存块中的写入,例如使用内存保护机制拦截这些访问,或在安全点完全停止应用程序线程以防止对象图的修改。无论哪种方式,解决这个问题都会带来性能成本,并表现出一个有趣的性能与安全权衡。注意,这种攻击并不通用,并非对所有 UAF 都有效。引言中所述的问题不会容易受到这种攻击的影响,因为悬空指针没有被复制。

由于安全效益确实取决于这种安全点的粒度,并且我们希望实验最快版本,我们完全禁用安全点。

运行我们的基础版本在 Speedometer2 上将总分减少了 8%。令人遗憾……

这些性能开销从何而来?不出意料,堆扫描对内存要求很高且成本昂贵,因为扫描线程必须遍历和检查整个用户内存的引用。

为了减少退化,我们实施了各种优化以提高原始扫描速度。自然,扫描内存最快的方法就是根本不扫描。因此我们将堆分为两类:可以包含指针的内存和我们可以静态证明不包含指针的内存,例如字符串。我们避免扫描任何不可能包含指针的内存。注意,这些内存仍在隔离区中,只是不被扫描。

我们将此机制扩展到涵盖作为其他分配器(例如 Zone 内存,由 V8 为优化 JavaScript 编译器管理)支持内存的分配。这样的 Zone 总是一次性丢弃(参见基于区域的内存管理),并且通过 V8 中的其他方法确保时间安全。

此外,我们应用了几种微小优化来加速并消除计算:我们使用帮助表进行指针过滤;利用 SIMD 快速处理内存绑定的扫描循环;并最小化提取和带锁前缀指令的数量。

我们还通过调整扫描所花费的时间与实际执行应用代码所花费的时间(参见垃圾收集文献中的变异体利用率)来改进最初的调度算法,该算法仅在达到某个限制时开始堆扫描。

最终,该算法仍然受到内存的限制,而且扫描仍然是显著昂贵的程序。然而优化帮助将 Speedometer2 的回归从 8% 降低到 2%。

尽管我们改进了原始扫描时间,但内存处于隔离状态的事实增加了进程的整体工作集。为了进一步量化这种开销,我们使用了一组选定的 Chrome 的真实浏览基准测试来衡量内存消耗。*Scan 在渲染器进程中的应用导致内存消耗增加约 12%。是这种工作集的增加导致了更多内存被分页,这在应用程序快速路径上是显著的。

硬件内存标记的援助

MTE(内存标记扩展)是 ARM v8.5A 架构上的一项新扩展,用于帮助检测软件内存使用中的错误。这些错误可能是空间错误(例如越界访问)或时间错误(使用已释放的内存)。该扩展的工作原理如下:每 16 字节的内存都会分配一个 4 位标记。指针也会分配一个 4 位标记。内存分配器负责返回一个与分配的内存具有相同标记的指针。加载和存储指令验证指针与内存标记是否匹配。如果内存位置和指针的标记不匹配,则会引发硬件异常。

MTE 对使用已释放内存不会提供确定性的保护。由于标记位数有限,内存和指针的标记可能由于溢出而匹配。有 4 位标记时,仅需 16 次重新分配就可能导致标记匹配。恶意攻击者可能利用标记位溢出来实现使用已释放的内存,只需等待悬空指针的标记再次与所指向内存的标记匹配。

*Scan 可用于解决这一问题的极端情况。每次调用 delete 时,底层内存块的标记会通过 MTE 机制递增。大部分时间里,这些块可以在 4 位标记范围内递增来进行重新分配。过时的指针会引用旧标记,因此在解引用时可靠地崩溃。当标记溢出时,对象会被放入隔离区并由 *Scan 处理。一旦扫描验证该内存块没有更多悬空指针,它会被返回到分配器。这减少了扫描次数及其伴随的开销约 16 倍。

以下图片展示了这一机制。指向 foo 的指针最初拥有标记 0x0E,这使得它可以递增一次以分配 bar。调用 delete 删除 bar 时标记溢出,内存实际上被放入 *Scan 的隔离区。

图 3:MTE

我们拿到了一些支持 MTE 的实际硬件,并在渲染器进程中重新进行了实验。结果令人鼓舞,因为 Speedometer 的回归处于噪声范围内,而 Chrome 的真实浏览故事的内存占用仅回归了约 1%。

这是否是传说中的免费午餐?事实证明 MTE 有一些成本,但已经支付了这些成本。具体而言,PartitionAlloc——即 Chrome 的底层分配器——默认会为所有支持 MTE 的设备执行标记管理操作。此外,从安全角度看,内存应该实际被尽快清零。为了量化这些成本,我们在支持 MTE 的早期硬件原型上运行了几种配置的实验:

A. 禁用 MTE 且不清零内存; B. 禁用 MTE 但清零内存; C. 启用 MTE,但不使用 *Scan; D. 启用 MTE,使用 *Scan;

(我们也知道存在同步和异步 MTE,这也会影响确定性和性能。为了进行这次实验,我们持续使用异步模式。)

图 4:MTE 回归

实验结果表明,MTE 和内存清零带来了一些成本,大约是 Speedometer2 的 2%。需要注意的是,PartitionAlloc 和硬件尚未针对这些场景进行优化。实验还显示,在 MTE 的基础上添加 *Scan 不会产生可测量的成本。

结论