对于像 Firefox 这样复杂且高度优化的系统,内存安全是最大的安全挑战之一。Firefox 主要是用 C 和 C++ 编写的。众所周知,这些语言很难安全地使用,因为任何错误都有可能导致程序完全崩溃。

Firefox 软件工程师 Nathan Froyd 写道,“我们努力寻找并消除内存风险,但也在改进 Firefox 代码库,以便在更深的层次上解决这些问题。”

截至目前,Firefox 主要关注两项技术:

  1. 将代码分解成多个沙箱进程,减少特权
  2. 用一门安全的语言去重写代码,比如 Rust

一种新方法

“虽然我们继续在 Firefox 中使用沙箱和 Rust,但它们各有局限性。对已有的大型组件,进程级沙箱很有效,但这会消耗大量系统资源,因此必须谨慎使用。”Nathan Froyd 写道。

虽然 Rust 是轻量级的,但是重写现有的数百万行 C++ 代码是一件“劳神费力”的事。

Graphite 字形库 为例,Firefox 用它来正确呈现某些复杂字体。它太小了,不适合“放入”自己的进程中。

然而,如果发现内存风险,即使是站点隔离的进程架构也无法阻止恶意字体破坏加载它的页面。同时,重写和维护这种领域专用的代码并不是 Firefox 有限工程资源的理想用法。

如今,Firefox 在“军火库”中加入第三种方法。

加利福尼亚大学、圣地亚哥大学、德克萨斯大学、奥斯汀分校和斯坦福大学的研究人员开发出一种新的沙箱技术,叫 RLBox

Nathan Froyd 表示,“它让我们能快速有效地将现有 Firefox 组件转换为在一个 WebAssembly 沙箱中运行。我们已经成功地将该技术集成到我们的代码库中,并将其用于沙箱化 Graphite。”

据悉,这种隔离将提供给 Firefox 74 的 Linux 用户和 Firefox 75 的 Mac 用户,不久之后还将提供 Windows 支持。

构建一个 wasm 沙箱

Wasm 沙箱背后的核心实现思想是,你可以将 C/C++ 编译成 wasm 代码,然后将该 wasm 代码编译成实际运行程序的机器的本机代码。

这些步骤与 在浏览器中运行 C/C++ 应用程序 的步骤类似,但是,“我们在构建 Firefox 本身之前,就执行本地代码到 wasm 的转换。这两个步骤都各自依赖于重要的软件,我们还添加了第三个步骤,以使沙箱转换更简单、更不易出错。”Nathan Froyd 写道。

首先,你要将 C/C++ 编译成 wasm 代码。作为 WebAssembly 工作的一部分,在 Clang 和 LLVM 中添加一个 wasm 后端。光有一个编译器是不够的;你还需一个 C/C++ 的标准库。该组件是通过 wsi -sdk 提供的。一旦拥有这些组件,你就有足够能力将 C/C++ 转化成 wasm 代码。

其次,你需要将 wasm 代码转换为本机对象文件。Nathan Froyd 说,“当我们第一次实现 wasm 沙箱时,经常有人问我们,‘为什么需要这个步骤?’你可以分发 wasm 代码,并在 Firefox 启动时在用户的机器上动态编译它。我们本可以做到这一点,但这种方法要求针对每个沙箱实例重新编译 wasm 代码。“

在每个源都位于单独进程中的情况下,每个沙箱都编译代码是不必要的重复。他们选择的方法支持在多个进程间共享已编译的本机代码,从而能节省大量内存。

这种方法还提高了沙箱的启动速度,这对于细粒度的沙箱非常重要,例如,将每次字体访问或图像加载的相关联代码置入沙箱。

通过 Cranelift 实现预编译

这种方法并不意味着必须自己编写将 wasm 代码编译成本机代码的编译器。

“我们用相同的编译器后端实现这种提前编译”,它最终将通过字节码联盟的 Lucet 编译器和运行时 来支持 Firefox JavaScript 引擎的 wasm 组件: Cranelift

这种代码共享可确保 JavaScript 引擎和 wasm 沙箱编译器共享改进所带来的好处。由于工程原因,这两段代码目前使用不同版本的 Cranelift。

然而,随着沙箱技术的成熟,“我们希望修改它们以使用完全相同的代码库”。

现在,Firefox 工程师已将 wasm 代码转换为本机对象代码,“我们需要能从 C++ 调用沙箱代码”。如果沙箱代码在单独的虚拟机中运行,这一步将涉及到在运行时查找函数名以及管理与虚拟机相关的状态。

但是,通过上面设置,沙箱代码是符合 wasm 安全模型的本机编译代码。因此,可以使用与调用常规本机代码相同的机制来调用沙箱函数。

“我们必须注意所涉及的不同机器模型:wasm 代码使用 32 位指针,而我们最初的目标平台 x86-64 Linux 使用 64 位指针。但是,还有其他障碍需要克服,这就把我们带到转换过程的最后一步。”Nathan Froyd 写道

确保沙箱正确

使用与常规本机代码相同的机制调用沙箱代码很方便,但它隐藏了一个重要细节。“我们不能相信任何来自沙箱的东西,因为对手可能已经损害沙箱”。

例如,有个沙箱函数:

复制代码

/* 返回 0 到 16 之间的值。 */

intreturn_the_value();

不能保证这个沙箱函数遵循它的契约。因此,“要确保返回的值落在我们期望范围内”。

类似地,对于一个返回指针的沙箱函数:

复制代码

externconstchar*do_the_thing();

Nathan Froyd 表示,“我们不能保证返回的指针实际上指向沙箱控制的内存。对手可能会强迫返回的指针指向应用程序沙箱之外的某个地方。因此,我们在使用指针前要验证它。”

在阅读源代码时,还有一些其他的运行时约束并不明显。

例如,上面返回的指针可能指向沙箱中动态分配的内存。在这种情况下,应该由沙箱释放指针,而不是由主机应用程序释放。“我们可以依靠开发人员始终记住哪些值是应用程序值,哪些值是沙箱值”。

经验表明,这种方法是不可行的。

污染数据

上面两个例子说明一个普遍原则:从沙箱返回的数据应该被明确标识。有了这个标识,我们就可以确保以适当方式处理数据。

我们将与沙箱相关的数据标记为“污染”。污染数据可以自由地操作(例如指针运算、访问字段),生成更多污染数据。

但是,当我们将污染数据转换为非污染数据时,我们希望这些操作尽可能明确。污染数据不仅对管理从沙箱返回的内存很有价值,它对于识别从沙箱中返回的可能需要额外验证的数据也很有价值,例如指向某个外部数组的索引。

因此,我们将沙箱中所有公开的函数建模为返回污染数据。这些函数还将污染数据作为参数,因为它们所操作的任何东西在某种程度上都必须属于沙箱。

一旦函数调用有了这个接口,编译器就变成了一个污染检查器。当污染数据在需要非污染数据的上下文中使用时,编译器将发生错误,反之亦然。

这些上下文正是需要传播污染数据和 / 或需要验证数据的地方。 RLBox 处理污染数据的所有细节,并提供一些特性,可以直接将库的接口增量转换为沙箱接口。

下一步工作

有了 wasm 沙箱的核心基础结构,我们就可以集中精力提高它在 Firefox 代码库中的影响力了——既可以将它带到所有支持的平台上,也可以将它应用到更多的组件上。

由于这种技术是轻量级的,并且易于使用,我们希望在接下来的几个月里对 Firefox 的更多部件进行快速沙箱化。

我们最初的努力集中在与 Firefox 绑定的第三方库上。此类库通常具有定义良好的入口点,并且不会与系统的其他部分广泛共享内存。然而,在未来,我们也计划将这项技术应用于甲方代码。

关于作者

Nathan Froyd 是 Firefox 的软件工程师。在业余时间,他喜欢奥林匹克举重和阅读。

英文原文:

Securing Firefox with WebAssembly

相关文章