Isolating Sensitive Data Using Wasm Multi-Memory
Problem
The WebAssembly linear memory model uses a single, flat address space for all application data by default. This design is intentional: it provides predictable layout, enables efficient compilation, and simplifies the host-guest interface. The security trade-off is that every piece of data the Wasm module touches — application state, user input, cryptographic keys, passwords, in-flight plaintext, connection strings — lives in the same memory region. A buffer overflow, an out-of-bounds read, or a logic error that allows arbitrary memory access can reach any of it.
The Wasm multi-memory proposal (standardized in 2024 and enabled by default in Wasmtime ≥10.0, WasmEdge ≥0.14, and V8 ≥11.6) addresses this by allowing a single Wasm module to declare and use multiple distinct linear memories. Each memory has its own base address, its own bounds, and its own access semantics. A load or store instruction targeting memory index 0 cannot access memory index 1, even if an index arithmetic error would produce an address that falls within the other memory’s range — the bounds check operates per-memory.
The security implication is significant: multi-memory enables intra-module data isolation. A module handling TLS session keys can place those keys in memory index 1, application data in memory index 0, and PII in memory index 2. An attacker who achieves an arbitrary read in memory 0 — perhaps via a format string bug or a missing bounds check in string handling — does not automatically gain access to the keys in memory 1. The memories are disjoint by construction.
This is not a complete security boundary — it is a structural control that raises the cost of memory disclosure attacks. An attacker who achieves full code execution can still access all memories directly. But for the more common classes of Wasm security bugs (buffer over-reads, use-after-free equivalents via dangling references, integer overflow in index computation), multi-memory containment provides meaningful protection.
The current adoption gap is that most Wasm toolchains and standard library allocators are not multi-memory-aware. LLVM’s Wasm backend, the Rust wasm32 target, and TinyGo all default to a single memory. Developers who want multi-memory isolation must either write raw WAT, use a memory management library that supports multi-memory, or manually partition data allocation between memories using host imports. This is non-trivial but achievable for the sensitive data paths that matter most — cryptographic key storage, PII handling, and session management.
Target systems: Wasm modules processing cryptographic keys, session tokens, or PII; Wasmtime ≥10.0, WasmEdge ≥0.14, Wasmer ≥4.2, or any runtime with multi-memory support; edge functions or MCP servers that handle user credentials.
Threat Model
Adversary 1 — Linear memory over-read. Access level: code execution inside the Wasm module (adversary controls input). Objective: craft input that triggers a buffer over-read in the main application heap, scanning adjacent memory for cryptographic keys or session tokens. With single memory, all data is adjacent. With multi-memory, keys in memory 1 are not accessible via an over-read in memory 0.
Adversary 2 — Integer overflow in array indexing. Access level: control over an array index that is computed from untrusted input. Objective: cause an integer overflow that wraps a large index into a small one, accessing data outside the intended array bounds. Multi-memory constrains the range of accessible addresses to the specific memory the array resides in.
Adversary 3 — Use-after-free equivalent via stale handle. Access level: ability to cause a memory allocation to be freed while a reference remains live. Objective: use the stale reference to read or write the memory region, which may have been reallocated for a sensitive data structure. If sensitive data is in a separate allocator backed by a separate memory, the stale reference in memory 0 cannot reach the sensitive allocator in memory 1.
Adversary 4 — Wasm coredump on trap. Access level: ability to cause a Wasm trap (deliberate or via bug). Objective: trigger coredump generation, which captures all linear memories. If sensitive data is in a separate, clearly identified memory, the coredump scrubbing pipeline can zero that specific memory before archival.
Without multi-memory isolation: any memory disclosure bug has access to the entire application state. With multi-memory: sensitive data is isolated to specific memories; disclosure bugs in the main heap do not automatically compromise the sensitive memory.
Configuration / Implementation
Step 1 — Understand multi-memory in WAT
At the WebAssembly text format level, multi-memory is declared with multiple memory entries:
;; multi-memory.wat — minimal example
(module
;; Memory 0: general application heap (4 pages = 256 KB initial)
(memory $heap 4 64)
;; Memory 1: secure key storage (1 page = 64 KB initial, max 1 page)
;; Fixed size prevents growth probing attacks
(memory $keys 1 1)
;; Export both for host inspection (optional — omit for production)
;; (export "heap" (memory $heap))
;; (export "keys" (memory $keys))
;; Store a key in the secure memory (memory index 1)
(func $store_key (param $key_offset i32) (param $key_len i32)
;; Copy key bytes into $keys memory starting at offset 0
;; This is a simplified example; production code needs bounds checks
(i32.store8 (memory $keys) (i32.const 0)
(i32.load8_u (memory $heap) (local.get $key_offset)))
;; ... copy remaining bytes
)
;; Load from secure memory — only accessible via explicit instructions
(func $get_key_byte (param $offset i32) (result i32)
(i32.load8_u (memory $keys) (local.get $offset))
)
;; Demonstrate isolation: this function can only access $heap
;; It cannot address $keys regardless of what offset value is passed
(func $read_heap_byte (param $offset i32) (result i32)
(i32.load8_u (memory $heap) (local.get $offset))
;; offset cannot reach $keys — different memory instruction required
)
)
Enable multi-memory in Wasmtime:
# Compile with multi-memory enabled
wasmtime compile --enable-multi-memory multi-memory.wat -o multi-memory.wasm
# Run
wasmtime run --enable-multi-memory multi-memory.wasm
Step 2 — Implement secure memory allocation in Rust
Using the wasm-bindgen ecosystem with a custom allocator for the secure memory:
// src/secure_memory.rs
// Implements an allocator backed by Wasm memory index 1
extern "C" {
// Import of host function to allocate in secure memory
// Host provides this via wasmtime linker
#[link_name = "secure_mem_alloc"]
fn host_secure_alloc(size: u32) -> u32;
#[link_name = "secure_mem_free"]
fn host_secure_free(ptr: u32, size: u32);
#[link_name = "secure_mem_write"]
fn host_secure_write(dst: u32, src: *const u8, len: u32);
#[link_name = "secure_mem_read"]
fn host_secure_read(src: u32, dst: *mut u8, len: u32);
#[link_name = "secure_mem_zero"]
fn host_secure_zero(ptr: u32, len: u32);
}
/// A handle to a value stored in secure memory (Wasm memory index 1).
/// The value is automatically zeroed on drop.
pub struct SecureBuffer {
ptr: u32, // Offset in secure memory (not the main heap)
len: u32,
}
impl SecureBuffer {
pub fn new(data: &[u8]) -> Self {
let len = data.len() as u32;
let ptr = unsafe { host_secure_alloc(len) };
unsafe { host_secure_write(ptr, data.as_ptr(), len) };
SecureBuffer { ptr, len }
}
/// Read the value into a temporary heap buffer for use.
/// Caller is responsible for zeroing the heap copy after use.
pub fn read_to_vec(&self) -> Vec<u8> {
let mut buf = vec![0u8; self.len as usize];
unsafe { host_secure_read(self.ptr, buf.as_mut_ptr(), self.len) };
buf
}
pub fn len(&self) -> usize {
self.len as usize
}
}
impl Drop for SecureBuffer {
fn drop(&mut self) {
// Zero the secure memory before freeing
unsafe { host_secure_zero(self.ptr, self.len) };
unsafe { host_secure_free(self.ptr, self.len) };
}
}
// Usage in application code:
pub fn process_with_key(key_bytes: &[u8], plaintext: &[u8]) -> Vec<u8> {
let secure_key = SecureBuffer::new(key_bytes);
// ... use secure_key.read_to_vec() temporarily for crypto operation
// secure_key is zeroed and freed on drop
vec![]
}
Step 3 — Implement the host side in Wasmtime
On the Wasmtime host, provide the secure memory allocation functions as imports:
// host/src/main.rs — Wasmtime host with multi-memory support
use wasmtime::*;
use wasmtime_wasi::WasiCtx;
use std::sync::{Arc, Mutex};
struct SecureMemoryState {
// Simple bump allocator for the secure memory
// Production: use a proper allocator
next_offset: u32,
capacity: u32,
}
fn setup_secure_memory_imports(
linker: &mut Linker<WasiCtx>,
store: &mut Store<WasiCtx>,
secure_memory: Memory, // Memory instance for index 1
) -> anyhow::Result<()> {
let state = Arc::new(Mutex::new(SecureMemoryState {
next_offset: 0,
capacity: 65536, // 1 page = 64 KB
}));
// Provide alloc function for secure memory
let alloc_state = state.clone();
linker.func_wrap("env", "secure_mem_alloc", move |size: u32| -> u32 {
let mut s = alloc_state.lock().unwrap();
let ptr = s.next_offset;
s.next_offset += size;
assert!(s.next_offset <= s.capacity, "Secure memory exhausted");
ptr
})?;
// Provide write function into secure memory
let mem_write = secure_memory.clone();
linker.func_wrap(
"env", "secure_mem_write",
move |mut caller: Caller<'_, WasiCtx>, dst: u32, src: u32, len: u32| {
// Read from main memory (memory 0), write to secure memory (memory 1)
let main_mem = caller.get_export("memory")
.and_then(|e| e.into_memory())
.expect("main memory not found");
let data = main_mem.data(&caller)[src as usize..(src + len) as usize].to_vec();
mem_write.write(&mut caller, dst as usize, &data)
.expect("secure memory write failed");
}
)?;
// Provide zero function (called on SecureBuffer drop)
let mem_zero = secure_memory.clone();
linker.func_wrap(
"env", "secure_mem_zero",
move |mut caller: Caller<'_, WasiCtx>, ptr: u32, len: u32| {
let zeros = vec![0u8; len as usize];
mem_zero.write(&mut caller, ptr as usize, &zeros)
.expect("secure memory zero failed");
}
)?;
Ok(())
}
Step 4 — Verify isolation with a probe test
Write a test that confirms an over-read in memory 0 cannot reach data in memory 1:
;; isolation-test.wat
(module
(memory $heap 1)
(memory $keys 1)
;; Write a canary value to secure memory
(func $write_canary
(i32.store (memory $keys) (i32.const 0) (i32.const 0xDEADBEEF))
)
;; Attempt to read from heap memory using an arbitrary offset
;; This CANNOT reach the keys memory regardless of offset value
(func $probe_heap (param $offset i32) (result i32)
;; The runtime will trap if offset > heap size
;; But even offset = MAX_INT cannot reach $keys memory
(i32.load (memory $heap) (local.get $offset))
)
(func $read_key_canary (result i32)
(i32.load (memory $keys) (i32.const 0))
)
(export "write_canary" (func $write_canary))
(export "probe_heap" (func $probe_heap))
(export "read_key_canary" (func $read_key_canary))
)
// isolation_test.rs
#[test]
fn test_multi_memory_isolation() {
let engine = Engine::default();
let module = Module::from_file(&engine, "isolation-test.wat").unwrap();
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[]).unwrap();
// Write canary to secure memory
let write_canary = instance.get_func(&mut store, "write_canary").unwrap();
write_canary.call(&mut store, &[], &mut []).unwrap();
// Verify canary is readable from secure memory
let read_canary = instance.get_func(&mut store, "read_key_canary").unwrap();
let mut results = vec![Val::I32(0)];
read_canary.call(&mut store, &[], &mut results).unwrap();
assert_eq!(results[0].i32().unwrap(), 0xDEADBEEFu32 as i32);
// Attempt to read the canary via a heap probe — should fail or return 0, never 0xDEADBEEF
let probe_heap = instance.get_func(&mut store, "probe_heap").unwrap();
for offset in [0u32, 1024, 4096, 65536, u32::MAX - 4] {
let mut results = vec![Val::I32(0)];
let result = probe_heap.call(&mut store, &[Val::I32(offset as i32)], &mut results);
match result {
Err(_) => {} // Trap expected for out-of-bounds access — this is correct
Ok(_) => {
// Should not equal the canary from the other memory
assert_ne!(
results[0].i32().unwrap(),
0xDEADBEEFu32 as i32,
"Memory isolation breach: heap probe at offset {} returned secure memory canary",
offset
);
}
}
}
println!("PASS: heap probing cannot reach secure memory contents");
}
Step 5 — Tag sensitive memories for coredump scrubbing
When coredumps are needed for debugging, identify secure memories by index for the scrubbing pipeline:
# Generate a coredump and identify memory sections
wasm-coredump-parser dump.wasm.core | grep -E "memory|linear_memory"
# Output shows memory indices and sizes
# Extended coredump scrubber (builds on previous article's scrubber)
# /usr/local/bin/scrub-multi-memory-coredump.sh
COREDUMP=$1
SENSITIVE_MEMORY_INDICES="1 2" # Zero these memory indices before archival
for mem_idx in $SENSITIVE_MEMORY_INDICES; do
python3 - <<EOF
import struct, sys
# Parse Wasm coredump format and zero memory index $mem_idx
# Coredump format: https://github.com/nicowillis/wasm-coredump-format
with open("$COREDUMP", "rb") as f:
data = bytearray(f.read())
# Simplified: find memory section for index $mem_idx and zero it
# Production: use a proper coredump parser
print(f"Zeroing sensitive memory index {$mem_idx} in coredump")
with open("$COREDUMP.scrubbed", "wb") as f:
f.write(data)
EOF
done
Expected Behaviour
| Signal | Before multi-memory | After multi-memory |
|---|---|---|
| Buffer over-read in heap code | May reach adjacent key material in same memory | Bounded to heap memory; key memory in separate region |
| Wasm trap with coredump | All memory (including keys) in dump | Secure memories identified by index; can be scrubbed |
| Integer overflow in heap index | May wrap to key data offset | Wraps within heap memory bounds only |
wasmtime run --enable-multi-memory |
Not needed | Required for multi-memory modules |
| Isolation test: heap probe returns canary | Test fails | Test passes — probe traps or returns non-canary value |
Verification:
# Confirm runtime supports multi-memory
wasmtime --version # Must be 10.0+
wasmtime explore --enable-multi-memory isolation-test.wat 2>/dev/null | \
grep "memory" | wc -l
# Expected: 2 (two memory sections)
# Run isolation test
cargo test --test isolation_test -- --nocapture
# Expected: "PASS: heap probing cannot reach secure memory contents"
# Verify multi-memory module loads correctly
wasmtime run --enable-multi-memory multi-memory.wasm
# Expected: no "unknown opcode" or "feature not enabled" errors
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Multi-memory for key isolation | Structural containment of key material; meaningful for buffer over-read attacks | Toolchain support is still maturing; Rust/C standard allocators don’t natively support multiple memories | Use host-side allocation helpers as shown; WAT for critical key management code |
| Fixed-size secure memory | Prevents memory growth probing; bounds are predictable | Must size correctly at compile time; OOM panic if exceeded | Size to maximum expected key material; monitor usage with host-side metrics |
| Host-imported allocation functions | Full control over secure memory layout | Increases host-guest interface complexity | Encapsulate in a well-tested library; fuzz the allocation interface |
| Separate memories for different data classes | Granular isolation (keys, PII, session tokens each separate) | Each memory adds overhead; WASM binary size grows | Use at most 2–3 specialized memories for highest-value isolation; not every data class needs separation |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Runtime does not support multi-memory | Module fails to load: “multi-memory feature not enabled” | Error at startup; deploy pipeline can check via wasmtime config |
Pin runtime version ≥10.0; add multi-memory capability check to deployment validation |
| Secure memory exhausted at runtime | Wasm trap: “secure memory exhausted” | Runtime trap; application error | Increase secure memory page count at compile time; add usage monitoring via host-side counter |
| Cross-memory pointer stored in heap | Pointer to secure memory address stored in heap data; heap over-read recovers the pointer value | Fuzzing of pointer recovery via heap scanning | Use opaque handles (array indices, not raw pointers) as the reference type in heap-resident data structures |
| Toolchain update resets to single-memory | Updated compiler emits standard single-memory module | Binary analysis detects single memory section |
Pin compiler version; add CI test that counts memory sections in output binary: wasm-objdump -h module.wasm | grep memory | wc -l |
Related Articles
- Wasm Linear Memory Safety — the single-memory model and why multi-memory is a structural improvement for sensitive data isolation
- Wasm Coredump Data Exposure Hardening — multi-memory’s per-memory index enables more precise coredump scrubbing of sensitive memory sections
- Wasm Edge Secrets Management — keeping secrets out of Wasm linear memory; multi-memory complements secret management by isolating unavoidable in-memory key material
- Wasmtime Production Hardening — enabling multi-memory and other security features in production Wasmtime deployments
- Wasm Memory64 Security — memory64 expands addressing within a single memory; combined with multi-memory, enables large-scale isolated memory regions