Sandboxing Untrusted Code From Rust With WebAssembly
Run untrusted plugins or user code from Rust inside WebAssembly with wasmtime: fuel for CPU limits, memory caps, no ambient file or network access, and host functions you choose.

Sooner or later a Rust program has to run code it did not write: a plugin a customer uploaded, a formula in a spreadsheet cell, a webhook transform, a user-supplied filter. Loading that code as a shared library gives it your whole process, including your memory, your file descriptors and your network. Spawning a container per request is safe but costs hundreds of milliseconds. WebAssembly sits in between: the guest is compiled to a small, portable binary that can only touch what the host explicitly hands it, and the host can cap how long it runs, how much memory it takes and how deep it recurses. This guide builds that boundary with the wasmtime crate. A guest written in Rust is compiled to wasm32-unknown-unknown, and a host program loads it and calls four hostile exports: an infinite loop, a memory grab, runaway recursion and a crash. Each is stopped by a different mechanism, and the output of every run is shown. Versions: rustc 1.99 stable, wasmtime 50, measured on an Apple M1 Max.
What the sandbox gives you, and what it does not
A WebAssembly module is a set of functions, a linear memory and a table, plus a list of imports it needs and exports it offers. The important property is what a module cannot express. It has no instruction to open a file, read the clock, make a socket or call into the host process. The only way out is an import, and the host decides what each import does, or whether it exists at all. A module that imports nothing can compute and nothing else.
That is a capability model, and it is the reason WebAssembly suits untrusted code. Memory is the second half of it. The module sees a single contiguous byte array that starts at zero. Every load and store is bounds-checked against that array, so a guest with a bug, or a guest written to attack you, can corrupt only its own memory. It cannot read the host heap, because the host heap is not addressable from inside.
The sandbox does not bound resources by default. A module is free to loop forever, to ask for as much memory as the engine will give it, and to recurse until the stack is gone. Those are denial-of-service attacks, and defending against them is the host's job. The rest of this guide is about exactly that, after first getting a module running.
The guest: a Rust library with no standard library
The guest is an ordinary Rust crate built as a cdylib for wasm32-unknown-unknown. It is no_std to keep the binary tiny and to make it obvious that nothing from the operating system is available. Five exports are the experiment, and each one is written to misbehave in one specific way:
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[link(wasm_import_module = "host")]
unsafe extern "C" {
fn log(value: i32);
}
/// Pure computation: fine in a sandbox.
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a.wrapping_add(b)
}
/// Calls back into the host, the only way out of the sandbox.
#[unsafe(no_mangle)]
pub extern "C" fn report(n: i32) -> i32 {
unsafe { log(n * 2) };
n
}
/// A hostile plugin: never returns.
#[unsafe(no_mangle)]
pub extern "C" fn spin() -> i32 {
let mut x: u32 = 1;
loop {
x = x.wrapping_mul(1664525).wrapping_add(1013904223);
if x == 7 {
return 0;
}
}
}
/// A hostile plugin: wants a lot of memory.
#[unsafe(no_mangle)]
pub extern "C" fn grow(pages: i32) -> i32 {
core::arch::wasm32::memory_grow(0, pages as usize) as i32
}
/// A hostile plugin: unbounded recursion.
#[unsafe(no_mangle)]
pub extern "C" fn recurse(n: u64) -> u64 {
let pad = [n as u8; 64];
if n == u64::MAX { return 0; }
recurse(n + 1).wrapping_add(pad[3] as u64)
}
/// A hostile plugin: divides by zero.
#[unsafe(no_mangle)]
pub extern "C" fn divide(a: i32, b: i32) -> i32 {
a / b
}The manifest sets the crate type and shrinks the output. panic = "abort" matters here: a no_std library must supply its own panic handler, and the one above compiles a panic down to the WebAssembly unreachable instruction, which traps.
[package]
name = "guest"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
panic = "abort"rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
ls -l target/wasm32-unknown-unknown/release/guest.wasmThe result is a 755-byte module. Two things in the source deserve a note. report calls log, which is declared in a block marked #[link(wasm_import_module = "host")]. That produces an import named host.log, and the module will not instantiate unless the host supplies it. And divide does not trap on a zero divisor in this build. Rust's integer division checks for zero and calls the panic handler, which here executes unreachable. The trap you see is therefore "unreachable code reached", not an arithmetic trap, and the output below confirms it.
The host: one Store per run, three limits
The host crate depends on wasmtime with default features off, keeping only the compiler and runtime. That avoids pulling in WASI, the component model and the cache, none of which this experiment needs.
[package]
name = "host"
version = "0.1.0"
edition = "2024"
[dependencies]
wasmtime = { version = "50", default-features = false, features = ["cranelift", "runtime", "std"] }Wasmtime separates three objects, and the separation is the design to understand. An Engine holds configuration and compiled code, and is shared across threads. A Module is a compiled program, cheap to clone. A Store owns all the mutable state of one running instance: memories, tables, globals, the fuel counter. Everything dangerous lives in the store, so the rule for untrusted code is simple: one store per untrusted run, thrown away afterwards. Nothing a guest leaves behind can leak into the next caller if the next caller gets a new store.
The function below is the whole sandbox. It wires one host import, builds a store with a memory cap, a fuel budget and an epoch deadline, instantiates the module, and calls an export by name:
use wasmtime::*;
struct State {
limits: StoreLimits,
logged: Vec<i32>,
}
fn run(engine: &Engine, module: &Module, name: &str, args: &[Val],
fuel: u64, max_memory: usize, deadline_ticks: u64) -> Result<Vec<Val>> {
let mut linker: Linker<State> = Linker::new(engine);
linker.func_wrap("host", "log", |mut caller: Caller<'_, State>, v: i32| {
caller.data_mut().logged.push(v);
})?;
let limits = StoreLimitsBuilder::new().memory_size(max_memory).build();
let mut store = Store::new(engine, State { limits, logged: Vec::new() });
store.limiter(|s| &mut s.limits);
store.set_fuel(fuel)?;
store.set_epoch_deadline(deadline_ticks);
let instance = linker.instantiate(&mut store, module)?;
let func = instance
.get_func(&mut store, name)
.ok_or_else(|| format_err!("no export named {name}"))?;
let mut results = vec![Val::I32(0); func.ty(&store).results().len()];
func.call(&mut store, args, &mut results)?;
let fuel_left = store.get_fuel()?;
println!(" fuel used: {}, host log: {:?}", fuel - fuel_left, store.data().logged);
Ok(results)
}Read it from the top. The linker is where capabilities are granted: the single host.log function appends to a vector in the store's state, and that is all the guest can reach. The StoreLimits value is consulted by the engine every time the guest tries to grow a memory or table. set_fuel hands the guest a budget of abstract work units. set_epoch_deadline arms the second timing mechanism, covered below. Wasmtime 50 has its own Error and Result types, so the code uses format_err! from the crate rather than anyhow.
main turns both timing features on in the Config and then calls each export in turn, printing either the result or the trap:
fn main() -> Result<()> {
let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
let engine = Engine::new(&config)?;
let module = Module::from_file(&engine, "../guest/target/wasm32-unknown-unknown/release/guest.wasm")?;
let cases: Vec<(&str, Vec<Val>)> = vec![
("add", vec![Val::I32(40), Val::I32(2)]),
("report", vec![Val::I32(21)]),
("divide", vec![Val::I32(1), Val::I32(0)]),
("grow", vec![Val::I32(1)]),
("grow", vec![Val::I32(1000)]),
("spin", vec![]),
("recurse", vec![Val::I64(0)]),
("does_not_exist", vec![]),
];
for (name, args) in &cases {
println!("{name}({args:?})");
match run(&engine, &module, name, args, 1_000_000, 32 * 65536, u64::MAX / 2) {
Ok(r) => println!(" ok: {r:?}"),
Err(e) => match e.downcast_ref::<Trap>() {
Some(trap) => println!(" trapped: {trap:?} ({trap})"),
None => println!(" error: {}", e.to_string().lines().next().unwrap_or("")),
},
}
}
Ok(())
}The error handling is the part to copy. A guest that fails does not produce a Rust panic or an abort in the host. It produces an Err, and downcast_ref::<Trap>() tells a guest fault (a trap, which is the guest's problem) from a host-side mistake such as a missing export (an ordinary error, which is yours). Here is what the first eight calls printed:
add([I32(40), I32(2)])
fuel used: 4, host log: []
ok: [I32(42)]
report([I32(21)])
fuel used: 6, host log: [42]
ok: [I32(21)]
divide([I32(1), I32(0)])
trapped: UnreachableCodeReached (wasm trap: wasm `unreachable` instruction executed)
grow([I32(1)])
fuel used: 4, host log: []
ok: [I32(16)]
grow([I32(1000)])
fuel used: 3, host log: []
ok: [I32(-1)]
spin([])
trapped: OutOfFuel (wasm trap: all fuel consumed by WebAssembly)
recurse([I64(0)])
trapped: OutOfFuel (wasm trap: all fuel consumed by WebAssembly)
does_not_exist([])
error: no export named does_not_existEach line teaches one thing, and the next sections take them in turn.
Fuel: a deterministic budget for computation
With consume_fuel(true) the compiler inserts a counter decrement into the generated code, roughly one unit per WebAssembly instruction, and traps with OutOfFuel when it reaches zero. The first two lines show the accounting: add cost 4 units, and report cost 6 because of the extra call and multiply. The numbers are small because the functions are tiny. A real plugin that parses a JSON document will use thousands to millions.
The spin export is an infinite loop whose exit condition is a value it never reaches, so in a normal host it would pin a CPU forever. Under a one-million-unit budget it traps. The same budget also stopped recurse, which is worth reading carefully: it was not the stack limit that ended the recursion in this run, it was the fuel running out first. A budget therefore bounds every kind of runaway computation at once, and bounds it deterministically: the same input and the same budget trap at the same instruction on every machine. That makes fuel good for billing, for tests and for reproducing a report.
Fuel has two limits to know about. It measures instructions, not wall time, so a guest that spends its budget inside a slow host import is not slowed down by it. And the counter costs something on every basic block, which is why it is opt-in. The numbers on that cost are near the end.
Memory: refuse, do not crash
A WebAssembly memory is measured in 64 KiB pages and grows with memory.grow, which returns the old size in pages, or -1 when the request is refused. The store's limiter decides. The cap in this run was 32 pages (2 MiB). The module starts with 16 pages, so grow(1) succeeded and returned the old size, 16. grow(1000) asked for about 64 MB, the limiter said no, and the guest got -1 back. Nothing trapped. This is the behaviour the specification requires, and it matters for what you write in the guest: the guest decides what a refused allocation means. Rust's allocator on wasm32 will turn it into a panic, which is then a trap, but a hand-written guest could degrade gracefully.
One failure mode is easy to miss. The cap has to be at least the module's declared initial memory. My first attempt used a cap of 4 pages and the module would not even instantiate, with the message "memory minimum size of 16 pages exceeds memory limits". That is the correct behaviour (a module that declares more than you allow should be rejected up front), but it means the cap is part of the contract you publish to plugin authors.
Also bound the things that are not linear memory. StoreLimitsBuilder has table_elements, instances, tables and memories, and a hostile module can otherwise declare a table of millions of entries or a thousand memories. The same builder, the same single limiter call.
Epochs: a wall-clock deadline
Fuel counts work; it does not count time. A guest can be cheap in instructions and still hold a thread, for example by calling a slow host function in a loop. The second mechanism is epoch interruption. The engine keeps a counter that the host increments from anywhere, typically a timer thread. Each store sets a deadline measured in epochs, and generated code checks the counter at loop back-edges and function entries. When the counter passes the deadline, the guest traps with Interrupt. The check is a load and a compare, much cheaper than fuel accounting, and it is what a production host uses to enforce a time limit.
let ticker = engine.clone();
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_millis(10));
ticker.increment_epoch();
});
let start = std::time::Instant::now();
// Fuel is effectively unlimited here; only the 5-tick deadline can stop the loop.
match run(&engine, &module, "spin", &[], u64::MAX / 2, 32 * 65536, 5) {
Ok(r) => println!(" ok: {r:?}"),
Err(e) => println!(" trapped: {:?} after {} ms", e.downcast_ref::<Trap>(), start.elapsed().as_millis()),
}spin with unlimited fuel but a 5-tick (about 50 ms) deadline
trapped: Some(Interrupt) after 65 msFive ticks of 10 ms is a 50 ms deadline, and the guest was stopped at 65 ms. The overshoot is the resolution of the ticker thread plus the time for the thread to get scheduled, not a failure of the mechanism; if you need a tighter bound, tick more often. Because the deadline is expressed in ticks, one ticker serves every store of the engine.
This experiment also contains a lesson I did not plan. My first version of this section ran recurse with an effectively unlimited fuel budget and no deadline to see the stack limit. That run never returned: the process sat at full CPU for more than seven minutes until I killed it. I did not dig into why the guest did not hit a stack trap (an optimising build may have turned the recursion into a loop, but I did not confirm that), and I will not claim a stack-overflow trap I did not observe. What it demonstrates is the point of the whole guide. A guest that is allowed to run without any budget can hold a core indefinitely, and the only reliable defence is to set a fuel limit, a deadline, or both, on every store.
What it costs
A sandbox you cannot afford to create per request will be shared across requests, and sharing is how isolation fails. So measure. This benchmark creates 10,000 fresh stores, instantiates the module into each from a pre-linked InstancePre, and calls add once; then it calls one long-lived instance a million times:
let pre = linker.instantiate_pre(&module)?;
let t = std::time::Instant::now();
for _ in 0..10_000 {
let mut s = new_store(); // limits, fuel, deadline set as before
let inst = pre.instantiate(&mut s)?;
let add = inst.get_typed_func::<(i32, i32), i32>(&mut s, "add")?;
add.call(&mut s, (1, 2))?;
}
let per_instance = t.elapsed().as_secs_f64() * 1e6 / 10_000.0;fresh sandbox per request: 7.3 us (store + instantiate + one call); reused instance: 18 ns per call (checksum 1000000)On an M1 Max in a release build, a brand-new, fully limited sandbox costs about 7.3 microseconds from nothing to a result, and a call into a warm instance costs 18 nanoseconds. Those numbers are for a tiny module with one small memory, so they are a lower bound: a module with a large initial memory or many data segments takes longer to instantiate, and compiling the module is a separate, one-time cost that this benchmark excludes because the compiled Module is reused. The practical reading is that a fresh store per request is affordable for any workload where the guest does more than a few microseconds of work. Compile once at upload time, keep the Module in a map, and instantiate per call.
Shaping the imports: the real attack surface
Everything above limits what a guest can consume. The other half is what it can do, and that is entirely the set of imports you link. The host.log function in this guide is harmless on its face, but each import is code that runs with host privileges on guest-controlled arguments, and it deserves the same scrutiny as a public API endpoint:
- Validate every pointer and length. A host function that takes
(ptr, len)reads from the guest's memory. UseMemory::datawith checked slicing and return an error on out-of-range values. Never trust a length the guest passes. - Bound what an import allocates. If
host.read_row(i)returns a buffer, cap its size. A guest that calls a cheap import in a loop and receives large results is allocating host memory, not guest memory, and the memory limiter does not see it. - Charge for expensive imports. Fuel does not know that an import took a second. Subtract fuel yourself with
Caller::set_fuelin proportion to the work, or enforce a per-run quota of calls. - Do not hand out ambient authority. If a plugin needs to fetch a URL, give it
fetch(url_id)against an allow-list you control, not a general socket.
WASI is the standard set of such imports (files, clocks, random, sockets). It is useful, but it is a large surface, and the safe way to use it is the capability model it was designed for: you pre-open exactly the directories and sockets the guest may touch, and it can name nothing else. A guest with no WASI context at all, like the one here, has no clock either, which also removes the most common timing side channel.
Where this boundary stops
This is a strong isolation layer, and it is not the only one you need. Be clear about its limits before you rely on it:
- The engine is the trusted base. The guarantees above hold if the runtime and compiler are correct. Wasmtime is memory-safe Rust, extensively fuzzed, and has had sandbox-escape advisories like any large compiler; keep it patched and subscribe to its security announcements.
- Side channels are not covered. Speculative-execution attacks against the host process are mitigated by the engine to a degree, but a determined guest on shared hardware is not provably unable to observe timing. For multi-tenant code from hostile parties, add process or VM isolation around the host.
- Fuel and epochs do not bound host work. They stop the guest's own instructions. A slow import, a huge returned allocation or a lock held by the host is outside them.
- It is not free of ceremony. Plugin authors must compile to WebAssembly, which is simple for Rust and awkward for languages that need a large runtime. If your plugins are trusted code from your own team, a dynamic library or a plain trait object is simpler and faster, and sandboxing buys you little.
- The cheapest bound is often policy. A fixed fuel and memory budget that every plugin must fit is easier to reason about than per-customer tuning.
A checklist for running untrusted code
Pulling the experiments together, a host that runs hostile modules should do all of the following, and the code in this guide does each one:
- Create one
Storeper run and drop it afterwards. Share theEngineand the compiledModule. - Set a fuel budget on every store. It is the only deterministic bound on computation.
- Set an epoch deadline on every store and run a ticker. It bounds wall time, including time the guest spends waiting on you.
- Install a
StoreLimitswith memory, table and instance caps, and publish the memory minimum to plugin authors. - Link only the imports the plugin needs, and treat each one as a public API: validate pointers, cap sizes, charge fuel.
- Separate traps from host errors with
downcast_ref::<Trap>(), report the trap kind to the caller, and never let a guest fault take down the host. - Keep wasmtime updated, and put the host itself under the OS-level limits you would use for any service that handles untrusted input.
The measured behaviour is worth repeating in one line each. An infinite loop is stopped by fuel (OutOfFuel) or by a deadline (Interrupt, 65 ms against a 50 ms target). A request for 64 MB is refused with -1 and the guest keeps running. A panic becomes the UnreachableCodeReached trap. A missing export is a normal error. A fresh, fully limited sandbox costs 7.3 microseconds. None of it required trusting the plugin, and none of it can take the host process down.


