Stream: git-wasmtime

Topic: wasmtime / issue #13316 DRC collector isn't walking exnre...


view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 05:09):

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 VMGcRef is not reference-counted nor traced as a strong root, causing use-after-free of the throw payload across a host-triggered GC

Scope:

Severity: **Use-after-free of a GC-heap exception object reachable
through safe public APIs.** With the default-on gc Cargo feature (DRC
collector), Config::wasm_exceptions(true), plus no debug feature
and no async, the embedder can use only safe wasmtime::* APIs to:

  1. Allocate an exn X1,
  2. Store::throw(X1),
  3. Drop X1's last LIFO root (e.g., by calling throw from inside a
    RootScope),

  4. Trigger an explicit GC via Store::gc() — this dec_refs the OASR
    entry and dealloc's X1's heap slot,

  5. Allocate a new exn X2 of the same size — the FreeList hands
    back the very slot X1 lived in,

  6. Return Err(ThrownException).

The runtime's compute_handler then reads the
(instance_id, defined_tag_index) from X2's bytes and uses them to
search the wasm stack for a matching try_table clause. The
embedder controls every byte of X2, including the tag-identity
header that determines which wasm catch clause runs (or whether
the "thrown" exception escapes the supposedly-catching try_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:

Summary

In the DRC collector, every reference-typed slot that holds a GC
object is expected to "own" a +1 on the object's reference count
(or to be live-traced as a stack root). For example:

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, no write_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's trace ignores:

// 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:

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's Store::throw and the
runtime's eventual compute_handler will 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 the wasmtime crate at
../../../wasmtime/crates/wasmtime, i.e. the main worktree).
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: 1

Expected output

The wasm `(try_table (catch $t
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 05:09):

alexcrichton added the wasm-proposal:gc label to Issue #13316.

view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 05:09):

alexcrichton added the wasm-proposal:exceptions label to Issue #13316.

view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 14:34):

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...

view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 14:52):

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.

view this post on Zulip Wasmtime GitHub notifications bot (May 07 2026 at 14:56):

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)

view this post on Zulip Wasmtime GitHub notifications bot (May 08 2026 at 20:38):

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.

view this post on Zulip Wasmtime GitHub notifications bot (May 12 2026 at 15:27):

cfallin commented on issue #13316:

Ah, thanks for tracking that down!

view this post on Zulip Wasmtime GitHub notifications bot (May 14 2026 at 16:46):

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 VMGcRef is not reference-counted nor traced as a strong root, causing use-after-free of the throw payload across a host-triggered GC

Scope:

Severity: **Use-after-free of a GC-heap exception object reachable
through safe public APIs.** With the default-on gc Cargo feature (DRC
collector), Config::wasm_exceptions(true), plus no debug feature
and no async, the embedder can use only safe wasmtime::* APIs to:

  1. Allocate an exn X1,
  2. Store::throw(X1),
  3. Drop X1's last LIFO root (e.g., by calling throw from inside a
    RootScope),

  4. Trigger an explicit GC via Store::gc() — this dec_refs the OASR
    entry and dealloc's X1's heap slot,

  5. Allocate a new exn X2 of the same size — the FreeList hands
    back the very slot X1 lived in,

  6. Return Err(ThrownException).

The runtime's compute_handler then reads the
(instance_id, defined_tag_index) from X2's bytes and uses them to
search the wasm stack for a matching try_table clause. The
embedder controls every byte of X2, including the tag-identity
header that determines which wasm catch clause runs (or whether
the "thrown" exception escapes the supposedly-catching try_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:

Summary

In the DRC collector, every reference-typed slot that holds a GC
object is expected to "own" a +1 on the object's reference count
(or to be live-traced as a stack root). For example:

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, no write_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's trace ignores:

// 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:

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's Store::throw and the
runtime's eventual compute_handler will 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 the wasmtime crate at
../../../wasmtime/crates/wasmtime, i.e. the main worktree).
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: 1

Expected output

The wasm `(try_table (catch $t1 ...
[message truncated]


Last updated: Jun 01 2026 at 09:49 UTC