Wasmtime Async Component DoS: Hardening Against CVE-2026-27195

Wasmtime Async Component DoS: Hardening Against CVE-2026-27195

The Problem

CVE-2026-27195 is a denial-of-service vulnerability in Wasmtime’s component model async execution path. It crashes the host process — not the guest component — when the host drops an in-flight async call future before that future completes.

The mechanism: when a host embedder calls a guest component function through the component model async API, it receives a Rust Future from TypedFunc::call_async. That future drives the component’s execution until the guest function returns or yields back to the host. If the host drops the future mid-flight — because a request deadline expired, a connection was closed, or an outer tokio::time::timeout fired — Wasmtime’s internal component task state machine does not unwind cleanly. It records the component’s task as still live and suspended.

On the next call into the same component instance, Wasmtime attempts to dispose of the previous task. That task has not yet exited. The runtime hits an assertion that no in-progress tasks exist at call entry, and issues a Rust panic!.

The consequence is determined by the host binary’s panic handler. The Bytecode Alliance’s recommended build profile for production Wasmtime embeddings sets panic = "abort" in the [profile.release] section of Cargo.toml. With panic = "abort", a single panic kills the entire OS process immediately. Every other component running in that process — along with all in-flight requests they are serving — terminates at the same instant. A single misbehaving or malicious guest component can destroy an entire host.

The component-model-async feature became the default in Wasmtime 39.0.0, released in early 2026. Before 39.0.0, async component calls required opting in. After 39.0.0, any host that uses async support and runs component model workloads is affected without any additional configuration. Deployments that upgraded from Wasmtime 38.x to 39.x picked up the feature silently.

Inducing the vulnerability does not require a crafted exploit payload. Any component function that delays its response long enough for the host’s timeout to fire is sufficient. A guest can sleep by calling wasi:clocks/monotonic-clock.subscribe-duration and blocking on the pollable, by looping over WASI I/O operations that stall, or by holding an open resource handle and doing nothing. From the guest’s perspective, this is legal WASM behavior. The host’s timeout mechanism does the rest.

The crates.io-ahead-of-advisory pattern. The patch releases 40.0.4 and 41.0.4 appeared on crates.io on April 9, 2026. The Bytecode Alliance GHSA advisory (GHSA-xxxx-xxxx-xxxx) was filed the same day but several hours after the crate publish. Operators who run automated cargo update in CI pipelines would have pulled wasmtime = "40.0.4" and redeployed before seeing any advisory notification from GitHub Dependabot or OSV. If you use automated dependency updates with short deployment cycles, you may already be patched — but verify with cargo tree -i wasmtime rather than assuming.

Affected versions: Wasmtime 39.0.0–40.0.3 and 41.0.0–41.0.3. Fixed versions: 40.0.4 and 41.0.4.

Threat Model

The vulnerability requires two actors to combine: a guest component that can delay its response, and a host that cancels async futures under load or deadline pressure.

Malicious or buggy guest component. A deliberately crafted component can sleep indefinitely in a WASI pollable wait, stalling the future for as long as the host tolerates. A buggy component can accidentally stall in the same way — a deadlock on a resource handle, an infinite loop in a WASI-backed I/O operation, or a dependency on an external service that has become unavailable. The vulnerability does not distinguish intent.

Host-side future cancellation. Any timeout mechanism that wraps the call_async future can trigger the bug. tokio::time::timeout, futures::future::select, a CancellationToken from the tokio-util crate, or a dropped task handle when a Tokio task is aborted all produce the same outcome: the future is dropped before it resolves.

Impact: process-wide kill. The panic aborts the host OS process. All components sharing that process lose their in-flight requests. If the Wasmtime host is the backend for a serverless compute layer or an edge runtime, the process restart time is the outage duration per pod or node. A high-frequency timeout loop — for example, an attacker that sends requests designed to always time out — can prevent the process from staying up long enough to serve legitimate traffic.

Affected deployments:

  • Wasmtime 39.0.0 through 40.0.3 (any host using async component calls)
  • Wasmtime 41.0.0 through 41.0.3 (same)
  • Multi-tenant serverless platforms running untrusted guest components
  • Edge compute systems with per-request timeouts shorter than guest execution time
  • Plugin systems where third-party components are loaded and called with deadlines

Not affected:

  • Embeddings that do not use component-model-async (core Wasm module calls via the Module/Instance API are unaffected)
  • Embeddings using call_async that never cancel or drop the future before it resolves
  • Wasmtime 38.x and earlier, where component-model-async was not the default

Hardening Configuration

1. Upgrade

The primary remediation is upgrading to a patched release. Update Cargo.toml:

[dependencies]
wasmtime = "40.0.4"
wasmtime-wasi = "40.0.4"

Or for the 41.x line:

[dependencies]
wasmtime = "41.0.4"
wasmtime-wasi = "41.0.4"

Verify the resolved version after updating:

cargo update -p wasmtime
cargo tree -i wasmtime | head -5

The output should show wasmtime v40.0.4 or wasmtime v41.0.4. If it shows an older version, a dependency elsewhere in the tree has a pinned constraint that prevents the upgrade. Identify the conflicting dependency with cargo tree -i wasmtime --edges features and resolve it before assuming the patch is applied.

After upgrading, rebuild and redeploy the host binary. A common failure mode in container-based deployments is updating Cargo.toml and committing the change without triggering a full image rebuild. The running container still carries the old binary. Confirm the deployed binary’s Wasmtime version by embedding a version string at build time:

const WASMTIME_VERSION: &str = env!("CARGO_PKG_VERSION");

Or, for the Wasmtime crate specifically:

println!("wasmtime {}", wasmtime::VERSION);

Expose this through a health or version endpoint so monitoring can confirm the patched version is live across all replicas.

2. Disable component-model-async If Unused

If your embedding uses Wasmtime for core WASM modules rather than component model components, disable async support entirely:

use wasmtime::Config;

let mut config = Config::new();
config.async_support(false);
let engine = Engine::new(&config)?;

If you use the component model but all guest functions are synchronous (no async exports in the WIT), async_support(false) is safe. The component-model-async feature only activates when the host drives component functions through call_async. Synchronous TypedFunc::call on a sync guest is not affected by this CVE.

To compile without the feature flag entirely, specify explicit features in Cargo.toml:

[dependencies]
wasmtime = { version = "40.0.4", default-features = false, features = [
    "cranelift",
    "component-model",
] }

Omitting component-model-async from the feature list prevents the async component call paths from being compiled into the host binary. This is the strongest mitigation for deployments that do not need async component calls — it removes the vulnerable code path entirely rather than patching around it.

3. Per-Component Process Isolation

Running multiple components in a single Wasmtime process concentrates risk. A panic in one component’s async task kills all of them. The architectural remedy is process isolation: each component instance runs in its own OS process, with the host managing a pool of worker processes.

use std::process::{Command, Stdio};
use std::io::{Write, BufRead};

pub struct ComponentWorker {
    child: std::process::Child,
    stdin: std::process::ChildStdin,
    stdout: std::io::BufReader<std::process::ChildStdout>,
}

impl ComponentWorker {
    pub fn spawn(component_path: &str) -> anyhow::Result<Self> {
        let mut child = Command::new("/usr/lib/myapp/component-runner")
            .arg(component_path)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()?;
        let stdin = child.stdin.take().unwrap();
        let stdout = std::io::BufReader::new(child.stdout.take().unwrap());
        Ok(Self { child, stdin, stdout })
    }

    pub fn call(&mut self, request: &[u8]) -> anyhow::Result<Vec<u8>> {
        self.stdin.write_all(request)?;
        self.stdin.write_all(b"\n")?;
        let mut line = String::new();
        self.stdout.read_line(&mut line)?;
        Ok(line.into_bytes())
    }
}

impl Drop for ComponentWorker {
    fn drop(&mut self) {
        let _ = self.child.kill();
    }
}

The component-runner binary is a minimal Wasmtime embedder that loads and runs a single component. When a panic occurs inside the worker — whether from CVE-2026-27195 on an unpatched binary or from any other runtime fault — only that worker process terminates. The pool manager restarts the worker and routes new requests to healthy workers in the interim. Other components in their own processes are entirely unaffected.

Process isolation has memory overhead proportional to component count: each worker process carries its own Wasmtime Engine, compiled module cache, and OS-level memory mappings. For small component counts (tens to low hundreds), this is typically acceptable. For large-scale multi-tenant systems with thousands of components, a tiered model works better: group components by trust level, where high-trust components share a monitored process and untrusted components each get their own.

4. Fuel-Based Execution Limits

Fuel limits cap how long a guest can run before the host interrupts it cooperatively. A component that sleeps in a tight WASI-pollable loop consumes fuel on each iteration. When the fuel runs out, the runtime traps the component cleanly, allowing the host to recover the future’s result as an Err(Trap) rather than a panic.

use wasmtime::{Config, Engine, Store};

let mut config = Config::new();
config.consume_fuel(true);
config.async_support(true);

let engine = Engine::new(&config)?;
let mut store = Store::new(&engine, ());

store.set_fuel(500_000_000)?;

The key property: fuel exhaustion produces a trap that unwinds the component’s execution and returns an Err to the host. The host can inspect the error, log it, and discard the component instance without triggering the panic condition. This does not fix the underlying CVE in the state machine, but it provides an alternative termination path that fires before the host’s external timeout would cancel the future.

Fuel limits require calibration per workload. Set them too low and legitimate long-running transforms — image processing, cryptographic operations, parsing large documents — terminate before completing. Set them too high (10 billion instructions or more) and they provide no practical protection because the host’s timeout fires first. Measure fuel consumption of representative production workloads:

let fuel_before = store.get_fuel()?;
let result = func.call_async(&mut store, params).await;
let fuel_after = store.get_fuel().unwrap_or(0);
let consumed = fuel_before.saturating_sub(fuel_after);
tracing::info!(fuel_consumed = consumed, "component call completed");

Record consumed as a histogram. Set the per-call fuel limit at 2–3x the p99 of observed consumption. This leaves room for legitimate variance while ensuring runaway components hit the fuel trap well before host-side timeouts.

5. Epoch Interruption

Epoch interruption is Wasmtime’s wall-clock-based cooperative deadline mechanism. The host runs a background thread that increments the engine’s epoch counter on a timer. Each Store has a deadline expressed as an epoch value. When the current epoch exceeds the deadline, the runtime traps the component at the next safe point.

use wasmtime::{Config, Engine, Store};
use std::sync::Arc;
use std::time::Duration;

let mut config = Config::new();
config.epoch_interruption(true);
config.async_support(true);

let engine = Arc::new(Engine::new(&config)?);

let engine_for_thread = Arc::clone(&engine);
std::thread::spawn(move || loop {
    std::thread::sleep(Duration::from_millis(10));
    engine_for_thread.increment_epoch();
});

let mut store = Store::new(&engine, ());
store.set_epoch_deadline(5);

With a 10ms tick and a deadline of 5 epochs, any component that runs for more than 50ms is interrupted. The interrupt fires at the next WASM execution safe point — typically within microseconds of the deadline passing. The host receives Err(Interrupted) from call_async, not a panic. The component instance can be discarded cleanly.

Epoch interruption is more precise than fuel for wall-clock-based timeouts and cheaper than fuel for per-operation accounting. The background thread adds negligible CPU overhead. The epoch counter is shared across all Store instances on the same Engine, so a single background thread services all components in the process.

Setting the epoch deadline requires the same calibration discipline as fuel limits. A deadline of 1 epoch with a 10ms tick gives a 10–20ms window, which is appropriate for edge compute functions but too tight for batch-processing components. Expose the deadline as a per-workload configuration parameter rather than a global constant.

Expected Behaviour After Hardening

On a patched Wasmtime (40.0.4 or 41.0.4), dropping a call_async future mid-flight no longer panics. The Wasmtime async cancellation path now unwinds the component task state machine correctly and marks the task as cancelled. A subsequent call to the same component instance does not encounter a not-yet-exited task. The host receives an Err from the cancelled future indicating the call did not complete, and can handle or log that error without process termination.

With epoch interruption active, a component that exceeds its deadline is interrupted before the host’s outer timeout fires. The host never cancels the future externally; Wasmtime returns Err(Interrupted) from within call_async. The future completes — it completes with an error, but it completes. No state machine inconsistency occurs.

Expected error handling on epoch interrupt:

match func.call_async(&mut store, params).await {
    Ok(result) => handle_result(result),
    Err(e) if e.is::<wasmtime::InterruptError>() => {
        tracing::warn!("component exceeded epoch deadline, discarding instance");
        return Err(ComponentError::Timeout);
    }
    Err(e) => {
        tracing::error!(error = %e, "component call failed");
        return Err(ComponentError::RuntimeFault(e));
    }
}

The component instance should be discarded after an interrupt. Reusing a Store after an interrupted call risks running into accumulated state from the interrupted execution. Instantiate a fresh component for the next request.

Trade-offs and Operational Considerations

Per-process isolation eliminates the blast radius of any host panic, not just CVE-2026-27195. It costs memory proportional to the number of concurrent component instances. For a system running 500 concurrent component workers, each carrying a 20 MB Wasmtime engine footprint plus module memory, the overhead approaches 10 GB before accounting for component-owned linear memory. Measure the actual footprint of your worker binary under load before committing to full process isolation. A pool of pre-warmed workers with preloaded modules reduces spawn latency but increases idle memory.

Fuel limits require empirical calibration. The fuel cost of a WASM instruction depends on the module’s logic, not just its count — a tight loop over integer arithmetic consumes fuel quickly while a component that spends most of its time in WASI calls consuming fuel on the WASM side consumes very little. Profiling with cargo flamegraph or Wasmtime’s built-in fuel tracking is necessary before deploying fuel-based limits to production.

Epoch interruption adds a background thread per Engine. In a system with multiple engines — for instance, separate engines per trust tier with different Config settings — each needs its own epoch incrementer thread. The threads are cheap but they are OS threads, and they do not exit unless you arrange a shutdown signal. Use a shared Arc<AtomicBool> as a stop flag and drive the sleep loop from that flag to allow clean shutdown.

Failure Modes

Upgrading the crate without rebuilding the container image. A cargo update commit in the source repository triggers a CI pipeline that runs tests but pushes the existing container image rather than building a new one. The deployed binary retains the pre-patch Wasmtime version. Mitigate by making the container build depend on the Cargo.lock file’s hash — any change to the lock file forces a full image rebuild. Verify the deployed version through a version endpoint before marking a rollout complete.

Fuel limits set too high. A fuel limit of 10 billion instructions sounds restrictive but is not. A tight WASI-polling loop that does nothing useful can burn through 10 billion fuel units in well under a second on modern hardware. A host-side request timeout of 5 seconds fires long before the fuel trap. The future gets dropped externally, and on an unpatched Wasmtime the panic follows. If you deploy fuel limits as a defense-in-depth measure against this CVE, set them relative to observed workload fuel consumption — not as an arbitrary large number.

Running multiple components in a single Engine and Store without process isolation. A Store is the per-instance execution context. Multiple component instances can share a Store only if they are composed into a single component artifact. If you instantiate independent components into the same Store to share state, a panic from one destroys all. The correct architecture is one Store per component instance, and one process per trust boundary.

Applying epoch interruption but sharing the epoch deadline across workload types. A single global set_epoch_deadline(5) works for uniform workloads. When the same process runs both latency-sensitive HTTP handler components and longer-running batch-transform components, a 50ms epoch deadline interrupts the batch components constantly. Per-Store epoch deadlines are supported — set them based on the workload’s SLA, not a single global value.