WebAssembly Memory Copy Safety: Bounds Checking, OOB Patterns, and Host Buffer Exchange
Problem
WebAssembly’s bulk memory proposal — standardised in 2022 and now baseline across Wasmtime, WasmEdge, V8, and SpiderMonkey — adds memory.copy, memory.fill, and table.copy instructions that operate on ranges within linear memory. The WASM specification requires every runtime to bounds-check these operations: if either the source or destination range extends past the end of the current linear memory, the instruction traps immediately. No undefined behaviour, no silent overread.
This guarantee is narrower than it appears, and three patterns reliably break it.
Pattern 1 — Host-to-WASM copies via the host API. Every WASM runtime exposes a host-side API for injecting data into guest linear memory. Wasmtime exposes Memory::write; WasmEdge exposes WasmEdge_MemoryInstanceSetData; the WebAssembly JS API exposes WebAssembly.Memory.buffer. These functions do not share the WASM trap mechanism — they execute as ordinary host code. If the host uses a guest-supplied offset or length to determine where to write, the host is performing a confused deputy operation: the guest controls the destination of a write that the host executes on its behalf. A malicious WASM module passes an offset that is within bounds at validation time but points to a security-sensitive region of linear memory — a function table, an in-memory capability set, or a length field for a subsequent operation.
Pattern 2 — TOCTOU on shared memory with WASM threads. The WASM threads proposal allows modules to declare linear memory as shared, enabling multiple threads to operate on the same backing store via memory.atomic.* instructions. This opens a time-of-check to time-of-use window on any value read from shared memory. The canonical case: Thread A reads a copy length from a shared location, validates it, then calls memory.copy. Between the read and the instruction, Thread B writes a larger length to the same location. The instruction executes with the newly-set length, not the validated one — and if the new length is beyond the validated range, the copy succeeds or traps depending on whether it crossed the linear memory boundary, but the invariant the host intended has already been violated.
Pattern 3 — Spectre via memory.copy cache timing. A WASM module that shares host execution context with other guests can use memory.copy to drive speculative loads across linear memory boundaries. The speculative path reads from a region outside the module’s intended access range; the architectural trap is suppressed by the speculative window. The data never reaches the guest’s registers architecturally, but the cache lines it loads do — and a subsequent timing measurement reveals whether a given byte was loaded. This is the same mechanism as Spectre v1 (Bounds Check Bypass), adapted to the WASM bulk memory instruction set.
Target systems: Wasmtime 20+ on x86_64/aarch64, WasmEdge 0.14+, V8 in Node.js 20+ and browser contexts, any WASM runtime with threading or host-API write access enabled. Embedded C-based runtimes (WAMR, wasm3) with host copy APIs are additionally subject to host stack overflow from guest-controlled lengths.
Threat Model
Threat 1 — Confused deputy via host memory write. A malicious WASM module calls a host function that accepts a guest-supplied offset and length, then uses them to call Memory::write without independent validation. The guest supplies an offset that points to its own function table, a permissions bitmap, or an adjacent tenant’s memory region (in runtimes that share linear memory across instances). The host writes attacker-controlled bytes to that region. Precondition: the host function exists and accepts guest-controlled address parameters. Impact: arbitrary write within linear memory; in multi-tenant runtimes, potential cross-tenant write if the runtime incorrectly shares the backing store.
Threat 2 — TOCTOU race on shared-memory copy length. Two WASM threads share linear memory. Thread A implements a security boundary: it reads a length field from a guest-controlled shared memory location, validates that length <= MAX_ALLOWED, then calls memory.copy(dst, src, length). Thread B, also guest-controlled, runs a tight loop writing length = MAX_ALLOWED + 1 to the same location. On sufficiently parallel hardware, Thread B’s write lands between Thread A’s validation read and the memory.copy instruction. The copy executes with the unvalidated length, potentially writing past the intended destination region. Precondition: WASM threading enabled; two threads share a linear memory instance. Impact: OOB write within linear memory; in modules with function tables or capability structures stored in linear memory, control-flow hijacking.
Threat 3 — Spectre v1 gadget using bulk memory. A WASM module constructs a gadget: an array index is loaded from guest-controlled input and used as an offset in memory.copy. The bounds check traps architecturally but the speculative path loads from outside the valid range before the trap is recognised. A high-resolution timer (implemented via memory.atomic.wait or via repeated iteration counting against a second thread) measures which cache lines were loaded. The attacker extracts bytes from adjacent linear memory — potentially another module’s key material if the runtime places multiple modules’ linear memory on adjacent pages. Precondition: high-resolution timer available to guest; co-location with a victim module. Impact: read-only side-channel at ~100–500 bytes per second, depending on cache geometry.
Threat 4 — Host stack overflow in C-based runtimes. A host function in a C-based WASM runtime (WAMR, wasm3, or a custom embedding) receives a guest-supplied length, allocates a stack buffer of that size (char buf[len] via VLA or alloca), then copies guest memory into it. The guest passes len = 0xffffffff. The runtime writes 4 GB of guest data into a stack buffer — overflowing the host’s stack, corrupting the host’s return addresses, and enabling host-process code execution. Precondition: host function uses guest-supplied length for stack allocation without capping it. Impact: host process compromise — the WASM sandbox is fully escaped.
Configuration and Implementation
WASM Memory Model: What the Runtime Guarantees
WASM linear memory is a contiguous byte array, always a multiple of 65,536 bytes (one page), with a fixed maximum size declared at module instantiation. Every memory access — load, store, memory.copy, memory.fill — is bounds-checked against the current size. Accesses outside the current size trap immediately; the trap is not catchable by the module (in the base specification; the exception-handling proposal changes this for structured exceptions, but trap-based OOB is not a structured exception).
memory.copy semantics per the specification:
;; memory.copy dst_addr src_addr len
;; Traps if dst_addr + len > mem.size OR src_addr + len > mem.size.
;; Handles overlapping regions correctly (like memmove, not memcpy).
(memory.copy
(i32.const 4096) ;; dst
(i32.const 0) ;; src
(i32.const 1024)) ;; len
The trap fires before any bytes are copied. There is no partial-copy behaviour. This is the property that makes memory.copy safe from within-WASM code — but it only applies to architecturally executed instructions.
Bulk Memory Instruction Safety in WAT
The following test cases demonstrate boundary conditions. Use wat2wasm to compile and wasmtime to execute:
(module
(memory 1) ;; 1 page = 65536 bytes
;; This function traps: dst + len = 65537 > 65536.
(func $copy_oob_dst (export "copy_oob_dst")
memory.copy
(i32.const 65533) ;; dst: near end of page
(i32.const 0) ;; src: start
(i32.const 4) ;; len: 4 bytes — dst+len = 65537, OOB
)
;; This function succeeds: exactly at boundary.
(func $copy_at_boundary (export "copy_at_boundary")
memory.copy
(i32.const 65532) ;; dst: 4 bytes from end
(i32.const 0) ;; src
(i32.const 4) ;; len: dst+len = 65536, exactly in bounds
)
;; memory.fill: same bounds semantics.
(func $fill_oob (export "fill_oob")
memory.fill
(i32.const 65535) ;; dst: 1 byte from end
(i32.const 0) ;; value: fill with 0
(i32.const 2) ;; len: 2 bytes — OOB
)
)
wat2wasm boundary_test.wat -o boundary_test.wasm
wasmtime run boundary_test.wasm --invoke copy_oob_dst
# Error: wasm trap: out of bounds memory access
wasmtime run boundary_test.wasm --invoke copy_at_boundary
# (success, no output)
wasmtime run boundary_test.wasm --invoke fill_oob
# Error: wasm trap: out of bounds memory access
The trap on copy_oob_dst and fill_oob confirms the runtime is enforcing bulk memory bounds. These test cases belong in a CI pipeline for any WASM runtime upgrade.
Safe Host-to-WASM Copy Pattern in Wasmtime (Rust)
The critical property is: validate all guest-supplied addresses and lengths against the current memory size in host code, independently of anything the guest reports. Do not trust the guest’s own length field. Fetch the current memory size from the runtime after locking the store.
use wasmtime::{Caller, Engine, Linker, Module, Store};
use anyhow::{anyhow, Result};
// UNSAFE: host function that accepts guest-controlled offset without validation.
// A malicious module passes offset=0 and len=65536 to overwrite the entire
// linear memory, including any function tables or security state stored there.
fn unsafe_write_to_guest(
mut caller: Caller<'_, ()>,
guest_offset: i32,
data_ptr: i32,
data_len: i32,
) -> Result<()> {
let offset = guest_offset as usize;
let len = data_len as usize;
let mem = caller.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| anyhow!("no memory export"))?;
// BUG: no bounds check before write.
// Memory::write will return an error on OOB, but the offset + len
// arithmetic is not independently validated here — integer overflow
// in offset + len can wrap to a valid range while the intent was OOB.
let host_data = get_data_from_somewhere(data_ptr as usize, len);
mem.write(&mut caller, offset, &host_data)?;
Ok(())
}
// SAFE: explicit bounds validation before any write.
fn safe_write_to_guest(
mut caller: Caller<'_, ()>,
guest_offset: i32,
data_ptr: i32,
data_len: i32,
) -> Result<()> {
// Cast to usize carefully; negative i32 values would wrap to huge usize.
let offset = usize::try_from(guest_offset)
.map_err(|_| anyhow!("guest_offset is negative"))?;
let len = usize::try_from(data_len)
.map_err(|_| anyhow!("data_len is negative"))?;
let mem = caller.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| anyhow!("no memory export"))?;
// Fetch the current memory size in bytes from the runtime — do not
// trust any size the guest reports via a parameter or a memory read.
let mem_size = mem.data_size(&caller);
// Use checked arithmetic to prevent offset + len integer overflow.
let end = offset
.checked_add(len)
.ok_or_else(|| anyhow!("offset + len overflows usize"))?;
if end > mem_size {
return Err(anyhow!(
"guest write request [{}..{}] exceeds memory size {}",
offset, end, mem_size
));
}
// Additionally enforce a maximum transfer size regardless of memory size.
// This prevents a guest from forcing the host to copy large amounts of data
// even within valid bounds — a DoS via CPU time.
const MAX_HOST_COPY: usize = 4 * 1024 * 1024; // 4 MiB
if len > MAX_HOST_COPY {
return Err(anyhow!("data_len {} exceeds MAX_HOST_COPY", len));
}
let host_data = get_data_from_somewhere(data_ptr as usize, len);
// Memory::write performs its own bounds check as a defence-in-depth layer,
// but it is not a substitute for the explicit check above.
mem.write(&mut caller, offset, &host_data)?;
Ok(())
}
fn get_data_from_somewhere(ptr: usize, len: usize) -> Vec<u8> {
// Placeholder: in practice, this reads from a host-side source.
vec![0u8; len]
}
The key differences between the unsafe and safe versions:
- Signed-to-unsigned cast with explicit error on negative values —
usize::try_from(i32)fails for negative inputs, whichas usizewould silently wrap. - Checked addition —
offset.checked_add(len)fails on overflow rather than wrapping. - Independent size fetch from the runtime —
mem.data_size(&caller)returns the actual current size, not a guest-reported value. - Explicit transfer size cap — prevents CPU-time DoS from large but valid copies.
WASM Threads and Shared Memory: TOCTOU Mitigation
The TOCTOU pattern on shared memory requires that any security-relevant value read from shared memory is not re-read between validation and use. In WASM threads, this means using atomic compare-and-swap to claim the value atomically.
(module
(memory (export "mem") 1 1 shared) ;; shared linear memory
;; Thread A — UNSAFE: validates length, then uses it.
;; Thread B can modify the length between the two reads.
(func $unsafe_copy_with_shared_len (export "unsafe_copy")
(local $len i32)
;; Read length from shared location at offset 0.
(local.set $len
(i32.atomic.load (i32.const 0)))
;; TOCTOU window: Thread B writes len=0xffffffff here.
;; Validate.
(if (i32.gt_u (local.get $len) (i32.const 4096))
(then unreachable)) ;; trap if too large
;; Use — but $len was read before validation and Thread B may have changed
;; the value at address 0; this code re-reads nothing, so $len holds the
;; validated value. However: if the code re-reads from address 0 here
;; (as some patterns do), the TOCTOU fires.
(memory.copy
(i32.const 8192) ;; dst
(i32.const 4096) ;; src
(local.get $len)) ;; length — safe here because $len is a local
)
;; Thread A — SAFE: use atomic CAS to claim a length value.
;; Once CAS succeeds, the value is exclusively owned by Thread A.
(func $safe_copy_with_atomic_claim (export "safe_copy")
(local $len i32)
(local $zero i32)
;; Atomically read the length and replace it with 0 (claim it).
;; If another thread has already claimed it (set it to 0), CAS fails.
;; This is a simplified pattern; production code uses a mutex or
;; a sentinel value to distinguish "unclaimed" from "claimed".
(local.set $len
(i32.atomic.load (i32.const 0)))
(block $claimed
;; CAS: if mem[0] == $len, set mem[0] = 0 and proceed.
(br_if $claimed
(i32.eq
(i32.atomic.rmw.cmpxchg
(i32.const 0) ;; address
(local.get $len) ;; expected
(i32.const 0)) ;; replacement
(local.get $len)))
;; CAS failed: another thread modified the value; abort.
(unreachable)
)
;; Validate the claimed value.
(if (i32.gt_u (local.get $len) (i32.const 4096))
(then unreachable))
;; Use — $len is now exclusively owned; no concurrent modification possible.
(memory.copy
(i32.const 8192)
(i32.const 4096)
(local.get $len))
)
)
For Wasmtime on the host side, enable threading with an explicit memory size cap to prevent a thread from growing shared memory as a DoS:
use wasmtime::{Config, Engine, PoolingAllocationConfig};
fn engine_with_threading() -> Result<Engine> {
let mut config = Config::new();
// Enable the threads proposal.
config.wasm_threads(true);
// Cap linear memory size — prevents a thread from calling memory.grow
// to a size that makes the TOCTOU window more exploitable by ensuring
// large OOB writes fail at the memory.copy trap rather than succeeding.
config.max_wasm_stack(512 * 1024); // 512 KiB WASM stack
// Use the pooling allocator to give each instance a fixed memory region.
// This prevents cross-instance linear memory adjacency that enables
// Spectre cache-timing attacks.
let mut pool = PoolingAllocationConfig::default();
pool.max_memory_size(16 * 1024 * 1024); // 16 MiB per instance
pool.total_memories(256);
config.allocation_strategy(wasmtime::InstanceAllocationStrategy::Pooling(pool));
Engine::new(&config)
}
Spectre Mitigations for memory.copy
Spectre v1 via memory.copy works because the speculative path can load data from outside the architecturally valid range before the trap is raised. Mitigations operate at the JIT layer:
Index masking (V8, Wasmtime). The JIT emits a mask instruction before every memory access that forces the effective address to remain within bounds even speculatively. Wasmtime enables this via the spectre_mitigations flag:
use wasmtime::Config;
fn hardened_engine_config() -> Config {
let mut config = Config::new();
// Enable Spectre mitigations. This emits an AND mask before each
// heap access on x86_64, preventing speculative loads from escaping
// linear memory bounds even under transient execution.
// Performance cost: ~3–8% on memory-intensive workloads.
config.cranelift_flag_set("enable_heap_access_spectre_mitigation", "true")
.expect("flag accepted");
// Reduce timer resolution available to WASM code to increase the cost
// of cache-timing attacks. Affects performance.clock_time_get in WASI.
// Set to 1ms resolution (1_000_000 ns).
// Note: this does not prevent Atomics-based timers in threaded modules.
config
}
Site isolation (browsers). In V8, memory.copy Spectre mitigations are only effective when combined with Cross-Origin Isolation, which prevents a WASM module from being co-located in the same process as a victim from another origin:
# HTTP headers required for SharedArrayBuffer and Spectre isolation.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Without these headers, the browser may place modules from different origins in the same process, sharing an address space and making cross-module Spectre feasible regardless of index masking.
Component model isolation. The WASM Component Model’s shared-nothing architecture is the strongest defence against cross-module memory.copy leakage: components cannot share linear memory. Each component has an entirely separate linear memory instance. A memory.copy in component A cannot speculate into component B’s memory because they do not share an address space within the runtime:
# Build components (not modules) for isolation.
cargo component build --release
# Compose components — the composition boundary is a strict isolation point.
wasm-compose --config composition.yaml -o composed.wasm
Memory Limit Configuration in Wasmtime
Preventing linear memory growth bounds the worst-case OOB range and limits DoS from memory.grow:
use wasmtime::{Engine, Limits, Module, Store, StoreLimits, StoreLimitsBuilder};
fn store_with_memory_limits(engine: &Engine) -> Store<StoreLimits> {
let limits = StoreLimitsBuilder::new()
.memory_size(32 * 1024 * 1024) // 32 MiB maximum linear memory
.memories(1) // one memory instance per store
.build();
Store::new(engine, limits)
}
Testing Boundary Conditions
Use wasm-smith to generate fuzzer corpus entries for bulk memory operations:
# Install tooling.
cargo install wasm-smith wasmtime-cli
# Generate random WASM with bulk memory enabled — use for fuzzing.
wasm-smith \
--bulk-memory-enabled \
--max-memory-pages 2 \
--generate-memory-inits \
-o fuzz_corpus/seed_$(date +%s).wasm
# Run a single generated module to observe trap behaviour.
wasmtime run --allow-unknown-exports fuzz_corpus/seed_*.wasm 2>&1 | \
grep -c "trap"
For structured boundary testing, write WAT test cases at each boundary condition (copy exactly at size, copy one byte past size, copy with zero length from end) and integrate them into the runtime’s test suite. The wasmtime CLI’s exit code is non-zero on trap, enabling shell-level assertions:
# Must trap.
wasmtime run oob_copy.wasm --invoke copy_oob_dst
if [ $? -eq 0 ]; then echo "FAIL: expected trap"; exit 1; fi
# Must not trap.
wasmtime run oob_copy.wasm --invoke copy_at_boundary
if [ $? -ne 0 ]; then echo "FAIL: expected success"; exit 1; fi
Expected Behaviour
The table below describes what the runtime or mitigation should produce for each threat and boundary case:
| Scenario | Runtime/Mitigation | Expected Outcome |
|---|---|---|
memory.copy with dst + len > mem.size (in-module) |
Wasmtime / WasmEdge / V8 | Trap before any bytes copied; module execution ends |
memory.copy at exactly mem.size boundary (last byte) |
All compliant runtimes | Success; no trap |
memory.copy with len = 0 at any address |
All compliant runtimes | Success; no bytes copied; no trap even if address = mem.size |
Host Memory::write with validated offset + len |
Safe host API (Rust Wasmtime) | Success or explicit host error; no memory corruption |
Host Memory::write with unvalidated guest offset |
Unsafe host API | Writes to arbitrary region within linear memory; no trap (host code, not WASM instruction) |
| TOCTOU on shared memory length (WASM threads) | No runtime mitigation; requires guest code discipline | May succeed with unvalidated length; no trap if result is in-bounds |
| TOCTOU on shared memory length, atomic CAS used | Guest-side mitigation | CAS failure if length was modified; explicit trap via unreachable |
Spectre gadget via memory.copy (Wasmtime, spectre-mitigations=true) |
Index masking | Speculative load masked to valid range; no data leakage |
Spectre gadget via memory.copy (no mitigations) |
No mitigation | Cache-timing side channel; architecturally no data leakage but microarchitecturally visible |
Guest-supplied len to host stack allocation (C runtime, uncapped) |
No mitigation | Stack overflow; potential host process compromise |
memory.grow past configured max |
Wasmtime max_memory_size |
memory.grow returns -1; module sees allocation failure |
memory.copy in component vs. same component |
Component model | Operates only on component’s own linear memory; no cross-component access possible |
Trade-offs
| Mitigation | What It Prevents | Overhead | What It Breaks |
|---|---|---|---|
Wasmtime enable_heap_access_spectre_mitigation |
Spectre v1 via speculative loads in bulk memory and heap accesses | 3–8% throughput on memory-intensive workloads; higher on pointer-chasing code | Nothing functionally; purely performance cost |
V8 index masking (--wasm-spectre-mitigation) |
Spectre v1 in browser WASM including memory.copy |
~5–10% on memory-intensive benchmarks | No functional breakage; throughput cost |
| Cross-Origin Isolation (COOP + COEP) | Cross-origin Spectre; required for SharedArrayBuffer |
Blocks cross-origin subresources unless they opt in with CORP/COEP headers; breaks some embedding patterns | Third-party iframes and resources without CORP headers cannot load |
| WASM Component Model (shared-nothing) | Cross-module memory.copy speculation; shared-memory TOCTOU across modules |
Higher instantiation cost; inter-component calls go through typed interface (serialisation); ~10–30% for chatty inter-module communication | Shared linear memory across components is architecturally impossible; modules that assumed shared memory must be rewritten |
Disabling WASM threads (wasm_threads(false)) |
TOCTOU on shared memory; Spectre via atomics-based timers | No performance overhead | Eliminates WASM threading entirely; breaks workloads relying on parallelism (ML inference, image processing) |
StoreLimitsBuilder::memory_size cap |
memory.grow DoS; bounds OOB range |
Negligible | Module fails if it legitimately requires more memory than the cap |
Maximum host copy size cap (MAX_HOST_COPY) |
CPU-time DoS from large valid copies; host stack overflow | Negligible | Legitimate large data transfers must be chunked |
Failure Modes
| Failure | Root Cause | Observable Symptom | Consequence |
|---|---|---|---|
| Host API forgets to validate guest offset | Developer assumes WASM runtime will bounds-check host API calls | No error at write time; corrupted linear memory region | Attacker writes to function table or security state; within-sandbox control flow hijacking |
Host API forgets checked_add on offset + len |
offset + len wraps around usize (e.g., offset=0xfffffffc, len=8 → wraps to 4) |
Write succeeds to wrong region; no error raised | Silent memory corruption; corrupted data structures without observable crash |
Guest-controlled length passed to alloca in C host |
Developer uses alloca(guest_len) or VLA without capping |
Host process stack overflow; segfault or silent corruption below the stack frame | Host process compromise; WASM sandbox escape |
WASM module bypasses mutex with i32.atomic.store |
Guest uses direct atomic store to a shared length field rather than going through mutex | Mutex is held by Thread A, but Thread B writes the length field anyway | TOCTOU succeeds; OOB copy within linear memory |
| Spectre mitigation disabled after runtime upgrade | New Wasmtime version changed flag name or default; deployment scripts not updated | No functional breakage; Spectre PoC succeeds in penetration test | Cache-timing side channel; key material leakable at ~100–500 bytes/second |
| COOP/COEP headers missing after CDN config change | CDN strips or rewrites security headers; SharedArrayBuffer silently disabled |
Browser console shows SharedArrayBuffer is not defined; OR headers present but incorrect, leaving Spectre window open |
Cross-origin Spectre reachable; or thread-dependent workload silently broken |
| Component model not used; modules share linear memory | Platform uses legacy module composition (import/export of memory) rather than component model | Cross-module memory.copy speculation is architecturally possible |
Spectre gadget in one module can speculatively read another module’s linear memory |
wasm-smith fuzzer not run after bulk memory changes |
Fuzz corpus not refreshed when memory layout changes | Boundary condition bugs not caught in CI | OOB bug ships to production |
| Wasmtime pool allocator not used; linear memories adjacent | Default allocator places instances with adjacent backing stores | Spectre cross-instance timing succeeds | Tenant isolation failure in multi-tenant runtime |