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
spto some number of bytes abovefpbut then afterwards it loads fromfpwhich means that a load is performed on something beneathsp. 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
Commit: 249bdf8fca (main2), audited 2026-10-02. Introduced by c46ba02162
"winch: support tail calls with callee cleanup" (#14349).Platform: Linux 7.0.0-31-generic x86_64 host, qemu-aarch64 9.1.2
- Auditor: Claude Opus 5.5 (claude-opus-5-5); repro re-verified by coordinator
Severity: medium-low. Winch on aarch64, default-enabled tail calls. Needs an
async signal (withoutSA_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.rsfinish_tail_call_empty
(~513-547), theTailCallFrameKind::EmptyCalleepath (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
addand
thelduroverwrites 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
SIGALRMhandler (noSA_ONSTACK),
100ussetitimer, 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)thenuncaught 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) beforeadd sp, ..., as the x64 code
andwith_tail_call_resizealready 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
alexcrichton added the winch label to Issue #14503.
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
spto some number of bytes abovefpbut then afterwards it loads fromfpwhich means that a load is performed on something beneathsp. 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
Commit: 249bdf8fca (main2), audited 2026-10-02. Introduced by c46ba02162
"winch: support tail calls with callee cleanup" (#14349).Platform: Linux 7.0.0-31-generic x86_64 host, qemu-aarch64 9.1.2
- Auditor: Claude Opus 5.5 (claude-opus-5-5); repro re-verified by coordinator
Severity: medium-low. Winch on aarch64, default-enabled tail calls. Needs an
async signal (withoutSA_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.rsfinish_tail_call_empty
(~513-547), theTailCallFrameKind::EmptyCalleepath (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
addand
thelduroverwrites 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
SIGALRMhandler (noSA_ONSTACK),
100ussetitimer, 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)thenuncaught 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) beforeadd sp, ..., as the x64 code
andwith_tail_call_resizealready 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