Test Case
Inline Wasm (save as resume_gc_result_stack.wat). This exercises a continuation that returns a GC struct reference; the result is kept on the operand stack (not in a GC-typed local) across a GC safepoint.
(module
(type $S (struct (field $x i32)))
(type $A (array i64))
(type $ft (func (result (ref $S))))
(type $ct (cont $ft))
(func $target (result (ref $S))
(struct.new $S (i32.const 0x12345678)))
(elem declare func $target)
(func (export "run") (result i32)
(local $k (ref null $ct))
(local $i i32)
(local.set $k (cont.new $ct (ref.func $target)))
;; Continuation result is a GC reference, kept as an operand-stack SSA
;; value rather than stored in a GC-typed local.
(resume $ct (local.get $k))
;; Force frequent collections while the only live copy of the struct ref
;; is the unmarked SSA value returned from the continuation payload buffer.
(local.set $i (i32.const 0))
(loop $again
(drop (array.new_default $A (i32.const 256)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $again (i32.lt_u (local.get $i) (i32.const 2000))))
;; Expected 0x12345678. Vulnerable builds read 256 here: the length field
;; of one of the arrays allocated during GC pressure.
(struct.get $S $x))
)
Steps to Reproduce
- Build
wasmtime from main (tested on a binary built from commit 897aa00de; affected code is present at main HEAD 08c456e887).
- Run the module above with stack switching and GC enabled, forcing a collection on every allocation:
wasmtime run \
-W stack-switching=y \
-W exceptions=y \
-W function-references=y \
-W gc=y \
-C collector=copying \
-O gc-zeal-alloc-counter=1 \
--invoke run \
resume_gc_result_stack.wat
Expected Results
305419896 (0x12345678) — the value of the freshly created struct's $x field. The continuation result is still live on the operand stack, so its struct must not be collected.
Actual Results
Incorrect result: the program prints 256, which is the length of one of the array.new_default $A allocations made during the GC pressure loop — i.e. a stale read of a reused object layout. The live GC object was missed by root discovery, reclaimed, and its memory reused while the guest still held a reference (use-after-free-like behavior).
warning: using `--invoke` with a function that returns values is experimental and may break in the future
256
As a control, storing the same continuation result into a GC-typed local before the allocation loop ((local.set $s (resume $ct (local.get $k))) then (struct.get $S $x (local.get $s))) returns 305419896, confirming the failure is specific to the reference living as an unmarked operand-stack SSA value across the safepoint.
Versions and Environment
Wasmtime version or commit: Wasmtime 47.0.0 (binary built from commit 897aa00de, 2026-06-15); affected code present at main HEAD 08c456e8870b0b85566f9e9808b7a2f044eaa265.
Operating system: Linux 5.15.0-139-generic (Ubuntu 20.04-based)
Architecture: x86_64
Rust: rustc 1.96.0 (ac68faa20 2026-05-25)
Extra Info
- Root cause:
VMHostArrayRef::load_data_entries at crates/cranelift/src/func_environ/stack_switching/instructions.rs:370 loads continuation/tag return values from a payload buffer using only CLIF ir::Type and pushes them as ordinary SSA values without calling builder.declare_value_needs_stack_map. It is reached from translate_resume (:1596), translate_suspend (:1671), and translate_switch (:1911). declare_value_needs_stack_map is not called anywhere under crates/cranelift/src/func_environ/stack_switching/.
- Ordinary GC-producing ops do declare stack-map needs — e.g.
crates/cranelift/src/translate/code_translator.rs:239 calls declare_value_needs_stack_map for results whose Wasm type needs it (via FuncEnvironment::val_ty_needs_stack_map / heap_ty_needs_stack_map), so such references are surfaced to the collector at safepoints. The payload-load path loses the Wasm-level reference metadata and skips this.
- This extends a known, maintainer-documented limitation:
crates/wasmtime/src/runtime/store/gc.rs (trace_wasm_continuation_roots, around line 721) has a FIXME noting that continuation payload buffers are not generally traced and that, as a workaround, cont.bind-ing GC values is currently disallowed. That workaround covers cont.bind argument buffers but not GC values loaded into live SSA values by resume/suspend/switch, which is the path exercised here.
- Stack switching is experimental and opt-in (
-W stack-switching=y); default configurations are unaffected. The issue requires stack switching and Wasm GC enabled together with a precise collector such as copying or drc.
- Candidate fixes: (1) preserve Wasm value-type metadata through
load_data_entries (or pass a parallel needs_stack_map bitmap) and call builder.declare_value_needs_stack_map for every loaded non-i31 VM GC reference; or (2) as a stopgap matching the existing cont.bind workaround, reject GC payload/result types for resume/suspend/switch until those references are either traced directly from payload buffers or precisely represented in stack maps.
Test Case
Inline Wasm (save as
resume_gc_result_stack.wat). This exercises a continuation that returns a GC struct reference; the result is kept on the operand stack (not in a GC-typed local) across a GC safepoint.Steps to Reproduce
wasmtimefrommain(tested on a binary built from commit897aa00de; affected code is present atmainHEAD08c456e887).Expected Results
305419896(0x12345678) — the value of the freshly created struct's$xfield. The continuation result is still live on the operand stack, so its struct must not be collected.Actual Results
Incorrect result: the program prints
256, which is the length of one of thearray.new_default $Aallocations made during the GC pressure loop — i.e. a stale read of a reused object layout. The live GC object was missed by root discovery, reclaimed, and its memory reused while the guest still held a reference (use-after-free-like behavior).As a control, storing the same continuation result into a GC-typed local before the allocation loop (
(local.set $s (resume $ct (local.get $k)))then(struct.get $S $x (local.get $s))) returns305419896, confirming the failure is specific to the reference living as an unmarked operand-stack SSA value across the safepoint.Versions and Environment
Wasmtime version or commit: Wasmtime 47.0.0 (binary built from commit
897aa00de, 2026-06-15); affected code present atmainHEAD08c456e8870b0b85566f9e9808b7a2f044eaa265.Operating system: Linux 5.15.0-139-generic (Ubuntu 20.04-based)
Architecture: x86_64
Rust: rustc 1.96.0 (ac68faa20 2026-05-25)
Extra Info
VMHostArrayRef::load_data_entriesatcrates/cranelift/src/func_environ/stack_switching/instructions.rs:370loads continuation/tag return values from a payload buffer using only CLIFir::Typeand pushes them as ordinary SSA values without callingbuilder.declare_value_needs_stack_map. It is reached fromtranslate_resume(:1596),translate_suspend(:1671), andtranslate_switch(:1911).declare_value_needs_stack_mapis not called anywhere undercrates/cranelift/src/func_environ/stack_switching/.crates/cranelift/src/translate/code_translator.rs:239callsdeclare_value_needs_stack_mapfor results whose Wasm type needs it (viaFuncEnvironment::val_ty_needs_stack_map/heap_ty_needs_stack_map), so such references are surfaced to the collector at safepoints. The payload-load path loses the Wasm-level reference metadata and skips this.crates/wasmtime/src/runtime/store/gc.rs(trace_wasm_continuation_roots, around line 721) has a FIXME noting that continuation payload buffers are not generally traced and that, as a workaround,cont.bind-ing GC values is currently disallowed. That workaround coverscont.bindargument buffers but not GC values loaded into live SSA values byresume/suspend/switch, which is the path exercised here.-W stack-switching=y); default configurations are unaffected. The issue requires stack switching and Wasm GC enabled together with a precise collector such ascopyingordrc.load_data_entries(or pass a parallelneeds_stack_mapbitmap) and callbuilder.declare_value_needs_stack_mapfor every loaded non-i31VM GC reference; or (2) as a stopgap matching the existingcont.bindworkaround, reject GC payload/result types forresume/suspend/switchuntil those references are either traced directly from payload buffers or precisely represented in stack maps.