Stream: git-wasmtime

Topic: wasmtime / issue #14503 Winch: tail calls on aarch64 may ...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 02 2026 at 21:28):

alexcrichton opened issue #14503:

The code generation here -- https://github.com/bytecodealliance/wasmtime/blob/3be934572ba647c8c925085480ae5670b6faa81f/winch/codegen/src/isa/aarch64/masm.rs#L528-L546 -- bumps sp to some number of bytes above fp but then afterwards it loads from fp which means that a load is performed on something beneath sp. Signal handlers and such can corrupt this region, however, so this is in theory a window for corruption of the stack.

If helpful, an LLM report is

<details>

Winch aarch64: tail call to a no-stack-arg callee reloads FP from below SP

Severity: medium-low. Winch on aarch64, default-enabled tail calls. Needs an
async signal (without SA_ONSTACK) to land in a 1-instruction window, but
the result is FP corruption that Winch (FP-relative stack args) and
Wasmtime's stack walking (traps, GC roots, exceptions) then follow.

Root cause

winch/codegen/src/isa/aarch64/masm.rs finish_tail_call_empty
(~513-547), the TailCallFrameKind::EmptyCallee path (tail-caller has stack
args, callee has none), emits (wasmtime objdump, coordinator-verified):

ldur x28, [x29, #-0x10]
ldur x30, [x29, #8]
add  sp, x29, #0x40
ldur x29, [x29]        ; reads 0x40 bytes *below* the new SP
b    <callee>

AArch64 Linux has no red zone: a signal frame pushed between the add and
the ldur overwrites the saved-FP slot. The callee saves/restores the
garbage FP and the caller's caller resumes with a corrupted FP. x64 and the
resize path in the same commit load the old frame before moving SP
("finish all old-frame reads before advancing SP").

Repro

Rust crate in this directory: no-op SIGALRM handler (no SA_ONSTACK),
100us setitimer, loops a Winch function calling $tc (12 i32 params) which
return_calls $leaf (1 param).

CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
CARGO_TARGET_DIR=<repo>/target/audit-repro \
  cargo build --release --target aarch64-unknown-linux-gnu
qemu-aarch64 -one-insn-per-tb -cpu max,sve-default-vector-length=256 \
  -L /usr/aarch64-linux-gnu <bin>
config result
Winch + signals round 0: ok (6221 signals) then uncaught target signal 11 (Segmentation fault) (3/3 runs incl. coordinator)
Cranelift + signals (CRANELIFT=1) 10M calls OK
Winch, no signals (NOSIG=1) 10M calls OK
Winch + signals, default qemu CPU (small SVE) no crash (signal frame too small to reach slot)

Faulting PC not captured; conclusion rests on disassembly + controls.
Darwin/Windows aarch64 write signal/exception context directly below SP
(Darwin 128-byte red zone), so callers with >~112 bytes of stack args are
likely exposed there too (untested).

Suggested fix

Load [x29] (into x29 or a scratch) before add sp, ..., as the x64 code
and with_tail_call_resize already do.

</details>

and an (unsuccessful so far I believe) theoretical repro is:

<details>

//! Winch/aarch64: `return_call` to a callee with no stack arguments, from a
//! caller that has stack arguments, moves SP above the saved frame pointer
//! before reloading it (`add sp, fp, #N ; ldr fp, [fp]`). An asynchronous
//! signal delivered between those two instructions clobbers the saved FP.
//!
//! Run on aarch64 (e.g. `qemu-aarch64 -one-insn-per-tb`). See report.md.
use std::sync::atomic::{AtomicU64, Ordering};
use wasmtime::*;

static SIGNALS: AtomicU64 = AtomicU64::new(0);

extern "C" fn on_alarm(_: libc::c_int) {
    SIGNALS.fetch_add(1, Ordering::Relaxed);
}

const WAT: &str = r#"
(module
  ;; No stack arguments: only vmctx x2 + one i32 in registers.
  (func $leaf (param i32) (result i32)
    local.get 0)
  ;; 12 i32 params + 2 vmctx = 14 integer args > 8 registers, so this
  ;; function has stack arguments; tail calling $leaf takes the
  ;; "EmptyCallee" path.
  (func $tc (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)
    local.get 0
    return_call $leaf)
  ;; `run` itself also has stack arguments, which Winch reads through FP.
  (func (export "run") (param $n i32) (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
                       (result i32)
    (local $sum i32)
    loop
      ;; local 12 is a stack argument of `run`, addressed relative to FP.
      local.get 12
      i32.const 1 i32.const 2 i32.const 3 i32.const 4 i32.const 5 i32.const 6
      i32.const 7 i32.const 8 i32.const 9 i32.const 10 i32.const 11
      call $tc
      local.get $sum i32.add local.set $sum
      local.get $n i32.const 1 i32.sub local.tee $n
      br_if 0
    end
    local.get $sum)
)
"#;

fn main() -> Result<()> {
    // Controls: `NOSIG=1` disables the timer signal, `CRANELIFT=1` uses
    // Cranelift instead of Winch.
    if std::env::var_os("NOSIG").is_none() { unsafe {
        let mut sa: libc::sigaction = std::mem::zeroed();
        sa.sa_sigaction = on_alarm as usize;
        sa.sa_flags = libc::SA_RESTART; // NB: no SA_ONSTACK
        libc::sigemptyset(&mut sa.sa_mask);
        assert_eq!(libc::sigaction(libc::SIGALRM, &sa, std::ptr::null_mut()), 0);
        let it = libc::itimerval {
            it_interval: libc::timeval { tv_sec: 0, tv_usec: 100 },
            it_value: libc::timeval { tv_sec: 0, tv_usec: 100 },
        };
        assert_eq!(libc::setitimer(libc::ITIMER_REAL, &it, std::ptr::null_mut()), 0);
    } }

    let mut config = Config::new();
    config.strategy(if std::env::var_os("CRANELIFT").is_some() {
        Strategy::Cranelift
    } else {
        Strategy::Winch
    });
    let engine = Engine::new(&config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;
    let run = instance.get_typed_func::<
        (i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32),
        i32,
    >(&mut store, "run")?;
    let iters = 2000;
    for round in 0.. {
        let got = run.call(&mut store, (iters, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 7))?;
        let want = iters * 7;
        if got != want {
            println!(
                "round {round}: MISMATCH got {got} want {want} (signals so far: {})",
                SIGNALS.load(Ordering::Relaxed)
            );
            std::process::exit(1);
        }
        if round % 100 == 0 {
            println!("round {round}: ok ({} signals)", SIGNALS.load(Ordering::Relaxed));
        }
        if round == 5000 {
            break;
        }
    }
    Ok(())
}

</details>

cc @macovedj @saulecabrera

view this post on Zulip Wasmtime GitHub notifications bot (Oct 02 2026 at 21:28):

alexcrichton added the winch label to Issue #14503.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 05 2026 at 16:32):

saulecabrera closed issue #14503:

The code generation here -- https://github.com/bytecodealliance/wasmtime/blob/3be934572ba647c8c925085480ae5670b6faa81f/winch/codegen/src/isa/aarch64/masm.rs#L528-L546 -- bumps sp to some number of bytes above fp but then afterwards it loads from fp which means that a load is performed on something beneath sp. Signal handlers and such can corrupt this region, however, so this is in theory a window for corruption of the stack.

If helpful, an LLM report is

<details>

Winch aarch64: tail call to a no-stack-arg callee reloads FP from below SP

Severity: medium-low. Winch on aarch64, default-enabled tail calls. Needs an
async signal (without SA_ONSTACK) to land in a 1-instruction window, but
the result is FP corruption that Winch (FP-relative stack args) and
Wasmtime's stack walking (traps, GC roots, exceptions) then follow.

Root cause

winch/codegen/src/isa/aarch64/masm.rs finish_tail_call_empty
(~513-547), the TailCallFrameKind::EmptyCallee path (tail-caller has stack
args, callee has none), emits (wasmtime objdump, coordinator-verified):

ldur x28, [x29, #-0x10]
ldur x30, [x29, #8]
add  sp, x29, #0x40
ldur x29, [x29]        ; reads 0x40 bytes *below* the new SP
b    <callee>

AArch64 Linux has no red zone: a signal frame pushed between the add and
the ldur overwrites the saved-FP slot. The callee saves/restores the
garbage FP and the caller's caller resumes with a corrupted FP. x64 and the
resize path in the same commit load the old frame before moving SP
("finish all old-frame reads before advancing SP").

Repro

Rust crate in this directory: no-op SIGALRM handler (no SA_ONSTACK),
100us setitimer, loops a Winch function calling $tc (12 i32 params) which
return_calls $leaf (1 param).

CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
CARGO_TARGET_DIR=<repo>/target/audit-repro \
  cargo build --release --target aarch64-unknown-linux-gnu
qemu-aarch64 -one-insn-per-tb -cpu max,sve-default-vector-length=256 \
  -L /usr/aarch64-linux-gnu <bin>
config result
Winch + signals round 0: ok (6221 signals) then uncaught target signal 11 (Segmentation fault) (3/3 runs incl. coordinator)
Cranelift + signals (CRANELIFT=1) 10M calls OK
Winch, no signals (NOSIG=1) 10M calls OK
Winch + signals, default qemu CPU (small SVE) no crash (signal frame too small to reach slot)

Faulting PC not captured; conclusion rests on disassembly + controls.
Darwin/Windows aarch64 write signal/exception context directly below SP
(Darwin 128-byte red zone), so callers with >~112 bytes of stack args are
likely exposed there too (untested).

Suggested fix

Load [x29] (into x29 or a scratch) before add sp, ..., as the x64 code
and with_tail_call_resize already do.

</details>

and an (unsuccessful so far I believe) theoretical repro is:

<details>

//! Winch/aarch64: `return_call` to a callee with no stack arguments, from a
//! caller that has stack arguments, moves SP above the saved frame pointer
//! before reloading it (`add sp, fp, #N ; ldr fp, [fp]`). An asynchronous
//! signal delivered between those two instructions clobbers the saved FP.
//!
//! Run on aarch64 (e.g. `qemu-aarch64 -one-insn-per-tb`). See report.md.
use std::sync::atomic::{AtomicU64, Ordering};
use wasmtime::*;

static SIGNALS: AtomicU64 = AtomicU64::new(0);

extern "C" fn on_alarm(_: libc::c_int) {
    SIGNALS.fetch_add(1, Ordering::Relaxed);
}

const WAT: &str = r#"
(module
  ;; No stack arguments: only vmctx x2 + one i32 in registers.
  (func $leaf (param i32) (result i32)
    local.get 0)
  ;; 12 i32 params + 2 vmctx = 14 integer args > 8 registers, so this
  ;; function has stack arguments; tail calling $leaf takes the
  ;; "EmptyCallee" path.
  (func $tc (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)
    local.get 0
    return_call $leaf)
  ;; `run` itself also has stack arguments, which Winch reads through FP.
  (func (export "run") (param $n i32) (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
                       (result i32)
    (local $sum i32)
    loop
      ;; local 12 is a stack argument of `run`, addressed relative to FP.
      local.get 12
      i32.const 1 i32.const 2 i32.const 3 i32.const 4 i32.const 5 i32.const 6
      i32.const 7 i32.const 8 i32.const 9 i32.const 10 i32.const 11
      call $tc
      local.get $sum i32.add local.set $sum
      local.get $n i32.const 1 i32.sub local.tee $n
      br_if 0
    end
    local.get $sum)
)
"#;

fn main() -> Result<()> {
    // Controls: `NOSIG=1` disables the timer signal, `CRANELIFT=1` uses
    // Cranelift instead of Winch.
    if std::env::var_os("NOSIG").is_none() { unsafe {
        let mut sa: libc::sigaction = std::mem::zeroed();
        sa.sa_sigaction = on_alarm as usize;
        sa.sa_flags = libc::SA_RESTART; // NB: no SA_ONSTACK
        libc::sigemptyset(&mut sa.sa_mask);
        assert_eq!(libc::sigaction(libc::SIGALRM, &sa, std::ptr::null_mut()), 0);
        let it = libc::itimerval {
            it_interval: libc::timeval { tv_sec: 0, tv_usec: 100 },
            it_value: libc::timeval { tv_sec: 0, tv_usec: 100 },
        };
        assert_eq!(libc::setitimer(libc::ITIMER_REAL, &it, std::ptr::null_mut()), 0);
    } }

    let mut config = Config::new();
    config.strategy(if std::env::var_os("CRANELIFT").is_some() {
        Strategy::Cranelift
    } else {
        Strategy::Winch
    });
    let engine = Engine::new(&config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;
    let run = instance.get_typed_func::<
        (i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32),
        i32,
    >(&mut store, "run")?;
    let iters = 2000;
    for round in 0.. {
        let got = run.call(&mut store, (iters, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 7))?;
        let want = iters * 7;
        if got != want {
            println!(
                "round {round}: MISMATCH got {got} want {want} (signals so far: {})",
                SIGNALS.load(Ordering::Relaxed)
            );
            std::process::exit(1);
        }
        if round % 100 == 0 {
            println!("round {round}: ok ({} signals)", SIGNALS.load(Ordering::Relaxed));
        }
        if round == 5000 {
            break;
        }
    }
    Ok(())
}

</details>

cc @macovedj @saulecabrera


Last updated: Oct 11 2026 at 04:10 UTC