WasmBoxC:简单、轻松、快速的无虚拟机沙箱

2020-07-28 14:25:22

软件生态系统有很多有用但不安全的代码,用沙箱保护这些代码越容易,这种情况就越经常发生。如果只需向编译器传递一个--sandbox标志,使不安全的库无法看到或影响其外部的任何内容,那就太不可思议了!我们不能很容易地做到这一点,但这篇文章描述了WasmBoxC,一种非常容易使用的沙箱方法。您需要做的就是:

使用WebAssembly(Wasm)编译器而不是普通的系统编译器编译不安全的库。这在内部使用wasm,但您不需要关心这一点-您所看到的就是它发出一个带有沙箱代码的C文件。

编写一些C来与不安全库的编译后的C进行接口。(这是必要的,因为沙箱代码不能访问外部内存,而且它还使用可移植的wasm ABI。)。

编译并链接C代码,现在不安全的库与应用程序的其余部分被沙箱隔开了!在后面的小节中,我们将看到这两个步骤是多么容易的具体示例。

通过编译到wasm,我们用沙箱保护代码,防止它访问外部的任何东西。这包括内存(沙箱代码不能读写它以外的任何地方)和功能-沙箱代码除了纯计算之外不能做任何事情,除非您给它一个函数来调用它来做一些事情,如读取文件、告知时间等。我们还得到了其余的wasm安全性和可移植性保证。Wasmsandboxing甚至可以安全地在与其他代码相同的进程中运行(至少是模频谱类型的漏洞),这与软件故障隔离(SFI)非常相似。

在我们编译了一个不安全的wasm库之后,我们如何将其作为应用程序的一部分来运行呢?我们可以集成一个wasm VM并在那里运行wasm。但是,使用WasmBoxC时,我们采用了一种无VM的方法,将wasm编译成本机代码,同时保留了wasm语义,包括沙箱,该本机代码可以正常链接到应用程序中,这比集成wasm VM简单得多。

WasmBoxC将wasm编译为本机代码的具体方法是使用wabt的wam2c工具将其编译为C,然后在其上运行标准C编译器。事实上,WasmBoxC的方法编译成C的一个简单子集,这是该方法如此简单的很大一部分原因,并带来了几个优点:

它让我们可以使用像clang或GCC这样的C编译器来快速编写沙盒代码。

单个构建的C代码几乎可以在任何平台上编译和运行,并且与沙箱交互的代码也只需要编写一次。

尽管使用C很简单,但WasmBoxC沙箱的开销很低:使用一些不可移植的C代码(“信号处理程序技巧”,见后文)只有14%,而在100%可移植的C中只有42%(根本没有特定于操作系统或CPU的操作)。我们还将看到在14%和42%的数字之间有选项。

WasmBoxC的基本思想很简单,不是独创的。这篇文章的创新之处在于展示了该方法的工作原理,对真实世界的代码进行了基准测试以证明它是快速的,给出了用沙箱对真实世界的库进行沙箱是多么容易的完整示例,并详细描述了该方法的优点(特别参见关于内存安全语言的一节)。这篇文章还为该技术起了一个名字。

为了了解WasmBoxC的速度,让我们来看看20个基准测试,比较一下clang 9.0.1、clang 11(截止到2020年5月23日的开发版本)、GCC 9.2.1和WasmBoxC。所有数字都归一化为clang 9(因此等于1;数字越小越好)。

这些基准测试包括各种各样的代码,以zzz_为前缀的是真实的代码库或基准测试:Box2D和BulletPhysical引擎、CoreMark和LINPACK基准测试、Lua VM(一个GC和一个计算基准测试)、LZMA和zlib压缩库以及SQLite数据库。实际上,这表明WasmBoxC现在可以运行所有这些!

WasmBoxC显示了两个结果,代表了内存沙箱的两个实现。第一种是显式沙箱,其中使用显式检查(即,在每次内存访问之前执行IF语句)来显式验证每个内存加载和存储是否在沙箱内存中。这有42%的开销。

基于操作系统的实现使用wasm VM的“信号处理程序技巧”。该技术在有效范围内保留大量内存,并依赖CPU硬件在访问越界时给出信号(有关更多背景信息,请参阅Tan,2017中的3.1.4节)。这是完全安全的,并且具有避免显式边界检查的好处。它只有14%的开销!但是,它不能用于所有地方(它需要信号和CPU内存保护,并且只能在64位系统上运行)。

在这14%到42%的数字之间有更多的选择。显式和基于操作系统的沙箱完美地保留了wasm语义,也就是说,陷阱将恰好在wasm VM将被捕获时发生。如果我们愿意放松这一点(但如果我们这样做了,我们可能不想称之为wasm),那么我们可以改用掩蔽沙箱(参见Tan,2017中的3.1.3节),它是100%可移植的,就像显式沙箱一样,还可以防止任何沙箱外的访问,并且开销为29%,略快一些。沙箱的其他改进也是可能的--几乎还没有人在这方面做过任何努力。

在lua_binarytree和havlak基准测试中发生了一件有趣的事情,在所有沙箱模式下,WasmBoxC实际上都比GCC和Cang都快,高达32%!我们如何才能击败普通的本机构建,并且领先这么多呢?考虑到这一点,这两个基准测试都使用了大量的错误锁定和带有指针的数据结构,与x32ABI一样,wasm是32位的,所以指针占用了一半的空间。在测量lua_binarytree中使用的最大进程内存时,WasmBoxC使用的内存减少了33%,这对CPU高速缓存的使用有很大帮助。虽然这对这两个基准测试有很大的影响,但由于这个因素,我们很可能也会在其他基准测试中获得一些加速,因为平均而言,x32比正常的x64快5-8%。Wasm是获得类似x32的好处的一个很好的方法!

这里的基准测试衡量沙箱中的性能。它不测量从外到内或从内到外的呼叫速度。这样的调用可以非常快,因为沙箱代码只有C语言,这意味着我们甚至可以安全地内联跨越沙箱边界!-如果我们Dolto的话。我在下一节的沙箱示例中验证了这一点,请参见后面的内容。(但是请注意,那么使用信号处理程序技巧可能会使这里的事情变得更加复杂。)。

我们可以用任何本机编译器编译WasmBoxC的C代码。在上面的示例中,为了简单起见,我们总是使用clang 9。当改变编译器时,结果略有不同,例如,“显式”沙箱结果在GCC 9.2中从14%上升到16%,或者在clang11中下降到11%。14%这个数字没有什么神奇之处--我们正处于本机编译器差异的关键时刻。

随着时间的推移,结果应该会有所改善,因为wasm添加了更多的性能特性,比如SIMD(请注意,与本机编译器相比,本机编译器可能已经从自动矢量化中获得了优势)。

WasmBoxC可以达到14%的开销,这表明通过wasm编译的成本(例如,它不能代表不可减少的控制流)相当低,而且目前的wasm编译器并没有引入大量不必要的开销。

我已经尽了最大努力仔细而准确地测量这里的一切,但我也有可能在什么地方弄错了。请检查我的工作,看看您是否得到类似的结果!

//my-code.c#include<;stdint.h>;#include<;stdio.h>;//我们也可以包含这些文件的.wasm.h文件,//但让';手动声明externs作为示例。Extern void wambox_init(Void);extern uint32_t(*Z_twiceZ_ii)(Uint32_T);extern uint32_t(*Z_do_ad_thingZ_ii)(Uint32_T);int main(){put(";初始化沙盒不安全库";);wasmbox_init();printf(";调用21两次返回%d\n&#。现在调用坏东西...";);Z_DO_BAD_ThingZ_II(1);放置(";(这将永远不会打印,因为坏东西会陷入陷阱)";);}。

Main()非常简单:初始化,在沙箱中调用为我们执行计算的东西,然后调用将被困在沙箱中的东西。(Z_Stuff是怎么回事?请参见后面的“API”部分。)

//unsafe-lib.c#include<;stdlib.h>;__ATTRIBUTE__((USED))INT TWORE(Int X){return x+x;}__ATTRIBUTE__((USED))INTO DO_BAD_THING(INT SIZE){//在此分配未知大小(因此LLVM优化器不知道//稍后的存储是否有效)。Char*x=malloc(Size);//写入一个绝对不在沙箱中的地址(默认//内存大小要小得多),这在wasm中会陷入陷阱。X[1024*1024*1024]=42;//避免优化器知道存储永远不会被观察到。Return(Int)x;}。

Two()做了您所期望的事情,而Do_Bad_Thing做了一个肯定会陷入陷阱的商店。(忽略那里的细节;在本例中,我们需要LLVM优化器不要将错误代码作为未定义的行为删除!)。

下面是使用WasmBoxC获得与我们的正常代码链接的完全沙箱库是多么容易:

#通常将我们的主代码构建为对象$clang my-code.c-c-O3-o my-code.o#使用emcc$emcc unsafe-lib.c-O3-o unsafe-lib.wasm-s WASM2C--no-entry#将不安全库构建为C,通常为$clang unsafe-lib.wasm.c-c-O3-o unsafe-lib.o#link Normal$。

非常简单!这里唯一“有趣”的部分是,第二个命令使用wasm工具链将库编译为wasm,然后再编译为C。这里我们使用Emscripten(有关如何获取它的信息,请参阅下载说明;下一小节中的zlib示例也将介绍这一点)。注意,那里没有-c,因为从EMCC的角度来看,它是到wasm的完整编译+链接,之后它会为我们运行wam2c,为此我们传递-s WASM2C。还要注意的是,我们告诉它--因为我们要在这里建一个图书馆,所以不能进入。

$./Program初始化沙盒不安全库在21上调用两次会返回42现在调用了一些错误的东西...。[WASM陷阱%1,正在停止]。

除了使用WasmBoxC构建代码很容易之外,还很容易了解它是如何工作的:只需读取生成的C代码即可。例如,我们之前调用了Z_twiceZ_II。如果我们很好奇那是什么,我们可以直接读取C,并看到除了函数指针间接之外,它只是一个普通的C函数:

Static u32 w2c_Two(U32 W2c_P0){//[..正文中的代码,以Return..]}

我们可以确切地看到它需要哪些参数、返回什么、读取u32的typedef值等等。正文中的实际编译代码(这里省略)可读性不是很好,但它仍然是C代码。这让我们可以做一些事情,例如添加方便的printf进行调试。这也是为什么我们前面说LTO可以跨沙箱内联:沙箱中的代码只是更多的C代码。下面是对Twice的调用及其结果的打印(在LLVM IR中,在LLVM LTO之前):

定义I32@Main()#0{[..]%2=加载I32(I32)*,I32(I32)**@Z_twiceZ_II,Align 8,!tbaa!1%3=尾部调用I32%2(I32 21)#8%4=尾部调用I32(i8*,...)@printf(i8*getelementptr入站([32 x i8],[32 x i8]*@.str,i64。I32%3)。

定义I32@main()#0{[..]%6=尾部调用I32(i8*,...)@printf(i8*非空可取消引用(1)getelementptr入站([32 x i8],[32 x i8]*@.str,i64 0,i64 0),I32 42)。

注意在LTO之前,我们如何加载一个函数指针,然后用21调用它,然后打印结果。函数指针在那里是因为wam2c发出了非常灵活的代码,比我们实际上需要的更多。我们可能想要添加一个选项来避免这种间接性,但正如您所看到的,LTO已经可以解决这个问题:事实上,它设法将对两次(21)的调用替换为main中的常量42,完全避免了调用!跨沙箱边界的优化可以带来巨大的好处,而且它是完全安全的。

关于发出的C代码的最后一点说明:如果您真的阅读了它,您会注意到它看起来并不是非常优化。这是因为wasm本身的级别非常低,并且wam2c转换以一种简单而准确的方式进行-它不会试图发出“最佳的”C代码,这种简单性意味着要快速地依赖于C编译器优化,这就是这个示例使用-O3编译的原因。

有关移植整个世界的库(而不仅仅是单个文件)的更完整的演练,请参见这个简单的演示,它展示了用沙箱保护zlib压缩库是多么容易。这包括如何获得wasm工具链的全部细节,因此您可以从头开始一步一步地遵循这些说明。

这还展示了如何在沙箱中进行内存管理的示例,这非常简单:

沙箱代码看到的“内存”是运行时错误分配的单个内存缓冲区。沙箱确保编译后的代码只能访问该缓冲区,而不能访问其他缓冲区。

当您从沙箱获取指针时,您可以直接读取该内存(也就是说,没有任何东西可以阻止外部查看)。您可以通过读取该指针偏移处的缓冲区来执行此操作。也就是说,如果缓冲区位于绝对(正常,不在沙盒中)地址buf,并且您想要在值为ptr的沙盒指针引用的位置读取数据,则您将在绝对地址buf+ptr读取数据。

沙箱代码有自己的malloc和free,对该代码来说这看起来很正常,但只保留和释放了单个缓冲区中的内存范围。如果您想要将一些数据传递到沙箱中,一种简单的方法是在沙箱中malloc(使用沙箱的malloc)并将数据复制进去。

如果我们一直使用叮当声而不是EMCC,这可能会更简单。我们也可以做到这一点!关于WasmBoxC方法没有特定于Emscripten,我们所需要的只是一个编译器来wasm、wam2c以及wasm2c输出的运行时支持。普通的clang也可以工作,因为它支持wasm;假设您已经在使用eclang,那么您唯一需要添加的新构建工具就是wam2c。或者您可以使用WASI SDK或其他任何工具。在Emscripten中添加wam2c集成和运行时支持并不太难,在其他地方可能也会类似。

我专注于Emscripten,因为无论如何出于其他原因,我已经在那里做了集成wasm2c的工作。Emscripten还支持移植目前最广泛的软件,这就是我们可以运行基准测试部分中提到的所有代码库的方法。Emscripten做了许多有用的优化,以产生快速的wasm,这有助于了解WasmBoxC可以有多快。

在引言中已经提到了软件故障隔离,WasmBoxC与它的主要不同之处在于使用wasm和通过C进行编译。因此,WasmBoxC方法的一个具体限制是您必须从源代码编译代码,这与可以对二进制文件进行操作的SFI方法不同。

使用掩蔽的SFI可以实现12%的开销,这明显好于我们观察到的29%。WasmBoxC的任务沙箱可能会改进--目前还没有人在这方面下功夫。

WasmBoxC类似于MinSFI(其灵感来源于asm.js,wasm的前身之一),其关键思想是将不安全的代码转换为沙盒形式。它在LLVM IR上这样做,而WasmBoxC转换为wasm,它本身就是沙箱的(这个领域的另一个例子是Kroll、Stewart和Appel,2014,它在CompCert的IR上工作)。

这种方法的大部分工作是正确定义沙箱表单(不犯任何安全错误),并实现它(这意味着大量的工具工作-我们需要能够将现实世界的代码转换为该表单)。当MinSFI被创建时,wasm还不存在,也没有一个很好的替代方案,但今天的wasm非常适合这个需要:指定和实现它的所有艰苦工作都已经完成,我们可以在WasmBoxC中使用它。

RLBox描述了对不可信代码进行细粒度隔离的框架。其中一种隔离机制(参见本文第9节)使用wasm;称之为“rlbox-wasm”。

与rlbox-wasm类似,WasmBoxC先编译成wasm,然后编译成本机代码,不同之处在于生成本机代码的方式。Wasm使用Lucet的定制版本,使用CraneLift生成本机代码,而WasmBoxC编译为C,然后使用标准的C编译器,如clang或GCC。为了考虑由此造成的性能差异,让我们看一下RLBox论文中的这段话:

我们发现,Wasm沙箱[结合使用RLBox和Lucet]会带来85%的开销[..]。我们将这种放缓很大程度上归因于新生的Wasm工具链,比如LLVM,它们还不支持与之相提并论的性能优化。

WasmBoxC在这种情况下提供了一个有用的比较点,因为它可以使用LLVM。实际上,由于WasmBoxC有14%-42%的开销,它支持报价的断言,即RLBox-wasm当前85%的开销很大一部分是由于CraneLift是相当新的(但它正在取得良好的进展)。(然而,RLBox-wasm只基于一个基准,libGraphite,这限制了我们的推广能力。)。

RLBox-wasm和WasmBoxC之间的另一个性能差异是RLBox-WASMHAS在沙箱和外部之间的蹦床。通常,这样的蹦床进行上下文和堆栈交换等,并且可能具有显著的开销。在RLBox-wasm中,他们在Lucet中使用了自定义蹦床,将开销降低了800%,几乎为零。与WasmBoxC相比,正如我们前面看到的,沙盒代码只是纯C,根本没有蹦床,甚至跨边界的内联也可以工作。

RLBox-wasm的一个优势是CraneLift是一个专用的wasm编译器,因此它可以使用特定于wasm的技术。例如,它可能为沙箱内存固定一个寄存器,或者它可能在沙箱中使用非标准的调用约定。使用WasmBoxC的普通C代码无法完成这些工作。现在看来,LLVM的总体优势超过了使用特定于wasm的编译器,但随着时间的推移,这种情况可能会改变。

与WasmBoxC相比,RLBox-wasm的另一个优势是构建时间:WasmBoxC编译TOC,然后运行完整的C编译器,而CraneLift设计用于编译wamto本机代码。WasmBoxC方法在编译过程中固有地增加了额外的步骤。然而,正如在这篇文章中所提到的,使用C语言不仅对速度有好处,而且对易用性也有好处,所以总体来说,在这个领域有一些有趣的权衡。

编辑:有人向我指出,RLBox在2019年1月的一个早期版本使用了wam2c,这一点我并不知道。

WasmBoxC有多安全?与任何新事物一样,您现在应该假设它是试验性的。但是,它是建立在经过良好测试的基础上的,特别是wasm本身、wasm工具链组件(如clang)和标准C编译器。据我所知,关键的wam2c组件还没有投入生产,但良好的迹象表明,wam2c通过了wasm spec测试套件,该测试套件涵盖了许多包装箱和可移植性的情况,我们已经对其进行了模糊化。

另一件有用的事情是可以检查C输出的安全性。很容易看到,所有内存访问都要经过相同的几个加载/存储方法,并且这些方法都保证驻留在沙箱中。虽然C不是一种内存安全的语言,但我们发出的非常简单的C形式实际上应该是安全的,而且很容易被看作是安全的。

如果安全人员可以看看WasmBoxC,那就太好了--请帮帮忙!

就现在哪些代码可以沙箱而言,在当前的实现中,它基本上是Emscripten可以移植到WASM的任何东西,这是相当多的-例如,它被用来移植许多整个游戏引擎,正如我们看到的那样,它可以移植我们基准的所有代码库。

.