Every few months someone tells me WebAssembly is “sandboxed and memory-safe,” usually as a reason they don’t need to worry about the C library they just compiled to it. Half of that sentence is true. The other half gets people owned, and the gap between the two halves is where I want to spend this post.
I’m going to make the argument with code you can run, not adjectives. Everything below is compiled with clang’s wasm32 target and run in Node, and I’ll give you the exact commands so you can reproduce it in a couple of minutes.
What the sandbox actually guarantees
The thing WebAssembly genuinely does well is isolation from the host. A module gets a single contiguous block of memory — “linear memory” — and that’s its entire world. It can’t name an address outside that block. It has no instruction that issues a raw syscall. Indirect calls don’t jump to an arbitrary address; they go through a table and are type-checked at the call site. Code and data live in separate address spaces, so a module can’t write to its own instructions the way a native stack-smash flips execution to a payload.
When a module tries to step outside linear memory, it doesn’t get undefined behavior. It traps. Here’s that boundary, enforced:
// demo.c — freestanding, no libc
typedef unsigned long size_t;
static void *my_memcpy(void *d, const void *s, size_t n) {
char *a = d; const char *b = s;
for (size_t i = 0; i < n; i++) a[i] = b[i];
return d;
}
struct { char name[16]; char secret[16]; } vault = { "", "TOPSECRETTOKEN!" };
__attribute__((export_name("copy_name")))
void copy_name(const char *src, int len) { my_memcpy(vault.name, src, len); }
__attribute__((export_name("name_ptr"))) char *name_ptr(void){ return vault.name; }
__attribute__((export_name("secret_ptr"))) char *secret_ptr(void){ return vault.secret; }
Build it:
clang --target=wasm32 -nostdlib -O2 \
-Wl,--no-entry -Wl,--export-all -Wl,--allow-undefined \
-o demo.wasm demo.c
Then drive it from Node, and ask it to write 70,000 bytes starting at vault.name:
copy_name(scratch, 70000); // runs off the end of linear memory
// -> RuntimeError: memory access out of bounds
That trap is the whole value proposition. In a native process the same write marches through the heap, past guard pages if you’re lucky, into a crash or a corrupted allocator if you’re not. In Wasm it stops dead at the edge of linear memory, every time, deterministically. The host’s memory is never in reach. That part of the pitch is real, and it’s genuinely good.
What it does not give you
Here’s the half people skip. The bounds check only fires at the edge of linear memory. Everything inside that block is one flat array with no internal protections — no guard pages between objects, no ASLR, no stack canaries, nothing. So a write that stays inside linear memory but overruns a buffer isn’t a trap. It’s just a write to a legal address that happens to belong to something else.
Same module. This time I overflow the 16-byte name buffer by 16 bytes, which runs straight into secret:
const payload = new TextEncoder().encode('AAAAAAAAAAAAAAAAPWNED-VIA-OVERFLOW');
mem.set(payload, scratch);
copy_name(scratch, payload.length); // len = 34 into a 16-byte buffer
Output:
secret @ 65552, name @ 65536, gap = 16 bytes
secret before: "TOPSECRETTOKEN!"
secret after: "PWNED-VIA-OVERFL"
OOB write -> RuntimeError: memory access out of bounds
memory is 131072 bytes
The secret got overwritten. No trap, no warning — the bounds check had nothing to say, because secret is a perfectly valid offset in linear memory. This is a textbook buffer overflow, alive and well, inside a sandbox that marketing told you was memory-safe.
And look at the addresses: secret sits at offset 65552 on every single run. There’s no ASLR, so the layout is deterministic. An attacker doesn’t have to defeat randomization or guess an offset; they can compute it once and rely on it forever.
This is the uncomfortable finding that the academic world has been making for a while now — Lehmann, Kinder, and Pradel’s Everything Old is New Again: Binary Security of WebAssembly (USENIX Security 2020) is the canonical write-up. The exploitation primitives we spent twenty years building mitigations against (overflows corrupting adjacent data, overwriting function pointers, smashing the stack, clobbering heap metadata) all still work when you compile C or C++ to Wasm, and several of them work more reliably than native because the mitigations simply aren’t there. The stack, by the way, lives in this same linear memory for Clang-compiled code, so stack buffers are overwritable neighbors too.
The sandbox contains the blast radius. It does nothing about the explosion inside.
So where does the real risk live?
Three places, in roughly the order they bite people.
The import surface. A module on its own can’t do anything interesting — it can’t open a file, make a network call, or read a clock. Everything it can do, you handed it, as an imported host function or through WASI. That import list is the entire attack surface between the module and the outside world, and it’s a capability list whether you think of it that way or not. If you import a host function that takes a pointer and a length into linear memory and trusts them, module-side corruption becomes host-side impact. If you hand a module broad WASI filesystem access “to keep things simple,” you’ve given untrusted code a filesystem. I wrote a whole post on sandboxing untrusted Python and the same rule is the punchline here: expose the narrowest specific capabilities, validate everything crossing the boundary, and assume the code on the other side is hostile. Wasm gives you a beautiful boundary and then lets you poke as many holes in it as you like.
The runtime itself. Your module runs inside Wasmtime, Wasmer, V8, wasm3, something. That runtime is a program parsing and JIT-compiling untrusted bytecode, and runtimes have shipped real CVEs — sandbox escapes, miscompilations, out-of-bounds in the host. The guarantees in this post are only as true as the engine’s implementation of them. Keep it patched like you’d patch any other code that eats attacker input, because that’s exactly what it is.
The supply chain. A .wasm file is a compiled blob. You usually can’t read it, you rarely built it, and “it’s sandboxed” is doing a lot of quiet work in the decision to run it. The sandbox limits what a malicious module can reach, which is genuinely helpful — but combine a hostile module with a too-generous import surface and you’re back to the first bullet. Treat a third-party Wasm dependency with the same suspicion you’d give a third-party native binary, not the free pass you’d give a text config.
I’ll also mention side channels briefly: deterministic layout and shared caches make Wasm a comfortable place for Spectre-style timing attacks, which is part of why browsers clamp timer resolution for it. Worth knowing, rarely the thing that gets you first.
What I actually do with this
I’m not down on WebAssembly. The isolation is real and I reach for it specifically because the host boundary is strong. But I treat it as exactly one property — host isolation — and not a bundle of safety I didn’t earn.
Concretely:
- Keep the import surface tiny and typed. Every host function is a capability. Don’t import convenience you don’t need, and validate every pointer/length and every argument coming from the module as if it were a hostile network packet, because it is.
- Set hard limits. Cap linear memory, and use the runtime’s execution limiter — Wasmtime’s fuel or epoch interruption — so a module can’t spin forever or balloon memory. A sandbox with no resource ceiling is a denial-of-service waiting to happen.
- Don’t assume the source language’s safety survived, and don’t assume it was there. Rust-to-Wasm keeps Rust’s guarantees; C-to-Wasm keeps C’s bugs and drops the native mitigations. If you must ship C/C++ to Wasm, compile with the hardening you’d use natively and fuzz it, because the sandbox won’t catch the overflow for you.
- Patch the runtime on the same cadence as anything else processing untrusted input.
- Audit third-party modules like binaries, not like data.
The one-sentence version, which is the version I wish people led with: WebAssembly protects the host from the module; it does not protect the module from itself. Design for both and it’s a great tool. Assume the second half for free and you’ve just moved your memory-corruption bugs into a place with fewer mitigations and a reassuring name.
The demo is a single C file and a dozen lines of Node — clang --target=wasm32 ships with LLVM, so there’s no toolchain to install beyond that and the lld linker. If you’ve never watched a “memory-safe sandbox” cheerfully overwrite a secret, spend the five minutes. It changes how the word “sandboxed” reads on a datasheet.
See also:
- Six layers to sandbox untrusted Python — and the escape I missed — the same import-surface lesson, in a different runtime
- Use-After-Free: a classic memory corruption bug — the native version of the bugs that follow you into Wasm
- Security Playground — interactive demos