WebAssembly and Back Again: Fine-Grained Sandboxing in Firefox 95
hacks.mozilla.org
hacks.mozilla.org
Security based on process isolation is extremely inefficient and coarse-grained - having a trusted compiler could (eventually) massively increase performance by removing processes entirely (no more virtual memory! no more TLB flushes and misses! less task switch overhead!) and eliminating the kernel/user mode separation, with an increase in security.
"Could" because it's not clear to me if the reduction in expressiveness from our languages now to future languages with a theoretical trusted compiler (all jump targets have to be known at compile-time?) will be accepted by the majority of the populace. Look at how hard it is to get people to accept borrow-checkers...
I saw a talk a while ago that was advocating for the same thing, except this was about JS and not webassembly. I can't find it tho - I remember it being related to the WAT js talk; It also mentioned that it would eliminate rings on the cpu (and simplify cpus) and context switches which would make execution faster; they were citing some MS research on the matter - damn I really wanna find the talk now...
Edit: https://www.destroyallsoftware.com/talks/the-birth-and-death...
thanks BoppreH
MS research: "Hardware-based isolation incurs nontrivial performance costs (up to 25-33%) and complicates system implementations" (virtual memory and protection rings); I think MS knows what they're talking about here
https://en.wikipedia.org/wiki/Singularity_(operating_system)
There is also a really great blog about Singularity’s “rebirth” experimental OS, Midori, that continued in its footsteps.
To put in another way - I don't think that security or performance are that hard to achieve on their own - the hard part is getting both at once. And then, adding expressiveness on top is even more difficult, as Rust as aptly demonstrated.
Sorry I have to quibble here, but this term is already a thing, and typically has the opposite connotation: a compiler that must be trusted because we cannot verify the output. It's trusted because we "trust" it (to not screw up).
I would argue that this makes the compiler untrusted--we don't care what it does, whatever it outputs is going to be both statically and dynamically verified to not break the sandbox properties.
> Security based on process isolation is extremely inefficient and coarse-grained - having a trusted compiler could (eventually) massively increase performance by removing processes entirely (no more virtual memory! no more TLB flushes and misses! less task switch overhead!) and eliminating the kernel/user mode separation, with an increase in security.
I thought this right up until, well, about this time in 2017. Side-channel attacks are a real and bad thing. Our conclusion is that Spectre, in all its flavors, break confidentiality for in-process memory, regardless of sandboxing technology. On current hardware, there is no 100% bulletproof way to enforce isolation.
Hasn't this been one of the goals of the design of WebAssembly since day one, and something that has been getting people excited about it too? Using something for one of its intended purposes isn't really a "hack", no?
This is a bit of a hack because Mozilla don't care about the platform-agnostic bit, so they're taking LLVM IR, compiling it to WebAssembly, then back to LLVM IR just so that they can ensure that the code is safe.
WebAssembly comes with extra constraints that you probably don't care about if you're compiling to native (e.g. there's no 'goto') so you would get more efficient code (and probably faster compilation) if you just had some way of compiling LLVM IR directly to "safe binary".
That would probably be a mountain of work though so its understandable why they went with this. Would be nice if they said how much the performance was impacted.
Compiling via C might be considered a hack (especially in the sense that it introduces a potential weak link in the chain), but it makes a lot of sense since they want to integrate the result into the Firefox build artefacts, and compiling via C is not exactly novel either.
I get that it's tongue in cheek, but it would probably be even funnier / ironic if I had more context to understand it.
It's the same spirit as languages adopting functional programming techniques aka rediscovering Lisp.
IIRC Microsoft explored something similar with their research OS Singularity.
That's fascinating. How does it do so? I thought it only separated the address space into user space and OS space.
I’m interested in how the compiler ensures process isolation.
I'm surprised clang doesn't simply have a mode to do this already. Its weird we need this strange wasm dance. I'm imagining a sort of a release mode version of ASAN + UBSAN where the compiled code has bounds checks for all array and pointer lookups. (And probably some more checks, besides). The rule is that it should be impossible for any binary code compiled this way to overrun arrays, or read or write memory outside of its allocated section. No matter the C code, the binary is memory-safe.
It would have a (modest) cost in runtime performance and binary size, but frankly that would be a trade off well worth making in all sorts of software we use daily.
Compiling to WebAssembly doesn't make the code safe; it simply compiles it to a virtual machine with a memory model that isolates any bugs from the rest of the parent process (presuming a bug-free parent process).
Basically, all pointers in WebAssembly are indexes to a global array (or global arrays--one for data and another for functions). It's basically the same trick people use in Rust to get around restrictions on reference cycles. And like in Rust, the compiled code can still have the same dangling pointer and buffer overflow problems, but the memory thus referenced is restricted to the unstructured array, rather than to potentially any address in the parent process' address space.
The difference between Rust and WASM is that when you compile WASM to native code, at least when JIT'ing the code, you can completely elide the extra degree of memory reference indirection. (I don't think the extra indirection can be elided for AOT compilation of WASM code without a special runtime linking step.) The cost, however, is that you're left with this single global data array, so any bug in the WASM program can break any other part of the WASM program, whereas in Rust you effectively end up with many segments of unstructured memory (basically, one per component that uses the trick) that can't be cross-referenced if the program is buggy.
Fundamentally, memory safety requires that you keep track of not just where a pointer currently points to, but where a pointer could validly point to. But, as applied in practice, the C ABI requires that pointers be integers of where it currently points to; making pointers also include range information (aka "fat" pointers) is possible, but requires rewriting almost any software you care about--it's the kind of change that's about as fundamental to C programs as assuming 2's complement arithmetic.
The way tools like ASAN and Valgrind work is by instead keeping track of where any pointer could point to (via shadow maps, basically a bit per memory location saying if you can read/write that location), and then padding every allocation with sufficient space that an innocuous read overflow (especially one-past-the-end) is likely to hit the padding. Their guarantees are at best probabilistic, not anything you would want to rely on for security.
So, if you pick your fat pointer implementation poorly, then yes, you run up against both the standard and de-facto C, but if you take care then the vast majority of C code just works, and we're (uniquely?) positioned to be able to prove that by having a FreeBSD-based kernel and userspace, and graphical desktop stack (plus an adaptation of WebKit's JSC JIT) all built with a compiler that maps C pointers to capabilities that enforce fine-grained bounds.
I think you're right but "validation around pointers and array lookups" makes it sound far better than it is. That sounds like the sort of strong validation is "safe" Rust gives you. But all it does is ensure the WASM virtual machine "executing" the WASM code doesn't access RAM outside of the VM's memory space. Everything in that memory space is fair game, so all the foot shooting stuff C allows like corrupting pointers and following them within that memory space is still possible.
It achieves what they want for security I guess, but not much more.
I guess that the idea that trusted compilers are the way forward is predicated on the assumption that we've managed to mitigate most/all side-channel attacks, because there really isn't much you can do about those otherwise.
Enforcing this kind of isolation in software will likely be quite difficult though.
Alternatively, you could forbid branches (and therefore loops, implicitly).
[0]: https://plsyssec.github.io/rlbox_sandboxing_api/sphinx/
source code -> WASM -> C -> native code
My engineer side is happy seeing how strong tooling enables such creative features with high assurances.
My futurist side is dreading the day Intel launches their first Javascript/WebAssembly-only processor.
[1] https://www.destroyallsoftware.com/talks/the-birth-and-death...
I like JS the most.
It's flexible, lightweight, and omnipresent.
The only other mainstream language that gives me that feeling is Rust.
I'm using TypeScript and JavaScript interchangably.
I know JavaScript like the back of my hand, so TypeScript isn't telling me much new, but the static type checking allows me to keep less of that knowledge in working memory. Pretty awesome language!
I'm not even sure it would be a terrible idea, as we'd have a very interesting JS/WASM-like set of opcodes that we could target with _any_ compiler.
JS accidentally got part of the x86 execution model for float conversion baked into the spec. ARM added an instruction to mimic the old x86 one. It's potentially useful in some other contexts too.
Fedora and Fennec F-Droid have since disabled this feature.
https://src.fedoraproject.org/rpms/firefox/c/4cb1381d80a94c9...
https://gitlab.com/relan/fennecbuild/-/commit/12cdb51bb045c3...
But in FreeBSD we build all the pieces directly, here's our build recipes (with some hacks due to llvm's cmake code being stupid sometimes):
compiler-rt (from llvm): https://github.com/freebsd/freebsd-ports/blob/main/devel/was...
libc (from what you linked): https://github.com/freebsd/freebsd-ports/blob/main/devel/was...
libc++ (from llvm): https://github.com/freebsd/freebsd-ports/blob/main/devel/was...
Hopefully Fedora manage to implement this to their satisfaction in the near future, although requiring extremely recent releases of build tools might be a blocker for some distros.
rlbox::rlbox_sandbox<rlbox_noop_sandbox> sandbox;
sandbox.create_sandbox();
sandbox.invoke_sandbox_function(hello);
https://github.com/PLSysSec/rlbox_sandboxing_api/blob/master...Seems like it could get a bit verbose when used all over the place but I guess there’s always a cost with security and having clearly defined risky parts also helps. Regardless I’m happy to see the effort being made beyond process isolation and OS capabilities.
Still, that's a very exciting development that could lead to a revolution in operating systems.
https://kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html
So implementing this in the compiler would entail some fairly involved handshaking between the code and the compiler beyond the normal scope of C/C++. Doing this in a library instead — and leaning on a well-understood and well-studied execution model — makes everything a bit more natural to work with.
It's wasm2c job to ensure that the C generated enforces the WASM memory rules, so I'd say the one sanitizing the code is not clang but wasm2c.
It's a stack machine with a limited set of operations, no direct control over the stack/control flow and restricted access to memory.
It's way easier to compile this limited set of operations to assembly (or C) that is guaranteed to not do things it shouldn't.
Because they want to compile arbitrary code in order to sandbox it.
The alternative is something like eBPF, but that imposes a limited subset of the source language, which would be unlikely to work with something like a video decoder.
[1]: https://kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html...
Take signed int overflow on addition. On some platforms ADD wraps, on others it traps. Depending on target CPU you'll get very different behavior even for non-optimizing compilers that just emit an ADD instruction!
WASM is just another target, with its own behavior.
Unless the produced C code is validated against optimizations across every single compiler version, there are no guarantees of that actually being the case.
Isn't the point that you don't care?
Each untrusted library is compiled to wasm then C then native, they can corrupt their own datastructures but the point is to prevent that corruption from escaping those boundaries, or at least that's how I understand it.
(But in general corruption inside the sandbox is potentially dangerous too. You need to be careful about what you do with data you get from the sandboxed code. RLBox does help in that area as well.)
This like attacking microservices, you have a module that exposes a set of interfaces and produces outputs when called with specific APIs.
For the sake of example lets say you have an authentication module that says if a given user id is root.
Now imagine producing a sequence of API calls that it will trigger the side effect of is_root(id) being true for an id that it is a plain user.
No sandbox escape took place, only internal corruption of internal data keeping structures that lead the is_root() to misbehave.
Corruption is not an issue when you assume the corruptee to already be an attacker
> Comparing the exploitability of WebAssembly binaries with native binaries, e.g., on x86, shows that WebAssembly re-enables several formerly defeated attacks because it lacks modern mitigations. One example are stack-based buffer overflows, which are effective again because WebAssembly binaries do not deploy stack canaries. Moreover, we find attacks not possible in this form in native binaries, such as overwriting string literals in supposedly constant memory.
That data corruption may be exploitable (e.g. if it lets you overwrite a password) but those kinds of attacks are much much rarer than the kind that wasm prevents.
> the programmer only needs to sanitize any values that come from the sandbox (since they could be maliciously-crafted), a task which RLBox makes easy with a tainting layer.
I don't think I've ever heard any WebAssembly advocates saying that it magically solves all security issues.
https://kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html
tl;dr Something like ~14% when using the best bounds checking strategy, or ~42% when using the most portable one. (There are options in the middle as well.)
I thought this was the same technique used in webassembly.
Chromium is using this too i think
This sandboxing technique ensures that both control flow and memory accesses remain in the sandbox (except for when you explicitly allow otherwise).
It may need to be asynchronous because of Firefox's peculiar infrastructure for process isolation, but if the intraprocess calls can be synchronous, the interprocess function calls could in principle also be synchronous. By default that's literally how most syscalls work, at least in Unix-like systems. And it's the direction microkernels seemed to have turned--synchronous calls work just fine, and you don't need a bunch of specialized asynchronous calling infrastructure inside the kernel itself.
This seems like a classic case of using a novel solution to subtly redefine the problem. If you just redefined the problem at the outset you wouldn't need so much contrivance.
The benefits of wasm over LLVM IR is that wasm has already done the work to define the sandboxed format and build the tooling to compile to it. Wasm is also almost as fast as running normally. (Wasm is also portable and lacks undefined behavior, although for this use case those might matter less.)
See the MinSFI section here which compares it directly to wasm for sandboxing:
https://kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html
And the original MinSFI presentation is here:
https://docs.google.com/presentation/d/1RD3bxsBfTZOIfrlq7HzG...
What's great, though, is that they are achieving this with tools that are already available.
I wonder what the other "good candidates" that he referred to are.
Wasm doesn't support all of SSE, but wasm does have SIMD support which is a portable subset of common SIMD instructions. You may lose some performance there, but wasm is adding more instructions to help (see "relaxed-simd"). There are also headers to help translate between SSE and wasm SIMD for existing code where possible.
wasm2c does have support for wasm SIMD, although I believe it is not 100% complete yet.
How are segmentation faults handled?
With wasm2c the trapping can be implemented in a variety of ways, for example using the signal handler trick like wasm VMs do (~14% overhead) or manual bounds checks (~42% overhead, but fully portable).
Do you plan to use the signal handler trick eventually? Less portable but in my tests it shrinks the total overhead by half (from masking's 29% to 14%).
I’m sure that this approach is valid as a hardening measure but some of the enthusiasm in the post is perhaps worthy of temperance. This thunk through WASM can’t protect against runtime heap overflows and such.
> However, the transformation places two key restrictions on the target code: it can’t jump to unexpected parts of the rest of the program, and it can’t access memory outside of a specified region
Oof. The paper I recall specifically called these out as not enforceable. The libpng example in the paper directly had an external request have libpng corrupt and access other WASM memory than it owned (in this model it would be other in-process native memory I think or at least the other code placed within the same heap region unless each component gets its own which then means you need to have a fixed memory allocated upfront…).
In any case, I think you've misunderstood the security properties. WASM can have weaker security within the sandbox because it doesn't have access to some of the more sophisticated mitigation measures that native code does, but the security of the sandbox boundary itself is very solid.
The part of the article that you quote is accurate in the sense that I believe it was meant - the code cannot jump to unexpected parts of the rest of the program (outside the sandbox) and cannot access memory outside of a specified region (the sandboxed memory). A vulnerability might allow the target code to jump to somewhat unexpected parts inside the sandbox, or buffer overflows inside the sandbox, but not outside.
As such, it's actually a really effective application of a WASM sandbox!
And that's mostly read-only data pages. The primary blocker there is how to integrate that capability with ArrayBuffer in the web platform, since Wasm memories can be exposed (or aliased) as ArrayBuffer objects, and most engines aren't prepared to encourage non-writable holes in ArrayBuffers.
> A vulnerability might allow the target code to jump to somewhat unexpected parts inside the sandbox, or buffer overflows inside the sandbox, but not outside. As such, it's actually a really effective application of a WASM sandbox!
Again, don't get me wrong. This is a very interesting idea to explore. The paper does point out ways that the host JS environment (which in this case can be considered the FF native process) can be exploited. Not as easily as a heap overflow, but not necessarily trivially either. I think both that paper & this post lay out an interesting idea on how to better lock down native extensions.
> This, in turn, makes it easy to apply without major refactoring: the programmer only needs to sanitize any values that come from the sandbox (since they could be maliciously-crafted), a task which RLBox makes easy with a tainting layer.
You mean like all those great programmers that keep introducing bugs like the one recently found out by Project Zero?
If I understand the post correctly, it's still native compiled code in the end, and it won't run through WASM. The goal of the approach sounds more like a code sanitizer tool to ensure the external library they're using isn't making calls outside of it, or requesting memory beyond the region its given.
We have gaping security holes with JavaScript already.
Stop the madness.