Host Functions Break the Sandbox: How to Design Isolation Boundaries for WASM Plugins
When I first designed a WASM sandbox, I reassured myself that "with linear memory isolation, guest code can't touch anything outside its own region anyway." But the deeper I dug into the design, the more an uncomfortable truth emerged. The actual security boundary of a sandbox is determined not by the WASM runtime, but by the set of functions the host explicitly exposes. In fact, the majority of reported WASM isolation bypass cases have originated not from the linear memory isolation semantics themselves, but from host-side implementations — improper handling of externref or resource handles, flaws in WASI path translation logic, or capability delegation mistakes. The runtime's memory model was solid, but the host function surface layered on top of it was too wide or contained errors.
If you're running third-party plugins, providing per-tenant isolation environments, or need to safely execute in-process code generated by AI agents, these design decisions will hit close to home. How to narrow the host function exposure scope, how to structure memory boundaries, and how to defend against resource exhaustion in multi-tenant environments — these three axes form the backbone of WASM sandbox design.
How WASM Isolation Actually Works
Three Security Principles
A WASM plugin sandbox constructs isolation through three layers.
Linear Memory Boundary — A WASM module can only access a linear memory space where the runtime enforces bounds-checks. This is why a guest's pointer bug cannot corrupt host memory. Guest code written in C/C++ can cause use-after-free within the linear memory, but the damage is confined to that module's memory space. The ptr/len interpretation issues that arise when passing strings or records via the Canonical ABI mostly stem from how the host code handles them, not from the boundary itself. Using bindings generated by wit-bindgen is far safer than parsing by hand.
Host Function Exposure — A WASM module has no access to any system resource by default. It can only call functions explicitly registered by the host as import. This is exactly what "the attack surface can be constrained by design" means.
Capability-Based Security — WASI is designed so that modules do not have ambient authority. All resource access — filesystem, network, system clock, and more — is mediated through capability handles explicitly granted by the host.
Looking at this diagram, you might think "linear memory isolation keeps things safe" — but if the HF (Host Function Registry) is too wide, a guest can use that path to turn the entire host upside down.
How the Component Model Structures Boundaries
The difference between the traditional single WASM module approach and the Component Model lies in the unit of isolation. In the Component Model, each component has its own independent linear memory and minimal privilege set. Even if an image-processing component is compromised, it cannot access the memory or capabilities of a database component.
WIT (WebAssembly Interface Types) is the IDL that defines these boundaries in a type-safe way. Below is an example of defining, in WIT, the interface a host would expose in a multi-tenant plugin system.
// plugin-host.wit
package myhost:plugin@0.1.0;
interface host-logging {
log: func(level: u8, message: string);
}
interface host-kv {
get: func(key: string) -> option<string>;
set: func(key: string, value: string) -> result<_, string>;
delete: func(key: string) -> result<_, string>;
}
world plugin-sandbox {
// Only functions the host provides to the guest are declared as imports
import host-logging;
import host-kv;
// Interface the guest must implement
export handle-request: func(payload: list<u8>) -> list<u8>;
}Declaring a world in WIT like this both documents the host functions a guest can access and enforces them at compile time. Language-specific bindings for Rust, Go, C, and others can be auto-generated with wit-bindgen, eliminating the need to manually interpret the Canonical ABI.
Designing a Narrow Host Function Exposure Scope
Registering Host Functions with Least Privilege
Taking Wasmtime as an example, host functions must be explicitly added to the Linker when registering them. The following is a conceptual example based on the Core WASM Linker. If using the Component Model, you should use the wasmtime::component::Linker and bindgen! macro combination instead — be careful not to mix the two APIs.
use wasmtime::*;
use wasmtime_wasi::WasiCtxBuilder;
fn create_sandboxed_store(engine: &Engine) -> Result<Store<MyHostState>> {
// WASI context: filesystem and network are closed off by default
let wasi_ctx = WasiCtxBuilder::new()
.inherit_stdio() // only stdout/stderr allowed
// .inherit_network() <- intentionally disabled
// .preopened_dir(...) <- open only specific paths when needed
.build();
let state = MyHostState {
wasi: wasi_ctx,
limiter: MemoryLimiter { max_pages: 1024 }, // defined below
tenant_id: "tenant-42".into(),
};
let mut store = Store::new(engine, state);
// fuel: instruction budget the guest can execute. Traps when exhausted.
// Covered in detail in the CPU exhaustion defense section below.
store.set_fuel(10_000_000)?;
store.fuel_async_yield_interval(Some(10_000))?;
// Memory limits can only be enforced via ResourceLimiter.
store.limiter(|state| &mut state.limiter);
Ok(store)
}
fn build_linker(engine: &Engine) -> Result<Linker<MyHostState>> {
let mut linker: Linker<MyHostState> = Linker::new(engine);
// Add WASI base interfaces (only what is needed)
wasmtime_wasi::add_to_linker_async(&mut linker, |s| &mut s.wasi)?;
// Custom host function: the module name in a Core WASM Linker is a plain string.
// Match what the guest imports as (module='host_kv', name='get').
linker.func_wrap_async(
"host_kv",
"get",
|mut caller: Caller<'_, MyHostState>, (ptr, len): (i32, i32)| {
Box::new(async move {
let key = read_string_from_memory(&mut caller, ptr, len)?;
// Enforce tenant ID-based key namespacing here
let namespaced_key = format!("tenant:{}:{}", caller.data().tenant_id, key);
let _value = caller.data().kv_store.get(&namespaced_key).await;
// Logic to write the result back into guest memory...
Ok(())
})
},
)?;
Ok(linker)
}The key point is that without explicitly calling inherit_network(), there is no network access. WASI operates as deny-all by default. And the part where keys are namespaced by tenant ID inside the KV access function — enforcing this at the host function layer blocks the path through which a guest could access another tenant's data entirely.
The reason memory limits aren't set via Config::memory_reservation is that this option is per-instance virtual memory reservation (for performance tuning), not a switch that enforces a hard maximum on the memory a guest can use. The actual limit must be applied by implementing ResourceLimiter and attaching it to the store.
use wasmtime::{ResourceLimiter, StoreLimits};
pub struct MemoryLimiter { pub max_pages: usize }
impl ResourceLimiter for MemoryLimiter {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
Ok(desired <= self.max_pages * 64 * 1024) // page = 64KiB
}
fn table_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
Ok(desired <= 10_000)
}
}WASI Filesystem Preopen: Restricting Paths to Explicit Subtrees
For plugins that require filesystem access, instead of opening the entire path, the pattern is to expose only a specific subtree via preopen.
let wasi_ctx = WasiCtxBuilder::new()
.preopened_dir(
"/data/tenants/tenant-42/workspace", // actual host path
"/workspace", // virtual path visible to the guest
DirPerms::READ | DirPerms::WRITE,
FilePerms::READ | FilePerms::WRITE,
)?
.build();From the guest's perspective, only /workspace exists. /etc, /var, and other tenants' directories simply do not exist in the guest's world.
Finer-Grained Isolation Units with Multi-Memory
Multi-Memory is one of the W3C WebAssembly phase 4 proposals, allowing multiple independent linear memories within a single module. For example, separating the memory that handles sensitive data from the memory used for general computation means that even if a buffer overflow occurs, the blast radius is confined to that memory space.
As of 2026, Multi-Memory is still at proposal stage with varying support across major browsers and some runtimes, so it is more realistic to first secure inter-component isolation at the Component Model level rather than adopting it directly in production plugin systems.
Raising the Abstraction Layer with Extism
If assembling a Linker directly on top of Wasmtime feels cumbersome, Extism is a good option. Extism layers an ABI for host-plugin data exchange on top of multiple WASM runtimes and provides host SDKs for a variety of languages.
// Running an Extism plugin from a Go host (conceptual example)
ctx := context.Background()
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{Path: "plugin.wasm"},
},
}
// Explicitly register the host functions to allow.
// Specify a namespace to match the guest import path.
kvGet := extism.NewHostFunctionWithStack(
"kv_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Enforce tenant isolation logic here
},
[]extism.ValType{extism.ValTypeI64},
[]extism.ValType{extism.ValTypeI64},
)
kvGet.SetNamespace("host_kv")
hostFunctions := []extism.HostFunction{kvGet}
plugin, err := extism.NewPlugin(ctx, manifest, extism.PluginConfig{
EnableWasi: true,
}, hostFunctions)
if err != nil {
return fmt.Errorf("plugin init: %w", err)
}
defer plugin.Close(ctx)Extism's advantage is that it maintains sandbox isolation without being tied to a specific WASM runtime. However, using a ready-made ABI comes with the tradeoff of reduced fine-grained control over the Canonical ABI.
Resource Limits That Must Be Set in Multi-Tenant Environments
Defending Against CPU Exhaustion: The Fuel Mechanism
Because WASM is in-process isolation, it is difficult to slice CPU using cgroups the way containers do. On this topic, arXiv:2509.11242 points out that current WASM/WASI/WASIX runtimes still have weaknesses in isolating shared OS resources such as CPU cycles, disk I/O, bandwidth, and entropy pools (specific degradation rates vary by workload and attack scenario, so it is more accurate to consult the experiment tables in the paper directly).
Wasmtime's fuel is a mechanism that sets an instruction budget for what the guest can execute. Each instruction executed decrements the fuel, and when it runs out, a trap is raised and execution halts. It is the key tool for defending against CPU exhaustion in the form of infinite loops or computational bombs.
// Set fuel: limit to 10 million instructions
store.set_fuel(10_000_000)?;
// In async environments, configure periodic yields when fuel is exhausted.
// A lower value improves scheduling fairness; a higher value reduces overhead.
store.fuel_async_yield_interval(Some(10_000))?;The memory limit is enforced via the ResourceLimiter defined earlier. Applying both fuel and the memory limiter together covers exhaustion scenarios on both the CPU and memory sides.
Decision Flow for Host Function Design
Before adding a new host function, it helps to review it using a flow like this.
Tradeoffs: What You Gain and What You Lose
| Item | WASM Sandbox | Container Isolation | MicroVM |
|---|---|---|---|
| Memory footprint | Low (in-process runtime) | Tens to hundreds of MB | Hundreds of MB |
| Cold start | Very low (module instantiation level) | Hundreds of ms | Hundreds of ms |
| Language neutrality | High (Rust, C/C++, Go, TinyGo, AssemblyScript, etc.) | High | High |
| Microarchitecture attack defense | Weak (Spectre-class attacks are not globally solved by WASM semantics) | Weak | Strong (hardware isolation) |
| Resource exhaustion defense | Requires runtime configuration (fuel, ResourceLimiter) | Limited via cgroup | Strong |
| Host function auditability | High (explicit registration) | Low | Low |
If you need quantitative benchmarks, it is safer to consult each runtime's official benchmark repository or reproducible results from papers directly. Numbers in third-party summary blog posts often lack clarity on version and workload.
Common Mistakes Encountered in Practice
1. Opening host functions wide "for convenience"
Registering a function like exec_command on the grounds of "it's only used internally anyway" effectively nullifies the sandbox at that moment. Every function included in a WIT world must be recognized as a potential attack surface.
2. Multi-tenant deployment without fuel
What works fine when testing in a single-tenant environment can become a situation in a multi-tenant environment where a malicious or buggy plugin monopolizes CPU with an infinite loop. Fuel and ResourceLimiter are not optional — they are mandatory.
3. Mistakenly believing Config::memory_reservation enforces a hard limit
This option is a reservation amount for performance tuning, not a switch that enforces an upper bound. The limit must be adjudicated in ResourceLimiter::memory_growing.
4. Not keeping the JIT runtime up to date
JIT compiler bugs are a primary path for sandbox escapes. In environments where security is paramount, using interpreter mode or a verified cache of AOT-compiled output instead of JIT is also an option (with a performance tradeoff).
5. Using WASM isolation alone for adversarial multi-tenancy
Spectre and data-only side-channel attacks are not globally resolved by WASM sandbox semantics. For adversarial environments where anonymous users can upload arbitrary code, it is worth considering a double-isolation architecture where the WASM runtime runs on top of a MicroVM (e.g., Firecracker). It is difficult to call this a "standard," but it is an approach that real code execution platforms have adopted.
Runtime Selection: A Brief Comparison as of 2026
| Runtime | Component Model Support | Specialization |
|---|---|---|
| Wasmtime | Reference implementation | Plugin systems, security-focused |
| Wasmer | Supported | Cross-platform embedding |
| WasmEdge | Supported | AI/ML, edge computing |
The WASI official site has a summary of the status for each release, so it is recommended to check the support matrix directly when starting a project. The biggest change in WASI 0.3 is the addition of native async support (async func, stream<T>, future<T>) to the Component Model, allowing plugins to express async I/O for databases, HTTP, and so on using language-native primitives.
// Example of an async function declaration in WASI 0.3
interface data-fetch {
fetch-records: async func(query: string) -> result<list<string>, string>;
}Closing: The WIT World Is a Security Contract, Not an API Contract
The judgment to hold onto in WASM plugin sandboxing comes down to one thing: what the runtime manages is the boundary of memory pages; what we must manage is the boundary of authority. These two things live on different layers.
It therefore makes natural sense to treat the WIT world as a security contract rather than an API contract. The decision to add a function is not a decision to open a new endpoint — it is a decision to open a new trust path. That implies three things: the world should be managed with semantic versioning like an API; each function should be accompanied by documentation specifying the scope of authority it grants, the tenant isolation enforcement point, and resource limits; and removal should be treated as a breaking change, not a reduction. Handling it this way naturally cuts off the pattern where a function added because "it seemed like it might be needed" becomes an attack surface months later.
Linear memory isolation, fuel, ResourceLimiter, preopen, Component Model — all of these tools are effective, but ultimately the strength of the sandbox converges on which functions are in the WIT world. Before adding a new host function, first check whether you can name which clause of the contract that function represents.
References
- WebAssembly Component Model in Go Backends: Sandboxed Plugin Execution, Host ABI Design, and the Isolation Tradeoff
- WASM on the Backend in 2025: Sandboxing, Performance, and Deployment Trade-offs
- WASI and the WebAssembly Component Model: Current Status
- WASI Official Site
- WASI Roadmap
- The Wasm Breach: Escaping Backend WebAssembly Sandboxes
- Exploring and Exploiting the Resource Isolation Attack Surface of WebAssembly Containers (arXiv:2509.11242)
- WebAssembly Security Official Documentation
- Multi-Memory Proposal (W3C WebAssembly)
- Building Native Plugin Systems with WebAssembly Components
- Extism Official Documentation - FAQ
- Wasmtime Official Documentation - ResourceLimiter
- In-Process WebAssembly Sandboxes for Agent-Generated Code
- WaSC: Hardening WebAssembly Sandboxes via System Interface Decoupling (ACM)