WASM Reference Types and Host Binding Security: Hardening externref and funcref
The Problem
Before reference types, WASM guest code interacted with host objects by passing integer indices. The guest held a number, and the host maintained a table mapping those indices to actual objects — file handles, database connections, socket descriptors. The pattern was clunky: every host call that needed a resource had to look up the integer in the host’s table, bounds-check it, and validate that the entry was still live. But the security properties were clear. The guest never held anything more than an opaque integer. Integer overflow was the primary concern. Misuse amounted to providing an out-of-range or recycled index; both conditions were detectable with straightforward bounds and liveness checks on the host side.
Reference types — part of the WebAssembly 2.0 proposal set, now standardised and enabled by default in Wasmtime, WasmEdge, V8, and SpiderMonkey — replace this pattern with a different model. The host creates a GC-managed externref value that wraps a host object, and passes it to the guest. The guest can store externref values in locals, globals, and tables. It can pass them back to host functions. It can compare two externref values for identity. What it cannot do is dereference them: the externref is genuinely opaque from the WASM instruction set’s perspective — there is no instruction to read the pointer out of an externref and cast it to a linear memory address. The security concern is not in the WASM instruction set; it is in the host binding layer that creates, accepts, and dispatches on externref values.
The same pattern applies to funcref, which allows WASM code to hold references to callable functions. A funcref can be stored in a function table and invoked later via call_indirect. The guest can receive a funcref from the host representing a callback, store it, and call it.
Three categories of vulnerability arise from insecure host bindings built on reference types:
Type confusion. The host creates externref values for multiple different resource categories — file handles, database connections, HTTP clients — and exposes host functions that accept externref as a parameter. If the binding layer does not validate what kind of resource a given externref wraps before using it, a guest that passes a file handle externref to a database host function causes the host to treat a file handle as a database connection. In C-based embedder code, this is a cast to the wrong pointer type; the outcome ranges from a crash to exploitable memory corruption depending on the struct layouts involved. In Rust-based embedders, the outcome depends on whether the wrong type is accessed through safe or unsafe code.
Use-after-free. A WASM component receives an externref representing a database connection during a request handler invocation. It stores the value in a global. The request handler returns; the host closes the database connection and drops the backing Rust value. On a subsequent invocation — possibly from a different request — the component calls a host function with the stale externref. If the host binding attempts to use the now-freed resource, the outcome is use-after-free: in a Rust host, this means accessing a dropped value, which safe Rust prevents only if the binding layer is written to check liveness before dereferencing. Unsafe code in the binding — or a C FFI layer beneath it — may simply dereference a dangling pointer.
Capability escalation. A multi-tenant WASM platform issues each tenant component an externref representing a read-only view of a shared data store. A host function that performs write operations accepts an externref parameter and assumes any reference it receives represents a writeable handle. If tenant A’s read-only externref is passed — via a shared table, a misconfigured composition, or a cross-component call — to the write-capable host function, the host treats a read-only handle as read-write and performs the mutation on behalf of a caller that should not have write access.
Target systems: any Wasmtime host embedder that passes externref values to guest modules; server-side WASM platforms using WasmEdge or Wazero; browser-based WASM applications that pass DOM element references via externref; WASM Component Model deployments using resource types that compile to externref at the core module level.
Threat Model
-
Type confusion via unsanitised
externrefparameters. A malicious WASM component receives anexternrefrepresenting a read-only file handle during a legitimate operation. It stores that reference in a global. It then calls a host function that is bound to accept a database connectionexternref— but the binding does not validate the type tag of the reference, only that it is non-null. The host binding casts the backing pointer to*mut DatabaseConnand calls.query(). The file handle struct is laid out differently from the database connection struct; the host reads garbage as a connection pointer and proceeds with undefined behaviour. -
Use-after-free via stale
externrefin module globals. A WASM component stores anexternrefin a mutable global. The host runtime ends the request lifecycle and drops all resources associated with that request, including the resource theexternrefwraps. On a subsequent invocation — triggered by a new request or a scheduled callback — the component retrieves the stale reference from its global and passes it to a host function. The host binding dereferences the backing value without checking whether it is still live. In a C FFI layer, this is a dangling pointer dereference. -
Cross-tenant reference leakage via shared tables. A multi-tenant platform hosts components from tenant A and tenant B in the same Wasmtime
Engine. Tenant A’s component stores anexternrefrepresenting A’s database connection in a WASM table. Due to a host composition bug, tenant B’s component gains access to the same table index — either because the host reuses table slot integers across tenants without clearing them, or because a host function that accepts a table index does not validate which tenant the table belongs to. Tenant B calls a host database function with A’sexternrefand reads or writes A’s data. -
funcrefprivilege escalation via table replacement. A WASM component is given afuncreftable pre-populated with low-privilege callback functions. The component is also given write access to that table via thetable.setinstruction. It replaces a low-privilegefuncrefat index N with afuncrefpointing to a host function — obtained through aref.funcin a module that was composed with higher-privilege exports — and then triggers code that calls index N viacall_indirect, now invoking the high-privilege function. -
externrefnull confused with valid handle. A host function that accepts anexternrefchecks only for null before using the reference. An attacker constructs a sequence of operations that causes a valid-looking non-nullexternrefto wrap a freed or semantically invalid resource — for example, by closing a handle through one code path while another path retains the reference. The null check passes; the subsequent dereference operates on an invalid object. -
Access level: Adversaries 1, 2, and 5 require only the ability to execute WASM code in the same runtime. Adversary 3 requires a host composition bug. Adversary 4 requires the guest to have both
table.setaccess and access to aref.funcfor a higher-privilege function. -
Objective: Escalate from read-only to read-write capability; access other tenants’ resources; dereference freed host objects; invoke high-privilege host functions through the
funcreftable.
Hardening Configuration
Step 1: Type-Tagged externref Handles
The foundational control is ensuring every externref the host issues carries an embedded type tag that the binding layer validates on every inbound call. Instead of passing a raw pointer wrapped in an externref, wrap the resource in a tagged handle struct.
use wasmtime::{ExternRef, StoreContextMut};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
enum HandleKind {
FileHandle = 0x46494C45,
DbConnection = 0x44424F4E,
HttpClient = 0x48545450,
}
struct TypedHandle<T> {
kind: HandleKind,
inner: T,
}
impl<T: 'static + Send + Sync> TypedHandle<T> {
fn new(kind: HandleKind, inner: T) -> Self {
Self { kind, inner }
}
fn into_externref(self) -> ExternRef {
ExternRef::new(self)
}
}
fn unwrap_db_connection<'a>(
ext: &'a ExternRef,
) -> Result<&'a TypedHandle<DbConn>, String> {
let handle = ext
.data()
.and_then(|d| d.downcast_ref::<TypedHandle<DbConn>>())
.ok_or_else(|| "externref is not a TypedHandle<DbConn>".to_string())?;
if handle.kind != HandleKind::DbConnection {
return Err(format!(
"type tag mismatch: expected DbConnection ({:#010x}), got {:#010x}",
HandleKind::DbConnection as u32,
handle.kind as u32,
));
}
Ok(handle)
}
Every host function that receives an externref calls the appropriate unwrap_* helper before touching the inner resource. A file handle externref passed to unwrap_db_connection fails at the downcast_ref step because the concrete type does not match — regardless of the tag — making this defence double-layered: Rust’s Any downcast is the primary check, and the tag is a secondary semantic check for cases where two different resource types share the same Rust struct layout.
Step 2: Reference Lifetime Tracking
The host must be able to mark an externref as invalid when its underlying resource is freed, and validate liveness before every dereference. Use a per-Store handle registry.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
struct HandleId(u64);
struct HandleEntry {
kind: HandleKind,
live: bool,
}
struct HandleRegistry {
entries: RwLock<HashMap<HandleId, HandleEntry>>,
next_id: std::sync::atomic::AtomicU64,
}
impl HandleRegistry {
fn new() -> Arc<Self> {
Arc::new(Self {
entries: RwLock::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(1),
})
}
fn register(&self, kind: HandleKind) -> HandleId {
let id = HandleId(
self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
);
self.entries
.write()
.unwrap()
.insert(id, HandleEntry { kind, live: true });
id
}
fn revoke(&self, id: HandleId) {
if let Some(entry) = self.entries.write().unwrap().get_mut(&id) {
entry.live = false;
}
}
fn validate(&self, id: HandleId, expected_kind: HandleKind) -> Result<(), String> {
let entries = self.entries.read().unwrap();
match entries.get(&id) {
None => Err(format!("handle {:?} not found in registry", id)),
Some(e) if !e.live => Err(format!("handle {:?} has been revoked (use-after-free)", id)),
Some(e) if e.kind != expected_kind => Err(format!(
"handle {:?} kind mismatch: expected {:?}, got {:?}",
id, expected_kind, e.kind,
)),
Some(_) => Ok(()),
}
}
}
The HandleId is embedded inside the TypedHandle struct alongside the resource. When the host binding is called with an externref, it extracts the HandleId, calls registry.validate(), and only proceeds to dereference the resource if validation passes. When a request lifecycle ends, the host calls registry.revoke() for every handle issued during that request. A subsequent invocation with a stale reference gets a "handle has been revoked" error instead of a use-after-free.
struct HostState {
registry: Arc<HandleRegistry>,
db_connections: HashMap<HandleId, DbConn>,
}
fn bind_db_query(
mut caller: wasmtime::Caller<'_, HostState>,
conn_ref: Option<ExternRef>,
query_ptr: i32,
query_len: i32,
) -> Result<i32, wasmtime::Error> {
let ext = conn_ref.ok_or_else(|| wasmtime::Error::msg("null externref"))?;
let handle = ext
.data()
.and_then(|d| d.downcast_ref::<TypedHandle<HandleId>>())
.ok_or_else(|| wasmtime::Error::msg("externref is not a typed handle"))?;
caller
.data()
.registry
.validate(handle.inner, HandleKind::DbConnection)
.map_err(wasmtime::Error::msg)?;
let query = read_guest_string(&mut caller, query_ptr, query_len)?;
let result_count = caller
.data_mut()
.db_connections
.get_mut(&handle.inner)
.ok_or_else(|| wasmtime::Error::msg("connection not found"))?
.execute(&query)?;
Ok(result_count as i32)
}
Step 3: Per-Component Reference Isolation
In multi-tenant deployments, each component must only be able to use externref values that were issued to it. Tag every handle with the component ID at creation time, and validate the component ID on every inbound call.
#[derive(Clone, PartialEq, Eq, Debug)]
struct ComponentId(String);
struct TypedHandle<T> {
kind: HandleKind,
handle_id: HandleId,
owner: ComponentId,
inner: T,
}
fn bind_db_query_isolated(
mut caller: wasmtime::Caller<'_, HostState>,
conn_ref: Option<ExternRef>,
query_ptr: i32,
query_len: i32,
) -> Result<i32, wasmtime::Error> {
let ext = conn_ref.ok_or_else(|| wasmtime::Error::msg("null externref"))?;
let handle = ext
.data()
.and_then(|d| d.downcast_ref::<TypedHandle<HandleId>>())
.ok_or_else(|| wasmtime::Error::msg("externref is not a typed handle"))?;
let calling_component = &caller.data().component_id;
if &handle.owner != calling_component {
return Err(wasmtime::Error::msg(format!(
"cross-tenant reference: handle owned by {:?}, called by {:?}",
handle.owner, calling_component,
)));
}
caller
.data()
.registry
.validate(handle.handle_id, HandleKind::DbConnection)
.map_err(wasmtime::Error::msg)?;
Ok(0)
}
The ownership check must come before the liveness check, not after. An attacker that constructs a forged externref — by somehow obtaining the opaque reference value for another tenant’s handle — should be rejected on the ownership check before the host even looks up whether the handle is live.
Step 4: funcref Table Restrictions
funcref tables accessible to guest code must not contain high-privilege host functions. Restrict which functions are placed in guest-accessible tables, and validate funcref signatures at composition time.
use wasmtime::{Engine, FuncType, ValType, Linker, Module, Store, Table, TableType, RefType};
fn build_guest_callable_table(
engine: &Engine,
store: &mut Store<HostState>,
) -> anyhow::Result<Table> {
let table_type = TableType::new(
RefType::FUNCREF,
1,
Some(16),
);
let table = Table::new(store, table_type, wasmtime::Val::FuncRef(None))?;
Ok(table)
}
fn validate_funcref_signature(
engine: &Engine,
expected: &FuncType,
candidate: &wasmtime::Func,
store: &mut Store<HostState>,
) -> anyhow::Result<()> {
let actual = candidate.ty(store);
if actual != *expected {
anyhow::bail!(
"funcref signature mismatch: expected {:?}, got {:?}",
expected,
actual,
);
}
Ok(())
}
Never expose host functions with access to privileged resources — filesystem, network, cryptographic keys — as funcref values that can be placed in guest-writable tables. If a guest needs to call back into the host, expose a narrow, purpose-built callback function with no ambient authority. Audit every table.set path to confirm that the guest cannot replace a low-privilege funcref slot with a reference to a higher-privilege function obtained from a separately composed module.
Step 5: WASM Component Model Typed Interfaces
The most durable defence against externref type confusion is to eliminate raw externref from the host-guest interface entirely. The WASM Component Model’s resource types compile to externref at the core module level, but expose a typed, named interface at the component boundary that the toolchain validates at link time.
package example:data-access;
interface db {
resource connection {
constructor(dsn: string);
query: func(sql: string) -> list<row>;
close: func();
}
resource file-handle {
open: func(path: string, mode: open-mode) -> result<file-handle, string>;
read: func(len: u32) -> list<u8>;
close: func();
}
enum open-mode {
read-only,
read-write,
}
record row {
columns: list<string>,
}
}
world app {
import db;
export run: func(input: string) -> string;
}
When the WIT toolchain generates Rust bindings from this interface, connection and file-handle become distinct Rust types at the component boundary. A guest function that receives a connection resource handle cannot pass it where a file-handle is expected — the WIT-generated binding rejects the mismatch at the interface layer, before any host code executes.
cargo component build --target wasm32-wasip2
wasm-tools component wit component.wasm
Migrating from raw externref to Component Model resource types is not a drop-in change: both host and guest must be recompiled against the WIT-generated bindings, and the component composition step must be re-run. For existing deployments, the type-tagged handle approach from Step 1 provides defence in depth while migration is in progress.
Step 6: Telemetry
wasm_externref_type_violation_total{component, expected_kind, actual_kind} counter
wasm_externref_revoked_access_total{component, handle_kind} counter
wasm_cross_tenant_ref_attempt_total{caller_component, owner_component} counter
wasm_funcref_signature_mismatch_total{component, expected_sig, actual_sig} counter
wasm_null_externref_call_total{component, host_function} counter
Alert on:
wasm_externref_type_violation_totalnon-zero — a component passed anexternrefof the wrong kind to a typed host function; type confusion attempt or guest programming error; investigate the component and the call sequence.wasm_externref_revoked_access_totalnon-zero — a component called a host function with a revoked handle; indicates a use-after-free attempt or a guest lifecycle management bug; check whether the component is retaining references past their valid scope.wasm_cross_tenant_ref_attempt_totalnon-zero — a component attempted to use another component’sexternref; indicates a cross-tenant reference leak in the composition layer; investigate immediately and audit all handle issuing paths.wasm_funcref_signature_mismatch_total— afuncrefwith an unexpected signature was presented at a call site; investigate whether the guest table was modified to substitute a different function.
Expected Behaviour After Hardening
After type-tagged handles are in place: a WASM component that passes a file handle externref to the db_query host function receives an error at the downcast_ref step before any pointer is dereferenced. The error message identifies the expected and actual handle kinds. No undefined behaviour occurs in the host.
After lifetime tracking: a component that retains an externref in a global and calls a host function with it after the request lifecycle has ended receives a "handle has been revoked" error. The host does not attempt to dereference the underlying resource. The liveness check adds one RwLock read per host call that involves an externref parameter — the lock is read-held and uncontended in the common case.
After per-component isolation: tenant B’s component cannot use tenant A’s externref values. Even if tenant B somehow obtains the opaque reference — via a shared WASM table, a host composition bug, or a speculative execution side-channel — the ownership check fires before any liveness check and before any resource access. The error is logged with both component IDs, making the leak visible in telemetry.
After funcref table restrictions: guest code cannot invoke high-privilege host functions via call_indirect. The table contains only pre-validated, low-privilege callbacks. Signature validation at insertion time prevents the table-substitution attack.
After Component Model WIT migration: externref type confusion is impossible at the component interface boundary — the toolchain enforces type safety statically, before the binary is deployed.
Trade-offs and Operational Considerations
The type-tagged handle approach adds one downcast_ref call and a tag check to every host call that receives an externref. downcast_ref is a vtable lookup plus a TypeId comparison — on modern hardware this is approximately 2–5 nanoseconds in the uncontended case. For applications where the hot path calls a host function with an externref parameter hundreds of thousands of times per second, this overhead is measurable. Profile before optimising: the downcast_ref cost is small compared to the syscall or I/O that most host functions actually perform.
The lifetime tracking registry requires a lock per Store for every host function call that validates an externref. If the WASM module runs on multiple threads sharing a Store — which Wasmtime does not support for the same Store but which can arise in multi-instance setups sharing a registry — the RwLock becomes a contention point. Design the registry to be Store-scoped rather than globally shared: each Store owns its registry, and handles from one store are not valid in another.
Migrating to the WASM Component Model WIT requires recompiling both the host and guest against the generated bindings. For a platform where guests are third-party-compiled WASM modules, this requires coordinating a binary format change with module authors. Use the type-tagged handle approach as a runtime safety net for modules that predate the migration.
The ComponentId-based ownership check in per-component isolation assumes that the host correctly sets the component_id field in each Store’s state at instantiation time. A host that reuses a Store across tenants without clearing the component_id — or that sets it incorrectly due to a race condition during concurrent instantiation — will silently bypass the ownership check. Enforce component_id assignment at Store construction time, not lazily.
Failure Modes
Type tags stored in a hash map keyed by ExternRef identity. If the tag lookup uses the opaque ExternRef pointer value as a hash map key, any GC movement of the object changes the key and the tag lookup fails. The host either panics or falls back to an unsafe default. Use the Store-scoped integer HandleId approach: the HandleId is embedded in the TypedHandle struct alongside the resource, and it is stable across GC cycles because the TypedHandle itself is what the GC manages.
Lifetime table checks on host call path but not on funcref table insertions. If the host validates externref liveness before dereferencing in host functions but does not check liveness when a funcref wrapping a host callback is inserted into a guest table, a freed resource’s associated function reference can still be invoked via call_indirect. Apply the same revocation check to funcref insertion paths.
Component isolation enforced for resource externref values but not for funcref references. A platform that checks handle.owner for database and file externref handles but places host callback funcref values in a shared table accessible to all components allows tenant B to call tenant A’s registered callback — which may execute with A’s ambient capabilities. Scope funcref tables per component, not globally.
WIT resource type migration done on host but not guest. A host migrated to WIT-generated resource bindings that compile to externref expects the type-safe component interface. A legacy guest compiled against the old raw-externref interface presents values that fail the WIT type validation at the component linker step. The component fails to instantiate. Fix: keep the raw-externref host binding path operational alongside the WIT path until all guests are recompiled; use the WIT component linker’s allow_unknown_exports only as a temporary migration bridge, not permanently.
externref null check substituted for full handle validation. A binding written for speed checks only conn_ref.is_some() before dereferencing. A non-null but revoked handle passes the null check and proceeds to dereference a freed resource. The null check is a necessary but insufficient guard. Null check, then type-tag check, then liveness check — all three, in that order.