It Asked What the Bytes Looked Like, Not Where They Came From: runwasi’s CVE-2026-47218

CVE-2026-47218 was published to NVD on 15 October 2026 at 19:17:39Z, source security-advisories@github.com, vulnStatus Received, with a GitHub-assigned CVSS 4.0 base score of 9.3 CRITICAL (AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N) and CWE-502, deserialization of untrusted data. The affected software is runwasi, the containerd sub-project that runs WebAssembly and WASI workloads as containers — 1,341 stars, 131 forks, Apache-2.0.

The advisory title, GHSA-cc25-vq59-rcjx, says the whole thing in eight words: “Forged Wasmtime precompiled OCI layer bypasses runwasi WASI sandbox.”

What went wrong

Wasmtime can ahead-of-time compile a WebAssembly module into native code and serialise the result. Loading that back is a different operation from loading Wasm: there is no validation, because the artifact is already machine code, and Wasmtime marks the API accordingly — the call in runwasi’s own source reads unsafe { Module::deserialize(&self.engine, wasm_binary) }. That is correct API design. Precompiled artifacts are a cache format, and a cache entry is only safe because you made it.

runwasi has such a cache: containerd stores precompiled output so a module does not have to be recompiled on every start. The defect is that the shim decided whether a blob was a cache entry by looking at the blob. The pre-fix execution path, read directly at the vulnerable revision, did this:

match WasmBinaryType::from_bytes(wasm_binary) {
    Some(WasmBinaryType::Module) => { /* compile and validate */ }
    Some(WasmBinaryType::Component) => { /* compile and validate */ }
    None => match wasmtime::Engine::detect_precompiled(wasm_binary) {
        Some(Precompiled::Module) => {
            log::info!("using precompiled module");
            let module = unsafe { Module::deserialize(&self.engine, wasm_binary) }?;
            /* ... */

Read the None arm as an attacker would. If these bytes are not recognisable WebAssembly, try them as native code. The compile path had the same shape: if wasmtime::Engine::detect_precompiled(&layer.layer).is_some(), log “Already precompiled”, skip compilation. A sniff on content, standing in for a fact about provenance.

So an attacker publishes an OCI image whose layer is a Wasmtime precompiled artifact they produced themselves. A victim runs it with containerd-shim-wasmtime-v1. WasmBinaryType::from_bytes does not recognise it as Wasm, detect_precompiled does recognise it, and the shim deserialises attacker-supplied native code instead of compiling and validating a module. WebAssembly validation never runs, because from the shim’s point of view it already ran, once, somewhere, for this cache entry.

The fix is one boolean, and it is the right boolean

Commit d28ab73c2c8a6c50d1927078821bd418b9e51638, authored by Jorge Prendes on 12 June 2026 at 15:31:41Z, is +119/−17 across four files, and 72 of the added lines are a new test. The substance is a field:

pub struct WasmLayer {
    pub config: Descriptor,
    pub precompiled: bool,
    #[serde(with = "serde_bytes")]
    pub layer: Vec<u8>,
}

In containerd/client.rs the three sites that construct a WasmLayer are now explicit about where the bytes came from: the ones fed by containerd’s precompile cache set precompiled: true, and the one that reads a layer out of the image sets precompiled: false. A new Source::is_precompiled() surfaces the flag to the runtime, and both of the dangerous branches are re-pointed at it. The compile path becomes:

if layer.precompiled {
    if wasmtime::Engine::detect_precompiled(&layer.layer).is_some() {
        log::info!("Already precompiled");
        compiled_layers.push(None);
        continue;
    }
    bail!(
        "A layer is marked as precompiled but does not appear to be a valid precompiled module/component"
    );
}

and the execution path flips from a content match with a precompiled fallback to an if precompiled { … } else { … }, where the else arm’s unrecognised case now ends in bail!("invalid wasm module") instead of quietly trying native code.

Two things are worth drawing out. First, detect_precompiled survives the patch — it is still called, but only after provenance has already decided the branch, where it functions as a sanity check rather than as an authorisation. Second, the unsafe block is unchanged, and that is correct: the unsafe contract was never violated by the compiler, it was violated by the caller feeding it bytes it had no right to trust. The patch does not make the deserialisation safer. It makes the input trusted.

This is the same defect class we described in Podman’s checkpoint-annotation sandbox bypass three weeks ago, approached from a different angle: a property of the artifact being run was allowed to select which code path builds the sandbox. In Podman an image annotation chose restore semantics over run semantics; here the shape of the bytes chose the no-validation loader. The generalisable rule is unchanged and keeps earning its keep: enumerate every input that can decide which loader, runtime or policy path executes, not just what executes inside it. And because the thing being selected is a trust decision, the selector has to be a fact the defender knows — a provenance flag set at the boundary — not a fact the attacker can present.

How bad, honestly

The advisory is unusually disciplined about scope, and we think its framing is the correct one. Quoting it directly:

The executed code runs in the shim’s “zygote” process inside the workload container. This can allow escape from the wasm sandbox to the container boundary, including access available to that container such as its filesystem mounts, environment, credentials, pod/container networking and the native Linux syscall surface allowed to that container. This does not by itself bypass container isolation or provide direct host-level code execution.

The CVSS 4.0 vector encodes that split precisely, which is worth noticing because scores of this class usually do not. VC:H/VI:H/VA:H is total loss of the vulnerable system — the Wasm sandbox and everything the workload container holds. SC:N/SI:N/SA:N is the honest statement that the subsequent system, the host, is not impacted. Nine-point-three with no subsequent impact is an unusual and accurate shape.

Our one disagreement is the usual one with AV:N/PR:N/UI:N. The realistic path is an operator, a pipeline or a cluster pulling an image whose contents the attacker influenced. No one reaches this across the network unauthenticated in the sense that vector implies. The consequence is scored faithfully; the access is scored at worst case.

What makes it matter more than the raw numbers suggest is the reason people run Wasm in containerd in the first place. The whole value proposition of a Wasm shim is that WebAssembly is a stronger boundary than a container: a validated bytecode sandbox inside the container sandbox, specifically so you can run code you trust less. That is exactly the architecture being reached for when teams need to execute model-generated or third-party code at scale. This bug collapses the inner boundary and leaves you with the outer one you already had — and most Wasm workloads are deployed with container-level hardening tuned down, precisely because the Wasm layer was supposed to be doing that work. If your threat model said “untrusted code is fine here, it is sandboxed twice,” for the duration of this bug it was sandboxed once, and the surviving layer is the one you optimised away.

The blast radius also extends past the named shim. Per the advisory, downstream shims built on runwasi’s containerd-shim-wasm precompile-cache API are affected if they implement Compiler and trust executable precompiled artifacts from OCI image layers, and the CVE record explicitly notes that the 0.6.1 boundary “does not independently certify downstream product releases.” If you ship or consume a shim built on this crate, the version number you need is your own, not runwasi’s.

The scanner gap: 120 days, and still no coordinate

The timeline is the second story here. The fix landed 12 June 2026 at 15:31Z. containerd-shim-wasm/v1.0.1 was released the same day at 16:11:45Z, and containerd-shim-wasmtime/v0.6.1 at 16:34:21Z — 63 minutes after the commit, which is a well-run embargo. The GHSA was published five days later, on 17 June 2026 at 00:22:13Z. The CVE record appeared 120 days after that. The previous release on that line, v0.6.0, dates to 20 May 2025, so the vulnerable artifact was the current one for roughly thirteen months.

The gap is not just a filing delay, and this is the operationally important part. At time of writing:

  • GET /advisories/GHSA-cc25-vq59-rcjx against the GitHub global advisory database returns 404. The advisory exists only as a repository advisory on containerd/runwasi.
  • api.osv.dev returns 404 for both GHSA-cc25-vq59-rcjx and CVE-2026-47218.
  • The advisory’s affected package is recorded with ecosystem runwasi and name containerd-shim-wasmtime. That is not a registry coordinate any scanner resolves — not crates.io, not an OCI reference.

Four months after a critical advisory with a shipped patch, there is still nothing for osv-scanner, Dependabot or a commercial SCA product to match against. This is the same mechanism we documented in OPNsense’s GeoIP importer batch and in Better Auth’s four-CVE batch: a published GHSA is not a record your scanners see. Infrastructure components distributed as binaries and shims fall outside package tooling by construction, and a CVE ID four months late does not retroactively create the coordinate. CVE-2026-47218 is not in CISA KEV (catalog 2026.10.15, 1,746 entries), and we have no evidence of exploitation.

What to do

  • Upgrade containerd-shim-wasmtime to v0.6.1 or later. v0.6.1 is both the fixed release and the current one; there is no newer line to weigh. If you consume containerd-shim-wasm as a library, v1.0.1 carries the WasmLayer change.
  • Find the shim yourself; do not expect a scanner to. Check the binaries registered as containerd runtimes on each node — containerd-shim-wasmtime-v1 in the PATH containerd searches, and the [plugins."io.containerd.grpc.v1.cri".containerd.runtimes] entries in config.toml. With no OSV or GHSA-global record and a non-standard ecosystem name, node inventory is the detection.
  • If you maintain a downstream shim implementing Compiler, audit your own precompile path. The question to ask is whether any code path can reach a precompiled-artifact loader with bytes that originated in an image layer. Upgrading the dependency does not answer it for you.
  • Until every node is patched, apply the advisory’s workaround literally: “Do not run untrusted OCI images with containerd-shim-wasmtime-v1 on affected versions.” In practice that means pinning Wasm workloads to digests from a registry you control.
  • Re-check the container-level hardening under your Wasm workloads. This bug’s ceiling is whatever that container can do. Dropped capabilities, a seccomp profile, no-new-privileges, a restrictive NetworkPolicy and a scoped service account are what stood between this and something considerably worse — and in a Wasm-first deployment they are the controls most likely to have been relaxed on the grounds that the inner sandbox was doing the work.
  • Treat image provenance as a security control for Wasm, not just for OCI. We made the same point about signature verification in hyper-mcp’s cosign TOCTOU: a Wasm artifact pulled from a registry is code, and the question of who produced it is not answered by the fact that it parses.

Verification note. The CVE identifier, publication and last-modified timestamps, vulnStatus, source identifier, CVSS 4.0 vector and base score, CWE assignment, full description and reference list were read first-hand from the NVD 2.0 API. The advisory summary, impact text, patch and workaround sections, severity, affected and patched version ranges, ecosystem and package name, publication timestamp, and the credits (reporter JulesDT; remediation developer jprendes; remediation reviewer jsturtevant; coordinator cpuguy83) were read first-hand from GHSA-cc25-vq59-rcjx via the GitHub API; every passage in quotation marks attributed to the advisory is verbatim. The fix commit’s author, timestamp, message, file list, line counts and complete diff were read first-hand from the GitHub API, and all Rust shown above — both the vulnerable form and the patched form — is taken verbatim from that diff. Release tag names and publication timestamps for containerd-shim-wasmtime v0.6.0 and v0.6.1 and containerd-shim-wasm v1.0.0 and v1.0.1, and the repository metadata, were read first-hand from the GitHub API. The GitHub global-advisory and OSV 404s and the absence of the CVE from CISA KEV were measured directly at time of writing and are stated as of that moment. Brave Search was used for discovery and returned only CVE-mirror and automated-scoring pages; no independent technical analysis of this CVE exists at time of writing. Nothing was installed: no containerd or runwasi instance was deployed, no shim was built or registered, no OCI image was crafted or pushed, no precompiled artifact was produced, no exploit was written or executed, and no third-party request was sent beyond reading the public records cited. The description of the exploitation path is derived from the advisory text and from reading the vulnerable and fixed source; we did not reproduce it. The severity critique, the deployment-hardening argument and the remediation guidance are our editorial analysis.

Sources: