WebAssembly GC Proposal Security Implications for Multi-Tenant Runtimes
The Problem
The WebAssembly GC proposal (v1 merged into the Wasm spec November 2023; shipped in Chrome 119, Firefox 120, and Wasmtime 16.0+) adds first-class garbage-collected types to WebAssembly: struct, array, and ref types with automatic lifetime management. The GC proposal enables Kotlin, Dart, Java, OCaml, and other managed-language programs to compile to Wasm without bundling their own garbage collector into the linear memory, producing smaller binaries and enabling direct interoperability between host GC objects and Wasm-allocated objects.
For browser contexts, the security implications of Wasm GC are comparable to existing JavaScript GC: one origin’s Wasm module cannot reference another origin’s GC objects due to the same-origin policy. But multi-tenant server-side Wasm runtimes — where a single process hosts Wasm modules from different users without OS-level process isolation — face isolation challenges that did not exist in the linear-memory era:
GC root visibility. In a process-wide GC (as used by Wasmtime’s optional GC integration and several embedding frameworks), the GC root set may span all live tenants in the process. A GC scan performed during collection could, in a pathological implementation, access object headers or metadata from another tenant’s live objects. Well-implemented runtimes (Wasmtime, V8) prevent this through per-instance GC roots, but the guarantee must be verified rather than assumed.
Finalizer-based side-channels. Wasm GC objects can have associated finalizer-equivalent behaviour through host bindings. If a Wasm module can time the execution of finalizers (by observing when a function called from a finalizer-adjacent path returns), it can infer when objects in a co-located tenant’s module are collected, revealing information about memory pressure and object allocation patterns in the neighbouring tenant.
Reference type aliasing across module boundaries. The externref and anyref types allow Wasm modules to hold references to host-provided values. If a host runtime improperly implements reference scoping, a module in one tenant could receive an externref that was created in another tenant’s execution context.
GC heap fragmentation and covert channels. In a shared GC heap (not recommended but possible), allocation patterns by one tenant can influence GC pause timing and fragmentation for other tenants, creating a covert timing channel for cross-tenant communication.
Missing isolation in JIT-compiled GC code. Wasm GC adds new instruction forms (struct.new, array.get, ref.cast) that JIT compilers must handle correctly. Early JIT implementations of GC instructions had more bugs than the mature linear-memory codegen paths, and a type confusion in ref.cast could allow a module to obtain a wrongly-typed reference — a type safety violation that linear memory made less consequential.
Target systems: Wasmtime 16.0+ with GC enabled (--wasm-gc); V8 (Node.js 22+, Deno 1.46+); Fastly Compute and Cloudflare Workers (both implemented Wasm GC support in 2024-2025); custom embedding frameworks using the Wasm C API with GC enabled.
Threat Model
1. Co-located tenant using timing side-channel (authenticated platform user). Objective: measure GC pause duration and finalizer scheduling timing to infer memory allocation patterns of a neighbouring tenant; deduce whether the neighbour is handling a high-volume request, which secrets it may be processing, or confirm/deny specific operations. Impact: covert channel for cross-tenant information inference.
2. Reference aliasing exploit (attacker exploiting a runtime bug). Objective: obtain a reference to a host object (database connection, session token) created in another tenant’s execution context via an externref aliasing bug; use that reference to impersonate the tenant. Impact: full session takeover for the affected tenant.
3. JIT type confusion via ref.cast (attacker crafting malicious Wasm binary). Objective: cause the JIT to incorrectly cast a ref to a struct type it does not actually have, producing a type confusion; use the confused reference to read or write out-of-bounds memory relative to the struct. Impact: memory safety violation within the Wasm module’s heap; potential read of adjacent structs from other contexts.
4. Exhaustion via GC root flooding (denial-of-service). Objective: allocate large numbers of GC objects in a tight loop to force continuous minor GC, degrading throughput for all co-located tenants. Impact: latency spike for co-located workloads; platform availability impact.
Without per-instance GC isolation, adversaries 1 and 4 are viable against any runtime that shares GC infrastructure between tenants.
Hardening Configuration
Verifying Per-Instance GC Isolation in Wasmtime
Wasmtime’s GC implementation (available from v16.0) uses per-store GC heaps when gc_heap_capacity is configured at the Store level, ensuring that one tenant’s GC heap does not share memory with another:
use wasmtime::{Config, Engine, Store, GcHeapCapacityReservation};
fn create_isolated_store(engine: &Engine) -> Store<()> {
// Each tenant gets their own Store, which has its own GC heap
let mut store = Store::new(engine, ());
// Set a per-store GC heap capacity limit to prevent GC exhaustion attacks
// from affecting the host process or other stores
store.set_gc_heap_capacity_reservation(
GcHeapCapacityReservation::new(
64 * 1024 * 1024, // 64 MiB initial reservation
256 * 1024 * 1024, // 256 MiB maximum
)
);
// Epoch-based interruption: ensure GC-heavy modules can be interrupted
store.set_epoch_deadline(10); // 10 epoch ticks = ~10ms at default tick rate
store
}
fn main() {
let mut config = Config::new();
config.wasm_gc(true);
// Epoch interruption is required when GC is enabled for multi-tenant safety
config.epoch_interruption(true);
let engine = Engine::new(&config).unwrap();
// Each tenant request gets its own isolated store
for tenant_request in incoming_requests() {
let store = create_isolated_store(&engine);
process_request(store, tenant_request);
// Store drops here; GC heap is freed
}
}
Verify isolation by testing that GC objects from one store are not accessible from another:
#[test]
fn test_store_gc_isolation() {
let engine = Engine::new(Config::new().wasm_gc(true)).unwrap();
let mut store_a = Store::new(&engine, ());
let mut store_b = Store::new(&engine, ());
// Instantiate a module that creates GC objects in store_a
let module = Module::new(&engine, GC_TEST_WAT).unwrap();
let instance_a = Instance::new(&mut store_a, &module, &[]).unwrap();
let instance_b = Instance::new(&mut store_b, &module, &[]).unwrap();
// An externref from store_a must NOT be usable in store_b
let ref_from_a = instance_a.get_func(&mut store_a, "get_ref")
.unwrap().call(&mut store_a, &[], &mut vec![Val::null_func_ref()][..]);
let use_in_b = instance_b.get_func(&mut store_b, "use_ref")
.unwrap().call(&mut store_b, &[/* ref from store_a */], &mut []);
// This should trap or return an error, not succeed
assert!(use_in_b.is_err(), "Cross-store reference must be rejected");
}
Mitigating GC Timing Side-Channels
The primary mitigation for GC timing side-channels is to ensure that GC pauses are not observable by co-located tenants. Two approaches:
Approach 1: Request-scoped GC (preferred for request-handling workloads)
Configure Wasmtime to trigger GC only between request executions, not during:
// Process tenant requests serially within a worker; GC between requests
async fn handle_request(engine: &Engine, module: &Module, request: Request) -> Response {
let mut store = Store::new(engine, ());
store.set_gc_heap_capacity_reservation(GcHeapCapacityReservation::new(
16 * 1024 * 1024,
64 * 1024 * 1024,
));
// Execute the module
let result = execute_module(&mut store, module, request).await;
// Store drops here; GC heap freed synchronously
// No cross-tenant timing observable during execution
result
}
Approach 2: Add jitter to observable timing
If GC must run during execution, add randomised jitter to responses to prevent timing inference:
use std::time::{Duration, Instant};
use rand::Rng;
async fn execute_with_timing_jitter(store: &mut Store<()>, request: Request) -> Response {
let start = Instant::now();
let response = execute_inner(store, request).await;
// Add random jitter (0-5ms) to normalise observable timing
let elapsed = start.elapsed();
let target_min = Duration::from_millis(5);
if elapsed < target_min {
let jitter_ms = rand::thread_rng().gen_range(0u64..5);
tokio::time::sleep(Duration::from_millis(jitter_ms)).await;
}
response
}
Restricting externref Host Object Scope
Host-provided externref values (database handles, session tokens, host objects) must be scoped to a specific tenant’s execution context:
// Use a per-tenant capability table rather than global handles
struct TenantCapabilities {
tenant_id: TenantId,
capabilities: HashMap<u32, Box<dyn Any + Send + Sync>>,
next_handle: u32,
}
impl TenantCapabilities {
fn register(&mut self, obj: Box<dyn Any + Send + Sync>) -> u32 {
let handle = self.next_handle;
self.capabilities.insert(handle, obj);
self.next_handle += 1;
handle
}
fn get(&self, handle: u32) -> Option<&dyn Any> {
self.capabilities.get(&handle).map(|b| b.as_ref())
}
}
// In the host function that creates externref values, pass handles
// (integers) instead of direct references, and resolve them
// through the per-tenant table only
fn host_open_connection(tenant_caps: &mut TenantCapabilities, ...) -> u32 {
let conn = create_connection(...);
tenant_caps.register(Box::new(conn))
}
Limiting GC Heap to Prevent Exhaustion
// Wasmtime: fuel-based execution limiting applies to GC allocations too
let mut store = Store::new(&engine, ());
store.set_fuel(1_000_000).unwrap(); // Limit total execution steps
// Additionally set memory limits
store.limiter(|_| {
let mut limiter = StoreLimitsBuilder::new()
.memory_size(64 * 1024 * 1024) // 64 MiB linear memory
.table_elements(10_000)
// GC heap limit via capacity reservation (see above)
.build();
limiter
});
Enabling Runtime Traps on Type Safety Violations
Ensure ref.cast traps rather than producing undefined behaviour on type mismatch:
let mut config = Config::new();
config.wasm_gc(true);
// wasm-gc ref.cast mismatches produce traps by spec; verify this is not disabled
// Do NOT enable any unsafe flags that relax type checking:
// config.cranelift_opt_level(OptLevel::None) is fine for security
// Do NOT set cranelift_debug_verifier(false) in production if it disables checks
Test that type mismatches trap:
;; test-ref-cast-trap.wat
(module
(type $t1 (struct (field i32)))
(type $t2 (struct (field f32)))
(func (export "test_cast_trap")
(local $r (ref null $t1))
;; Create a $t1 struct
(local.set $r (struct.new $t1 (i32.const 42)))
;; Attempt to cast to $t2 — must trap
(drop (ref.cast (ref $t2) (local.get $r)))
)
)
wasmtime run --wasm-gc test-ref-cast-trap.wat --invoke test_cast_trap
# Expected: trap: failed ref.cast
Expected Behaviour After Hardening
| Scenario | Before Hardening | After Hardening |
|---|---|---|
| Tenant A exhausts GC heap | Potential OOM affecting all tenants in process | Per-store GC heap limit traps at 256 MiB; other stores unaffected |
| GC timing side-channel measurement | Observable 5-50ms GC pauses per store correlated with tenant | Request-scoped stores: GC runs between requests; no observable timing during execution |
Cross-store externref aliasing |
Implementation-dependent; may succeed in some runtimes | Test suite verifies cross-store references produce trap or error |
ref.cast type mismatch |
Undefined behaviour in insecure JIT | Wasmtime traps; no type confusion possible |
| GC allocation loop (DoS) | Infinite allocation causes process OOM | Fuel limiting terminates module after 1M steps; GC heap cap prevents OOM |
Verification:
# Verify GC is enabled and isolation tests pass
cargo test --test gc_isolation -- --nocapture
# Confirm per-store heap capacity
WASMTIME_LOG=wasmtime_runtime=trace wasmtime run \
--wasm-gc test-alloc.wasm 2>&1 | grep "gc heap"
# Confirm epoch interruption fires
wasmtime run --wasm-gc --epoch-interruption \
--max-wasm-stack=1048576 test-infinite-gc-loop.wasm
# Expected: trap after epoch deadline
Trade-offs and Operational Considerations
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Per-request Store (GC heap per request) | Eliminates cross-request GC state; strong isolation | Store creation overhead per request (~10-50µs) | Measure overhead; use store pooling for latency-sensitive paths |
| Request-scoped GC | No cross-tenant timing signal from GC pauses | GC heap not reused across requests; higher allocation rate | Profile allocation rate; tune GC heap initial size |
| Fuel limiting with GC | Prevents GC exhaustion DoS | Fuel consumption for GC operations must be calibrated | Run load tests to determine appropriate fuel budget for workload |
| Per-tenant capability tables | Prevents externref aliasing |
More complex host function implementation | Implement as middleware layer; shared by all host functions |
| Timing jitter | Reduces timing side-channel resolution | Adds latency (0-5ms) to all responses | Apply only to security-sensitive paths; document in SLO |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Per-store GC heap limit too small | Tenant module traps on legitimate allocation | Wasm trap in logs: gc heap capacity exceeded; user-facing error |
Increase per-store GC heap limit; profile typical module allocation |
| Epoch interruption too aggressive | Legitimate GC-heavy module interrupted prematurely | Unexpected traps in modules that compile managed languages | Increase epoch deadline for GC-heavy workloads; consider async epoch ticking |
| Fuel calibration wrong for GC workloads | Modules trap before completing legitimate work | User-facing errors; logs show fuel exhaustion | Re-calibrate fuel using representative workloads; expose fuel metric |
| Cross-store reference leak in custom host code | Security test fails; potential data leakage | Security regression test suite | Audit all host functions that create externref values; enforce capability table pattern |
JIT bug in new ref.cast codegen path |
Type confusion; potential security violation | Wasm spec conformance test suite failure | Pin Wasmtime version; apply upstream patch; disable GC in affected version |