WebAssembly Dynamic Linking Security: Module Composition, Trust Chains, and Plugin Graphs
Problem
Static WASM modules — a single .wasm binary that runs in isolation — have a well-understood security model: capabilities flow from the host to the module via explicit imports. Dynamic linking changes this: multiple WASM modules compose at runtime, importing and exporting functions from each other, sharing memory segments, and forming a graph of trust relationships.
The WebAssembly Component Model (standardised in 2024) provides the formal foundation for dynamic module composition. Components import and export interfaces (defined in WIT — WASM Interface Types), and the runtime links them together. This enables plugin architectures, shared library reuse, and microkernel patterns — but introduces security questions that static linking avoids:
- Confused deputy attacks via module imports. Module A has
filesystem-readcapability. Module B is untrusted but imports a function from Module A. If Module A’s exported function unconditionally usesfilesystem-readon behalf of any caller, Module B inherits access to the filesystem through A — even though B was never granted that capability directly. - Trust escalation via component composition. A low-trust plugin module is composed with a high-trust utility module. The utility exports a general-purpose HTTP function. The plugin calls the utility’s HTTP export with a URL to an internal metadata endpoint, using the utility’s network access as a confused deputy.
- Shared linear memory between linked modules. Some dynamic linking approaches share linear memory between modules. A malicious module that shares memory can read and write the trusted module’s data — including credentials, keys, and application state — bypassing the usual memory isolation guarantee.
- Module graph supply chain attacks. A composed application depends on 10 WASM modules from 5 vendors. One module in the dependency graph is compromised. The compromised module’s imports grant it access to capabilities transitively provided by other modules in the graph.
- Dynamic dispatch exploitation. A WASM module that accepts function references (via the function references proposal) as arguments can be passed an attacker-controlled function reference, causing the module to call attacker code with the caller’s capabilities.
Target systems: WASM Component Model 1.0 (WIT, Wasmtime 20+, WASM Tools); Extism with multiple linked plugin modules; wasm-bindgen multi-module Rust/JS projects; Emscripten dynamic linking (SIDE_MODULE, MAIN_MODULE); WASI-NN with dynamically loaded backend modules.
Threat Model
- Adversary 1 — Confused deputy via imported high-capability function: An untrusted plugin module imports
read-config()from a trusted core module. The trusted module’sread-config()returns any configuration key without checking the caller’s identity. The plugin callsread-config("database-password")and exfiltrates the value. - Adversary 2 — Shared linear memory read across trust boundary: Two WASM modules are linked with shared linear memory (Emscripten-style). The untrusted module scans the shared memory space, finds the trusted module’s stack and heap, and extracts private keys or session tokens written there.
- Adversary 3 — Module graph dependency substitution: An attacker publishes a WASM module with a name similar to a popular utility module in a WASM registry. A composed application pulls the attacker’s module as a dependency. The attacker’s module calls the legitimate modules’ imports — including filesystem and network — on behalf of the attacker.
- Adversary 4 — Function reference as capability confusion: A WASM module accepts a function reference parameter and calls it with trusted data. An attacker passes a function reference pointing to an attacker-controlled module function, causing the trusted module to call attacker code with trusted-context data.
- Adversary 5 — Interface type confusion: Two components export the same WIT interface with the same type signatures but different semantics. A component that expects the trusted version of the interface is linked with the attacker’s version, which logs all arguments or returns modified values.
- Access level: Adversaries 1, 2, and 4 only need to run code in the same composed environment. Adversary 3 needs registry access. Adversary 5 needs the ability to supply a module to the composition.
- Objective: Escalate capabilities across module trust boundaries; access data from higher-trust modules; execute code in the high-trust module’s context.
- Blast radius: In a composed application where one high-trust module handles credentials or I/O, a confused deputy attack gives any co-linked module the same capabilities — defeating the per-module capability grant that makes WASM’s security model valuable.
Configuration
Step 1: Component Model Trust Architecture
Design the module composition graph with explicit trust tiers:
# Principle: capabilities flow down (from host to modules), never up or sideways.
# A module should only be able to do what its explicit capability grant allows,
# regardless of what other modules it is composed with.
# Trusted tier (host-adjacent, full capabilities):
# ┌────────────────────────────────────┐
# │ Host (native code) │
# │ Grants: filesystem, network, etc. │
# └─────────────┬──────────────────────┘
# │ explicit capability grants
# ┌─────────────▼──────────────────────┐
# │ Core Module (high trust) │
# │ Has: filesystem-read (config dir) │
# │ Exports: get-config(key: string) │
# └─────────────┬──────────────────────┘
# │ limited exports only
# ┌─────────────▼──────────────────────┐
# │ Plugin Module (low trust) │
# │ Has: none (no host capabilities) │
# │ Imports: get-config (filtered) │ ← Core filters what plugin can read.
# └────────────────────────────────────┘
// Core module: filter what plugins can access before forwarding.
// The core module mediates access to its capabilities.
use wit_bindgen::generate;
generate!({
world: "core-world",
exports: {
"example:core/config": Config,
},
});
struct Config;
impl exports::example::core::config::Guest for Config {
// Plugin-callable config reader — filter sensitive keys.
fn get_config(key: String) -> Option<String> {
// Allowlist: only these config keys are accessible to plugins.
const PLUGIN_ACCESSIBLE: &[&str] = &[
"feature-flags",
"api-endpoint",
"timeout-ms",
];
if !PLUGIN_ACCESSIBLE.contains(&key.as_str()) {
// Log the attempted access.
log_access_violation(&key);
return None; // Deny access silently; do not reveal that the key exists.
}
// Safe to return — this key is in the allowlist.
read_actual_config(&key)
}
}
Step 2: WIT Interface Design for Trust Separation
Define WIT interfaces with the minimum necessary exposure:
// interfaces/plugin-api.wit
// This interface is what plugins see — a minimal, safe subset.
package example:plugin-api;
interface config {
// Only expose non-sensitive configuration.
// Plugin cannot request arbitrary keys.
get-feature-flag: func(name: string) -> bool;
get-timeout: func() -> u32;
// NOT: get-config(key: string) -> string — too broad.
}
interface logging {
// Plugins can log but cannot read other plugins' logs.
log-info: func(message: string);
log-error: func(message: string);
// NOT: read-logs() — would expose other plugins' log data.
}
// What plugins are NOT given:
// - filesystem access
// - network access
// - access to other plugins' state
// - raw config access
world plugin {
import config;
import logging;
export plugin-main: func(input: string) -> string;
}
// interfaces/core-api.wit
// Separate WIT world for the core module — broader capabilities.
package example:core;
interface filesystem {
read-config-file: func(path: string) -> list<u8>;
// NOT exported to plugins — only used internally.
}
interface http {
post: func(url: string, body: list<u8>) -> list<u8>;
// NOT exported to plugins — would enable exfiltration.
}
world core {
import filesystem;
import http;
// Core exports a SUBSET to plugins via the plugin-api world.
}
Step 3: Avoid Shared Linear Memory
Shared linear memory is the highest-risk dynamic linking pattern. Use the component model’s value-passing model instead:
// BAD: Emscripten-style shared memory — pointer passing between modules.
// The receiving module can read all of shared memory, not just the passed buffer.
extern "C" {
fn plugin_process(ptr: *const u8, len: usize) -> usize;
}
// GOOD: Component model — values are copied across the module boundary.
// Each component has its own linear memory; data is serialised at the boundary.
// WIT functions accept and return values, not pointers.
// In WIT:
// process: func(data: list<u8>) -> result<list<u8>, string>;
// Wasmtime copies the data at the boundary — plugin cannot read host memory.
# Cargo.toml — use component model target, not shared-everything-threads.
[profile.release]
opt-level = "s"
# Build as a component (not a core module with dynamic linking).
# Components do not share linear memory by construction.
Step 4: Module Integrity in the Composition Graph
Verify every module in the composition graph before linking:
// module_linker/src/main.rs — verify all modules before composing.
use sha2::{Digest, Sha256};
use std::collections::HashMap;
struct ModuleManifest {
modules: HashMap<String, ModuleEntry>,
}
struct ModuleEntry {
path: String,
expected_sha256: String,
allowed_imports: Vec<String>, // Which interfaces this module may import.
allowed_exports: Vec<String>, // Which interfaces this module may export.
}
fn load_verified_module(entry: &ModuleEntry) -> Result<Vec<u8>, String> {
let bytes = std::fs::read(&entry.path)
.map_err(|e| format!("Cannot read {}: {}", entry.path, e))?;
// Verify SHA-256.
let actual = format!("{:x}", Sha256::digest(&bytes));
if actual != entry.expected_sha256 {
return Err(format!(
"Module integrity check FAILED for {}:\n expected: {}\n actual: {}",
entry.path, entry.expected_sha256, actual
));
}
Ok(bytes)
}
fn compose_verified(manifest: &ModuleManifest) -> Result<(), String> {
for (name, entry) in &manifest.modules {
let bytes = load_verified_module(entry)?;
println!("✓ Verified: {} ({} bytes)", name, bytes.len());
// Parse module to check imports match allowlist.
// (Use wasm-tools crate to inspect the module's import section.)
verify_imports(&bytes, &entry.allowed_imports)?;
}
Ok(())
}
Step 5: Capability Attenuation at Composition Time
Use Wasmtime’s linker to enforce capability grants per-module:
// host/src/main.rs — Wasmtime host with per-module capability grants.
use wasmtime::*;
use wasmtime_wasi::preview2::*;
fn build_plugin_linker(engine: &Engine) -> Result<Linker<PluginState>> {
let mut linker = Linker::new(engine);
// Add WASI to the linker — but only the capabilities the plugin needs.
// The plugin gets: stdout, stderr, random.
// The plugin does NOT get: filesystem, network, environment variables.
wasmtime_wasi::preview2::add_to_linker_async(&mut linker, |state: &mut PluginState| {
&mut state.wasi
})?;
// Add the filtered config interface.
linker.func_wrap(
"example:plugin-api/config",
"get-feature-flag",
|caller: Caller<'_, PluginState>, name_ptr: i32, name_len: i32| -> i32 {
let name = read_string_from_guest(&caller, name_ptr, name_len);
// Enforce the allowlist here at the host level.
if ALLOWED_FEATURE_FLAGS.contains(&name.as_str()) {
get_feature_flag(&name) as i32
} else {
0 // Default false for unknown flags.
}
}
)?;
Ok(linker)
}
// Each plugin gets its own isolated state and linker instance.
// State is NOT shared between plugins.
struct PluginState {
wasi: WasiCtx,
plugin_id: String,
}
Step 6: Module Dependency Pinning
# wasm-modules.lock — pin all modules in the composition graph by digest.
# Similar to Cargo.lock or package-lock.json, but for WASM modules.
modules:
- name: "core-module"
source: "registry.example.com/wasm/core-module"
version: "2.1.0"
digest: "sha256:abc123def456..."
allowed_capabilities:
- "filesystem:read:/etc/config/"
- "http:post:https://api.internal/*"
- name: "analytics-plugin"
source: "registry.example.com/wasm/analytics"
version: "1.5.2"
digest: "sha256:789ghi012..."
allowed_capabilities: [] # No host capabilities; only composed interfaces.
imports_from:
- "core-module:config" # Only this interface from core.
- name: "third-party-formatter"
source: "registry.wapm.io/formatter/json-formatter"
version: "0.8.1"
digest: "sha256:jkl345mno..."
allowed_capabilities: []
imports_from: [] # Pure computation; no imports.
trust_level: untrusted # Third-party module; extra scrutiny.
Step 7: Runtime Isolation Between Plugin Instances
Isolate concurrently running plugin instances:
// Multi-tenant plugin execution: each tenant gets isolated stores.
use wasmtime::{Engine, Store, Module, Instance};
use std::sync::Arc;
struct PluginPool {
engine: Arc<Engine>,
module: Arc<Module>, // Compiled once; instantiated per-tenant.
}
impl PluginPool {
fn execute_for_tenant(
&self,
tenant_id: &str,
input: &[u8],
) -> Result<Vec<u8>, String> {
// Each invocation gets a fresh Store — isolated state.
let mut store = Store::new(
&self.engine,
PluginState {
wasi: build_minimal_wasi(),
plugin_id: format!("tenant-{}-{}", tenant_id, uuid::Uuid::new_v4()),
}
);
// Set resource limits per instance.
store.limiter(|state| &mut state.resource_limiter);
store.set_fuel(10_000_000)?; // Instruction limit.
// Fresh instance — no shared state from previous calls.
let instance = self.linker.instantiate(&mut store, &self.module)?;
// Call the module function.
let process = instance.get_typed_func::<(i32, i32), i64>(&mut store, "process")?;
// ... invoke and collect result.
Ok(vec![])
}
}
Step 8: Telemetry
wasm_module_integrity_check_total{module, status} counter
wasm_capability_violation_total{module, capability} counter
wasm_confused_deputy_attempts_total{caller, callee, function} counter
wasm_module_composition_errors_total{error_type} counter
wasm_plugin_execution_duration_ms{module, tenant} histogram
wasm_memory_per_module_bytes{module} gauge
wasm_function_call_cross_boundary_total{from, to, function} counter
Alert on:
wasm_module_integrity_check_total{status="failed"}— a module in the composition graph failed its hash check; do not execute; investigate.wasm_capability_violation_totalnon-zero — a module attempted to import a capability not in its allowlist; investigate the module and its caller.wasm_confused_deputy_attempts_total— a plugin called a core function with a denied key or argument; possible confused deputy attempt.wasm_module_composition_errors_total— composition failed; likely interface mismatch or missing module in the graph; check module registry.
Expected Behaviour
| Signal | Naive module composition | Hardened component composition |
|---|---|---|
| Untrusted plugin reads config via core | Core returns any key; confused deputy succeeds | Core allowlist rejects sensitive keys; returns nil |
| Shared memory cross-module read | Plugin reads entire shared linear memory | Component model: no shared memory; value copying only |
| Compromised dependency module | Executes with transitive capabilities | Module integrity check fails; composition aborted |
| Function reference passed to trusted module | Trusted module calls attacker’s function | Function references validated against trusted source |
| Cross-tenant plugin state leak | Previous tenant’s store accessible | Fresh Store per invocation; no shared state |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Component model over shared-memory linking | Strong isolation by construction | Higher serialisation overhead at module boundary | Benchmark: typically < 1µs for small payloads; acceptable for most use cases |
| Capability allowlists in WIT | Prevents interface creep; minimal exposure | Must update allowlist when requirements change | Document allowed interfaces in WIT definitions; change review process |
| Per-invocation Store (no pooling) | Zero cross-tenant state leakage | Higher instantiation overhead (~100µs) | Pre-compile modules; warm store pool (but verify isolation per-call) |
| Module graph pinning | Prevents supply chain substitution | Dependency updates require hash update | Automate hash update via CI; sign the lock file |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| WIT interface version mismatch | Composition fails at link time | wasm_module_composition_errors_total |
Pin interface versions in WIT; update both modules together |
| Fuel exhausted in nested call | Plugin times out mid-computation | Fuel error in execution log | Increase fuel limit for deep call chains; profile expected call depth |
| Allowlist too restrictive | Plugin cannot access needed config | Plugin returns error for valid operation | Review allowlist; add specific key; avoid wildcard grants |
| Module integrity check blocks update | New module version fails SHA256 check | Integrity failure alert; deployment blocked | Update hash in lock file via CI after verifying the new module |
| Memory explosion via deep composition | Host OOM from large module graph | Memory usage alert | Set per-module memory limits; profile memory at composition time |