alexcrichton opened issue #13316:
This test:
#[wasmtime_test(wasm_features(exceptions))] #[cfg_attr(miri, ignore)] fn store_pending_exception_is_rooted(config: &mut Config) -> wasmtime::Result<()> { let engine = Engine::new(&config)?; let mut store = Store::new(&engine, ()); let module = Module::new( &engine, r#" (module (import "h" "t1" (tag $t1 (param i32))) (import "h" "throw_t1" (func $throw_t1)) (func (export "run") (result i32) (block $h (result i32) (try_table (result i32) (catch $t1 $h) call $throw_t1 unreachable ) ) ) ) "#, )?; let functy = FuncType::new(&engine, [ValType::I32], []); let tagty = TagType::new(functy); let t1 = Tag::new(&mut store, &tagty)?; let exnty = ExnType::from_tag_type(&tagty)?; let exnpre_for_t1 = ExnRefPre::new(&mut store, exnty); let throw_t1 = Func::wrap( &mut store, move |mut caller: Caller<'_, ()>| -> Result<()> { let err = { let mut scope = RootScope::new(&mut caller); let exn = ExnRef::new(&mut scope, &exnpre_for_t1, &t1, &[Val::I32(0x1111_1111)])?; scope.as_context_mut().throw::<()>(exn) }; caller.as_context_mut().gc(None)?; err.map_err(|e| e.into()) }, ); let instance = Instance::new( &mut store, &module, &[Extern::Tag(t1), Extern::Func(throw_t1)], )?; let run = instance.get_typed_func::<(), i32>(&mut store, "run")?; let result = run.call(&mut store, ())?; assert_eq!(result, 0x1111_1111); Ok(()) }currently fails with:
$ cargo test --test all store_pending_exc Compiling wasmtime-cli v46.0.0 (/home/alex/code/wasmtime) Finished `test` profile [unoptimized + debuginfo] target(s) in 2.41s Running tests/all/main.rs (target/debug/deps/all-5d784fe606f0e513) running 3 tests test exceptions::winch_store_pending_exception_is_rooted ... ok thread 'exceptions::craneliftpulley_store_pending_exception_is_rooted' (2812196) panicked at crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:709:9: assertion failed: self.ref_count > 0 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace thread 'exceptions::craneliftpulley_store_pending_exception_is_rooted' (2812196) panicked at /rustc/59807616e1fa2540724bfbac14d7976d7e4a3860/library/core/src/panicking.rs:225:5: panic in a function that cannot unwind stack backtrace: ... big backtrace here ... thread caused non-unwinding panic. aborting. error: test failed, to rerun pass `--test all` Caused by: process didn't exit successfully: `/home/alex/code/wasmtime/target/debug/deps/all-5d784fe606f0e513 store_pending_exc` (signal: 6, SIGABRT: process abort signal)An LLM-generated summary (possibly wrong) is:
<details>
Pending exception's
VMGcRefis not reference-counted nor traced as a strong root, causing use-after-free of the throw payload across a host-triggered GCScope:
crates/wasmtime/src/runtime/store.rs:2747-2754
(StoreOpaque::set_pending_exception: plain assignment, noinc_ref).
crates/wasmtime/src/runtime/store.rs:2348-2356
(StoreOpaque::trace_pending_exception_roots: registers the slot via
add_vmgcref_root).
crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:425-475
(DrcHeap::trace: explicitly skips every root for which
!root.is_on_wasm_stack(), so the pending-exception root never
marks its referent alive).
crates/wasmtime/src/runtime/gc/enabled/exnref.rs:419-427
(ExnRef::_to_raw:try_clone_gc_refthenexpose_gc_ref_to_wasm;
the+1ref count from the clone is consumed entirely by the OASR
list, leaving the pending slot as a borrowed view).
crates/wasmtime/src/runtime/vm/throw.rs:21-26
(compute_handlerreads(instance_id, defined_tag_index)from the
pending exnref after the embedder may have GC'd that slot).Severity: **Use-after-free of a GC-heap exception object reachable
through safe public APIs.** With the default-ongcCargo feature (DRC
collector),Config::wasm_exceptions(true), plus no debug feature
and no async, the embedder can use only safewasmtime::*APIs to:
- Allocate an exn
X1,Store::throw(X1),Drop
X1's last LIFO root (e.g., by callingthrowfrom inside a
RootScope),Trigger an explicit GC via
Store::gc()— this dec_refs the OASR
entry and dealloc'sX1's heap slot,Allocate a new exn
X2of the same size — theFreeListhands
back the very slotX1lived in,Return
Err(ThrownException).The runtime's
compute_handlerthen reads the
(instance_id, defined_tag_index)fromX2's bytes and uses them to
search the wasm stack for a matchingtry_tableclause. The
embedder controls every byte ofX2, including the tag-identity
header that determines which wasm catch clause runs (or whether
the "thrown" exception escapes the supposedly-catchingtry_table
entirely). This is a clean primitive for forcing wasm to run a
handler for a tag that was never actually thrown (or to fail to run
a handler for a tag that was thrown).This bug is distinct from reports 003 / 004 / 010:
003 / 010 are about the pending-exception slot's contents being
manipulated through additionalStore::throw/take_pending_exception
calls; they require thedebugfeature.004 is about the slot being emptied during a debug pre-pass.
- This bug requires neither
debugnorasyncand no second
Store::throw. It is purely a memory-safety consequence of
pending_exceptionbeing a borrowedVMGcRefrather than an
owned/reference-counted slot, combined with DRC'strace
explicitly skipping non-stack roots.Summary
In the DRC collector, every reference-typed slot that holds a GC
object is expected to "own" a+1on the object's reference count
(or to be live-traced as a stack root). For example:
The user LIFO root list owns
+1per entry;exit_lifo_scope
dec_refs each entry's gc_ref before truncating the list.A wasm-stack value is added to the over-approximated stack-roots
(OASR) list with+1and is then "marked" during the GC trace
phase by walking the stack maps; the OASR sweep keeps the entry
if it was marked.Globals and table elements are stored via
write_gc_ref, which
inc_refs the source and dec_refs the destination.The pending-exception slot does not follow either contract:
// crates/wasmtime/src/runtime/store.rs:2747-2754 #[cfg(feature = "gc")] pub(crate) fn set_pending_exception(&mut self, exnref: VMExnRef) { self.pending_exception = Some(exnref); }Plain assignment; no
inc_ref, nowrite_gc_ref. The slot does not
hold its own+1. It is added as a root for tracing
(trace_pending_exception_roots,store.rs:2348-2356), but only via
add_vmgcref_root, which DRC'straceignores:// crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:436-444 for root in roots { if !root.is_on_wasm_stack() { // We only trace on-Wasm-stack GC roots. ... continue; } ... self.index_mut(drc_ref(&gc_ref)).set_marked(); }Only
is_on_wasm_stack()roots get marked; everything added via
add_vmgcref_root(LIFO/Owned user roots, the pending-exception
slot, globals, table elements) is treated as if it were responsible
for holding its own+1. The pending-exception slot does not.The two host-side throw paths interact with the OASR list as follows:
Wasm
throw_reflibcall (vm/libcalls.rs::throw_ref): explicitly
clone_gc_ref(which inc_refs) beforeset_pending_exception. The
+1fromclone_gc_refbelongs to the slot. OK.Host
Store::throw(store.rs::throw_impl):
rust fn throw_impl(&mut self, exception: Rooted<ExnRef>) { let mut nogc = AutoAssertNoGc::new(self); let exnref = exception._to_raw(&mut nogc).unwrap(); // (a) let exnref = VMGcRef::from_raw_u32(exnref) .expect("exception cannot be null") .into_exnref_unchecked(); // (b) nogc.set_pending_exception(exnref); // (c) }At (a),
_to_rawcallstry_clone_gc_ref(inc_ref) and then
expose_gc_ref_to_wasm.expose_gc_ref_to_wasm(DRC)
consumes the clonedVMGcRefinto the OASR list — so the+1from
the clone is owned by the OASR entry. At (b), a freshVMGcRef
is reconstructed from the rawu32value (no inc). At (c), it is
stored as the pending exception (no inc). **Pending slot is now a
borrowed view of the heap object whose only refcount is held by
the OASR list.**The OASR list is dec_ref'd at the next GC sweep for any object whose
mark bit is not set. Since no marker walks the pending-exception
root, a sweep called between the host'sStore::throwand the
runtime's eventualcompute_handlerwill dealloc the underlying
slot (assuming no other refcount holders, e.g., once the user's
LIFO root is gone). The next allocation of the same size hits the
free list and returns the same slot, populated with attacker-chosen
bytes.Reproducer
reports/011-pending-exception-uaf-via-gc/is a self-contained
Cargo project (depends on thewasmtimecrate at
../../../wasmtime/crates/wasmtime, i.e. themainworktree).
Build and run:cd reports/011-pending-exception-uaf-via-gc cargo build --release ./target/release/repro ; echo "EXIT: $?"Observed output
error from go.call(): error while executing at wasm backtrace: 0: 0x49 - <unknown>!<wasm function 1> Caused by: thrown Wasm exception BUG REPRODUCED (error path): the runtime walked into a deallocated/recycled heap slot. EXIT: 1Expected output
The wasm `(try_table (catch $t
[message truncated]
alexcrichton added the wasm-proposal:gc label to Issue #13316.
alexcrichton added the wasm-proposal:exceptions label to Issue #13316.
cfallin commented on issue #13316:
Huh. I'm somewhat perplexed as we have this function that traces the pending-exception root; but from the LLM-generated summary it sounds like the DRC collector doesn't actually do anything in
add_vmgcref_root()?? I'll dig into this more...
alexcrichton commented on issue #13316:
There's a claim that this is a distinct issue, but by my read it's basically the same problem. Probably worthwhile to test a potential fix against though to double-check.
cfallin commented on issue #13316:
(Just as a logistical note, I have a completely solid day of meetings today, and then a long weekend Fri/Mon, so I will get to this Tuesday afternoon at earliest)
alexcrichton commented on issue #13316:
I ended up debugging this a bit and came up with https://github.com/bytecodealliance/wasmtime/pull/13330 -- not a lack of rooting but improper barriers applied.
cfallin commented on issue #13316:
Ah, thanks for tracking that down!
fitzgen closed issue #13316:
This test:
#[wasmtime_test(wasm_features(exceptions))] #[cfg_attr(miri, ignore)] fn store_pending_exception_is_rooted(config: &mut Config) -> wasmtime::Result<()> { let engine = Engine::new(&config)?; let mut store = Store::new(&engine, ()); let module = Module::new( &engine, r#" (module (import "h" "t1" (tag $t1 (param i32))) (import "h" "throw_t1" (func $throw_t1)) (func (export "run") (result i32) (block $h (result i32) (try_table (result i32) (catch $t1 $h) call $throw_t1 unreachable ) ) ) ) "#, )?; let functy = FuncType::new(&engine, [ValType::I32], []); let tagty = TagType::new(functy); let t1 = Tag::new(&mut store, &tagty)?; let exnty = ExnType::from_tag_type(&tagty)?; let exnpre_for_t1 = ExnRefPre::new(&mut store, exnty); let throw_t1 = Func::wrap( &mut store, move |mut caller: Caller<'_, ()>| -> Result<()> { let err = { let mut scope = RootScope::new(&mut caller); let exn = ExnRef::new(&mut scope, &exnpre_for_t1, &t1, &[Val::I32(0x1111_1111)])?; scope.as_context_mut().throw::<()>(exn) }; caller.as_context_mut().gc(None)?; err.map_err(|e| e.into()) }, ); let instance = Instance::new( &mut store, &module, &[Extern::Tag(t1), Extern::Func(throw_t1)], )?; let run = instance.get_typed_func::<(), i32>(&mut store, "run")?; let result = run.call(&mut store, ())?; assert_eq!(result, 0x1111_1111); Ok(()) }currently fails with:
$ cargo test --test all store_pending_exc Compiling wasmtime-cli v46.0.0 (/home/alex/code/wasmtime) Finished `test` profile [unoptimized + debuginfo] target(s) in 2.41s Running tests/all/main.rs (target/debug/deps/all-5d784fe606f0e513) running 3 tests test exceptions::winch_store_pending_exception_is_rooted ... ok thread 'exceptions::craneliftpulley_store_pending_exception_is_rooted' (2812196) panicked at crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:709:9: assertion failed: self.ref_count > 0 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace thread 'exceptions::craneliftpulley_store_pending_exception_is_rooted' (2812196) panicked at /rustc/59807616e1fa2540724bfbac14d7976d7e4a3860/library/core/src/panicking.rs:225:5: panic in a function that cannot unwind stack backtrace: ... big backtrace here ... thread caused non-unwinding panic. aborting. error: test failed, to rerun pass `--test all` Caused by: process didn't exit successfully: `/home/alex/code/wasmtime/target/debug/deps/all-5d784fe606f0e513 store_pending_exc` (signal: 6, SIGABRT: process abort signal)An LLM-generated summary (possibly wrong) is:
<details>
Pending exception's
VMGcRefis not reference-counted nor traced as a strong root, causing use-after-free of the throw payload across a host-triggered GCScope:
crates/wasmtime/src/runtime/store.rs:2747-2754
(StoreOpaque::set_pending_exception: plain assignment, noinc_ref).
crates/wasmtime/src/runtime/store.rs:2348-2356
(StoreOpaque::trace_pending_exception_roots: registers the slot via
add_vmgcref_root).
crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:425-475
(DrcHeap::trace: explicitly skips every root for which
!root.is_on_wasm_stack(), so the pending-exception root never
marks its referent alive).
crates/wasmtime/src/runtime/gc/enabled/exnref.rs:419-427
(ExnRef::_to_raw:try_clone_gc_refthenexpose_gc_ref_to_wasm;
the+1ref count from the clone is consumed entirely by the OASR
list, leaving the pending slot as a borrowed view).
crates/wasmtime/src/runtime/vm/throw.rs:21-26
(compute_handlerreads(instance_id, defined_tag_index)from the
pending exnref after the embedder may have GC'd that slot).Severity: **Use-after-free of a GC-heap exception object reachable
through safe public APIs.** With the default-ongcCargo feature (DRC
collector),Config::wasm_exceptions(true), plus no debug feature
and no async, the embedder can use only safewasmtime::*APIs to:
- Allocate an exn
X1,Store::throw(X1),Drop
X1's last LIFO root (e.g., by callingthrowfrom inside a
RootScope),Trigger an explicit GC via
Store::gc()— this dec_refs the OASR
entry and dealloc'sX1's heap slot,Allocate a new exn
X2of the same size — theFreeListhands
back the very slotX1lived in,Return
Err(ThrownException).The runtime's
compute_handlerthen reads the
(instance_id, defined_tag_index)fromX2's bytes and uses them to
search the wasm stack for a matchingtry_tableclause. The
embedder controls every byte ofX2, including the tag-identity
header that determines which wasm catch clause runs (or whether
the "thrown" exception escapes the supposedly-catchingtry_table
entirely). This is a clean primitive for forcing wasm to run a
handler for a tag that was never actually thrown (or to fail to run
a handler for a tag that was thrown).This bug is distinct from reports 003 / 004 / 010:
003 / 010 are about the pending-exception slot's contents being
manipulated through additionalStore::throw/take_pending_exception
calls; they require thedebugfeature.004 is about the slot being emptied during a debug pre-pass.
- This bug requires neither
debugnorasyncand no second
Store::throw. It is purely a memory-safety consequence of
pending_exceptionbeing a borrowedVMGcRefrather than an
owned/reference-counted slot, combined with DRC'strace
explicitly skipping non-stack roots.Summary
In the DRC collector, every reference-typed slot that holds a GC
object is expected to "own" a+1on the object's reference count
(or to be live-traced as a stack root). For example:
The user LIFO root list owns
+1per entry;exit_lifo_scope
dec_refs each entry's gc_ref before truncating the list.A wasm-stack value is added to the over-approximated stack-roots
(OASR) list with+1and is then "marked" during the GC trace
phase by walking the stack maps; the OASR sweep keeps the entry
if it was marked.Globals and table elements are stored via
write_gc_ref, which
inc_refs the source and dec_refs the destination.The pending-exception slot does not follow either contract:
// crates/wasmtime/src/runtime/store.rs:2747-2754 #[cfg(feature = "gc")] pub(crate) fn set_pending_exception(&mut self, exnref: VMExnRef) { self.pending_exception = Some(exnref); }Plain assignment; no
inc_ref, nowrite_gc_ref. The slot does not
hold its own+1. It is added as a root for tracing
(trace_pending_exception_roots,store.rs:2348-2356), but only via
add_vmgcref_root, which DRC'straceignores:// crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs:436-444 for root in roots { if !root.is_on_wasm_stack() { // We only trace on-Wasm-stack GC roots. ... continue; } ... self.index_mut(drc_ref(&gc_ref)).set_marked(); }Only
is_on_wasm_stack()roots get marked; everything added via
add_vmgcref_root(LIFO/Owned user roots, the pending-exception
slot, globals, table elements) is treated as if it were responsible
for holding its own+1. The pending-exception slot does not.The two host-side throw paths interact with the OASR list as follows:
Wasm
throw_reflibcall (vm/libcalls.rs::throw_ref): explicitly
clone_gc_ref(which inc_refs) beforeset_pending_exception. The
+1fromclone_gc_refbelongs to the slot. OK.Host
Store::throw(store.rs::throw_impl):
rust fn throw_impl(&mut self, exception: Rooted<ExnRef>) { let mut nogc = AutoAssertNoGc::new(self); let exnref = exception._to_raw(&mut nogc).unwrap(); // (a) let exnref = VMGcRef::from_raw_u32(exnref) .expect("exception cannot be null") .into_exnref_unchecked(); // (b) nogc.set_pending_exception(exnref); // (c) }At (a),
_to_rawcallstry_clone_gc_ref(inc_ref) and then
expose_gc_ref_to_wasm.expose_gc_ref_to_wasm(DRC)
consumes the clonedVMGcRefinto the OASR list — so the+1from
the clone is owned by the OASR entry. At (b), a freshVMGcRef
is reconstructed from the rawu32value (no inc). At (c), it is
stored as the pending exception (no inc). **Pending slot is now a
borrowed view of the heap object whose only refcount is held by
the OASR list.**The OASR list is dec_ref'd at the next GC sweep for any object whose
mark bit is not set. Since no marker walks the pending-exception
root, a sweep called between the host'sStore::throwand the
runtime's eventualcompute_handlerwill dealloc the underlying
slot (assuming no other refcount holders, e.g., once the user's
LIFO root is gone). The next allocation of the same size hits the
free list and returns the same slot, populated with attacker-chosen
bytes.Reproducer
reports/011-pending-exception-uaf-via-gc/is a self-contained
Cargo project (depends on thewasmtimecrate at
../../../wasmtime/crates/wasmtime, i.e. themainworktree).
Build and run:cd reports/011-pending-exception-uaf-via-gc cargo build --release ./target/release/repro ; echo "EXIT: $?"Observed output
error from go.call(): error while executing at wasm backtrace: 0: 0x49 - <unknown>!<wasm function 1> Caused by: thrown Wasm exception BUG REPRODUCED (error path): the runtime walked into a deallocated/recycled heap slot. EXIT: 1Expected output
The wasm `(try_table (catch $t1 ...
[message truncated]
Last updated: Jun 01 2026 at 09:49 UTC