What Browser WASM CVEs Teach Server-Side Runtimes: V8 JIT Miscompilation and Isolation Boundaries

What Browser WASM CVEs Teach Server-Side Runtimes: V8 JIT Miscompilation and Isolation Boundaries

Problem

CVE-2026-3910 and CVE-2026-2796 are not especially exotic vulnerabilities. CVE-2026-3910, classified as an “inappropriate implementation in WebAssembly” in Chromium’s V8 engine, allowed a crafted WASM module to violate linear memory isolation: the JIT-compiled machine code for a memory access instruction failed to emit the correct bounds check under a specific optimisation tier transition, and a heap address outside the module’s linear memory region became readable. CVE-2026-2796, a JIT miscompilation in V8’s JavaScript/WebAssembly component, produced incorrect native code for a polymorphic memory store when the type feedback vector entered an unexpected state — the resulting code wrote to a calculated address without the intended range check, enabling a full sandbox escape from the renderer process.

Both bugs were exploited in the wild. Both followed the same structural pattern: a JIT compiler optimisation, applied under specific runtime conditions, silently dropped or mis-generated a memory safety check that the WASM specification requires. The renderer process sandbox in Chromium — a separate OS process with its own seccomp profile and reduced privileges — contained the escape at the process boundary. The attacker still needed a second-stage renderer-to-browser-process exploit to reach the operating system.

Now remove the renderer process sandbox. That is precisely the situation for most server-side WASM deployments.

When you run Wasmtime, WasmEdge, or wazero in a production service to execute untrusted WASM modules, the runtime typically runs inside your application process. A successful JIT miscompilation exploit does not escape to a sandboxed renderer — it escapes directly to your application’s address space, its credentials, its in-memory secrets, its connections to databases and downstream services. The structural gap between browser and server WASM is not the quality of the runtime’s JIT compiler. It is the absence of the surrounding OS-level sandbox that browsers have had for over a decade.

This article analyses what CVE-2026-3910 and CVE-2026-2796 reveal about the class of bugs that affect all JIT-capable WASM runtimes, maps those findings to the specific threat model for server-side deployments, and details the layered controls that close the gap between “WASM sandbox” and “WASM sandbox you can actually trust with untrusted input.”

Target systems: production deployments of Wasmtime 22+, WasmEdge 0.14+, wazero v1.8+, and Node.js WASM running untrusted WASM modules on Linux x86-64 or arm64.

Threat Model

Threat 1 — JIT miscompilation sandbox escape. A malicious WASM module triggers a miscompilation bug in Wasmtime’s Cranelift backend or WasmEdge’s AOT compiler. The emitted native code performs a memory access without the required bounds check. The module reads host process memory — private keys, connection strings, other tenants’ data — or overwrites a function pointer to redirect control flow. The attacker controls the WASM module content and can fuzz the JIT with crafted inputs designed to hit edge cases in type specialisation or optimisation tier transitions.

Threat 2 — Trusted module with runtime type confusion. A legitimate WASM module from a trusted supplier contains a memory corruption bug (use-after-free, buffer overflow in a host function callback). The runtime’s type system or object model has a corresponding type confusion vulnerability. The combination escalates from module-controlled memory corruption to host-process code execution. This is the scenario CVE-2026-2796 illustrated in the browser context: the module bug alone was insufficient; the JIT’s incorrect handling of the type feedback state was the amplifier.

Threat 3 — Cross-module isolation breach. Multiple WASM modules run inside the same runtime process (common in multi-tenant serverless runtimes and plugin hosts). One module, already compromised or malicious, exploits a runtime bug to read another module’s linear memory. In a multi-tenant setting this is a direct data breach: module A reads module B’s in-flight request data, session tokens, or cryptographic material.

Contrast with browser context. In Chromium, the WASM module runs inside a renderer process. That renderer process has a seccomp BPF filter that blocks the majority of syscalls, a separate UID, no write access to the filesystem, and no direct access to the GPU or network stack. A sandbox escape from the renderer reaches a process that is already heavily constrained — and reaching the browser’s privileged broker requires a second exploit targeting inter-process communication. Server-side WASM typically skips every one of these layers. The runtime process has network access, filesystem access, environment variables containing credentials, and in many cases runs as a service account with elevated cloud IAM permissions. A single JIT miscompilation exploit is sufficient to reach all of it.

Configuration and Implementation

Why Browser WASM CVEs Are a Leading Indicator

Browser JavaScript engines — V8, SpiderMonkey, JavaScriptCore — receive more security investment than any other JIT compiler in the world. Google Project Zero, Mozilla Security, and Apple Security Research fuzz them continuously. Bug bounty programmes pay tens of thousands of dollars per JIT bug. The result is that known bug classes in browser JIT compilers are extremely well-documented and the mitigations are mature.

Server-side WASM runtimes are not at that level of scrutiny. Wasmtime receives excellent research attention (the bytecode alliance runs a dedicated security response process and accepts CVE reports), but the volume of fuzzing is lower and the researcher population is smaller. The practical consequence is that bug classes that appeared in V8 in 2024 and 2025 often appear in Cranelift or WasmEdge’s compiler in 2025 and 2026 — with less prior mitigation infrastructure in place. CVE-2026-3910’s “inappropriate implementation” class — incorrect bounds check generation under optimisation tier transitions — has a direct analogue in Wasmtime’s history: GHSA-4947-4m49-c4q3 (2024) described an epoch-interruption miscompilation that under specific loop conditions omitted a bounds check in generated x86-64 code. The mechanisms differ; the category is identical.

The practical implication for operators: every critical WASM CVE fixed in Chrome is a signal to audit your server-side runtime for the same class. Wasmtime’s changelog explicitly cross-references browser CVE classes in its security fix notes. Treat Chrome’s WASM CVE feed as a threat intelligence source for your Wasmtime deployment.

Wasmtime’s Security Model: What Each Mitigation Covers

Wasmtime’s Cranelift-based JIT provides several security layers, each with distinct scope and limits:

Linear memory bounds checking. Every WASM load and store compiles to a bounds check before the memory access. On x86-64, Wasmtime uses a guard page strategy: it maps 8 GiB of virtual address space per instance (on 64-bit), with the linear memory at the base and guard pages occupying the upper region. An out-of-bounds access faults on the guard page and is caught by the signal handler rather than returning attacker-controlled data. This is effective against a module that generates out-of-bounds addresses through normal (non-exploited) logic, but it does not protect against a miscompilation that omits the bounds check entirely — which is exactly what CVE-2026-3910 class bugs do.

Control flow integrity (CFI). Wasmtime’s Cranelift emits indirect call targets that are validated against the expected type signature. This prevents a compromised module from using a call_indirect instruction to jump to an arbitrary host function address. It is not a complete mitigation against all control-flow hijacking — ROP chains that pivot through the runtime’s own code are not blocked by WASM-level CFI.

Spectre mitigations — backedge guards. The --wasm-backedge-guards flag (enabled by default since Wasmtime 20) inserts serialising instructions on loop backedges to prevent speculative execution from racing past bounds checks. This mitigates Spectre v1 attacks through WASM loops. It has a measurable throughput cost (2–8% on compute-heavy workloads) and does not address Spectre v2 (indirect branch injection).

What is not covered. None of these mitigations prevent a JIT miscompilation from producing incorrect code. They operate at the level of correct code generation. A bug in Cranelift’s optimisation passes that causes an incorrect instruction sequence to be emitted bypasses all of the above — the guard page is never consulted because the access instruction itself does not trigger a fault, it just reads the wrong memory.

AOT Compilation: Reducing JIT Attack Surface

The most structurally significant reduction in JIT attack surface is eliminating the JIT at module load time entirely. Wasmtime’s wasmtime compile subcommand produces an .cwasm file (a native shared object with Wasmtime-specific metadata) that can be loaded at runtime without re-running the Cranelift compiler:

# Compile the module offline, in a controlled environment
wasmtime compile \
  --target x86_64-unknown-linux-gnu \
  --wasm-features=bulk-memory,reference-types \
  --cranelift-opt-level=speed \
  --output plugin-v1.2.3.cwasm \
  plugin-v1.2.3.wasm

# Verify the output is a valid precompiled module
file plugin-v1.2.3.cwasm
# plugin-v1.2.3.cwasm: ELF 64-bit LSB shared object, x86-64, ...

# Load the precompiled module at runtime (no Cranelift invoked)
wasmtime run --allow-precompiled plugin-v1.2.3.cwasm

In your Rust embedder, loading a precompiled module avoids the compilation step:

use wasmtime::{Config, Engine, Module, Store};
use anyhow::Result;

fn load_precompiled_module(engine: &Engine, path: &str) -> Result<Module> {
    // Safety: the .cwasm file must have been produced by this exact
    // engine version and configuration. Validate the file hash before
    // loading in production.
    let module = unsafe { Module::deserialize_file(engine, path)? };
    Ok(module)
}

fn build_hardened_engine() -> Result<Engine> {
    let mut config = Config::new();

    // Disable JIT for precompiled-only workloads
    // Note: with precompiled modules you still need the Cranelift engine
    // for the signal handler infrastructure, but no compilation occurs at load time.

    // Enable Spectre mitigations
    config.wasm_backedge_guards(true);

    // Enable linear memory guard pages (default on 64-bit, explicit here for clarity)
    config.static_memory_guard_size(8 * 1024 * 1024 * 1024); // 8 GiB guard

    // Limit per-instance memory to 64 MiB
    config.max_wasm_stack(512 * 1024); // 512 KiB stack
    // Memory limits are enforced at instantiation via Store::set_fuel / ResourceLimiter

    Ok(Engine::new(&config)?)
}

AOT compilation shifts the JIT attack surface from the production runtime to the build pipeline. An attacker who can influence the build pipeline can still craft a malicious module that produces incorrect compiled output — but the build pipeline is a controlled environment that you can instrument, restrict, and run with elevated logging. A production pod handling live traffic is a much harder place to instrument Cranelift’s internal optimisation decisions.

The tradeoff: AOT-compiled modules are tied to a specific engine version. When you update Wasmtime, you must recompile all modules. This creates an operational dependency that must be managed — see the failure modes section.

Defence-in-Depth: Process Isolation

The browser’s renderer process is the model to replicate. Each untrusted WASM module should run in a separate OS process, isolated from the host application and from other modules.

The minimal implementation is a supervisor process that forks a worker for each module execution:

#!/usr/bin/env bash
# wasm-worker-runner.sh: Execute a single WASM module in an isolated process.
# The supervisor calls this script; output is captured over a pipe.
# Adjust WASM_MEMORY_MB and WASM_CPU_TIME_S per module policy.

set -euo pipefail

MODULE_PATH="${1:?Usage: $0 <module.wasm> <entrypoint> [args...]}"
ENTRYPOINT="${2:?}"
shift 2

WASM_MEMORY_MB="${WASM_MEMORY_MB:-64}"
WASM_CPU_TIME_S="${WASM_CPU_TIME_S:-30}"

# Resource limits applied to this process and all children
ulimit -v $((WASM_MEMORY_MB * 1024))   # virtual memory
ulimit -m $((WASM_MEMORY_MB * 1024))   # resident memory
ulimit -t "${WASM_CPU_TIME_S}"          # CPU seconds
ulimit -f 0                             # no file creation
ulimit -n 16                            # minimal file descriptors

exec wasmtime run \
  --allow-precompiled \
  --wasm-backedge-guards \
  --max-wasm-stack=524288 \
  --invoke "${ENTRYPOINT}" \
  "${MODULE_PATH}" \
  -- "$@"

In a production Rust service, use std::process::Command to spawn isolated workers and communicate over stdin/stdout or a Unix socket:

use std::process::{Command, Stdio};
use std::time::Duration;
use anyhow::{bail, Result};

pub struct WasmWorker {
    module_path: String,
    memory_mb: u64,
    cpu_seconds: u64,
}

impl WasmWorker {
    pub fn execute(&self, entrypoint: &str, input: &[u8]) -> Result<Vec<u8>> {
        let mut child = Command::new("/usr/local/bin/wasm-worker-runner.sh")
            .arg(&self.module_path)
            .arg(entrypoint)
            .env("WASM_MEMORY_MB", self.memory_mb.to_string())
            .env("WASM_CPU_TIME_S", self.cpu_seconds.to_string())
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        // Write input and close stdin to signal EOF
        if let Some(mut stdin) = child.stdin.take() {
            use std::io::Write;
            stdin.write_all(input)?;
        }

        // Wait with a wall-clock timeout as a backstop to ulimit -t
        let output = child.wait_with_output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "WASM worker exited with status {}: {}",
                output.status,
                stderr.trim()
            );
        }

        Ok(output.stdout)
    }
}

Defence-in-Depth: Seccomp Filtering on the Worker Process

The worker process needs far fewer syscalls than a general-purpose Linux process. Apply a seccomp-bpf profile that blocks everything not required for WASM execution:

# Generate a seccomp profile by running a known-good module under strace,
# then trim the observed syscall set to a minimal allow list.
strace -f -e trace=all \
  wasmtime run --allow-precompiled /path/to/known-good.cwasm \
  2>&1 | grep -oP 'called [\w]+' | sort -u

# Apply the profile using unshare + systemd-run for testing:
systemd-run --scope \
  --property=SystemCallFilter="read write mmap mprotect munmap brk \
    rt_sigaction rt_sigprocmask sigreturn exit_group futex \
    clock_gettime gettid getpid" \
  --property=SystemCallErrorNumber=EPERM \
  wasmtime run --allow-precompiled plugin.cwasm

For a Rust embedder that applies seccomp before invoking the module (using the seccompiler crate):

use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, SyscallRuleSet};
use std::collections::BTreeMap;

fn apply_wasmtime_seccomp_profile() -> anyhow::Result<()> {
    // Minimal syscall set for Wasmtime on Linux x86-64.
    // Profile must be validated against your Wasmtime version and module workload.
    let allowed_syscalls: Vec<i64> = vec![
        libc::SYS_read,
        libc::SYS_write,
        libc::SYS_mmap,
        libc::SYS_mprotect,
        libc::SYS_munmap,
        libc::SYS_brk,
        libc::SYS_rt_sigaction,
        libc::SYS_rt_sigprocmask,
        libc::SYS_sigreturn,
        libc::SYS_exit_group,
        libc::SYS_futex,
        libc::SYS_clock_gettime,
        libc::SYS_gettid,
        libc::SYS_getpid,
        libc::SYS_tgkill,    // signal delivery between threads
        libc::SYS_sched_yield,
        // Do NOT include: execve, fork, clone3, ptrace, mount, init_module
    ];

    let mut rules: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = BTreeMap::new();
    for syscall in &allowed_syscalls {
        rules.insert(*syscall, vec![]);
    }

    let filter = SeccompFilter::new(
        rules,
        SeccompAction::KillProcess,  // kill on any unlisted syscall
        SeccompAction::Allow,
        std::env::consts::ARCH.try_into()?,
    )?;

    let bpf_prog: BpfProgram = filter.try_into()?;
    seccompiler::apply_filter(&bpf_prog)?;

    Ok(())
}

Call apply_wasmtime_seccomp_profile() in the worker process after forking, before loading the module. The filter is inherited by any threads the runtime creates, and cannot be relaxed by the process after it is set.

Defence-in-Depth: Linux Namespaces

Pair seccomp with namespace isolation to eliminate filesystem and network access from worker processes:

# Run the WASM worker in a minimal namespace environment:
# - New mount namespace with read-only root
# - New network namespace (no network access)
# - New PID namespace
# - New UTS namespace (no hostname modification)
unshare \
  --mount \
  --net \
  --pid \
  --uts \
  --fork \
  --kill-child \
  -- \
  sh -c '
    # Remount root read-only
    mount --make-rprivate /
    mount -o remount,ro /

    # Provide a minimal /proc for Wasmtime signal handling
    mount -t proc proc /proc

    exec wasmtime run --allow-precompiled "$@"
  ' _ "${MODULE_PATH}" "${ENTRYPOINT}"

Kubernetes deployments can express equivalent isolation through the pod security context:

apiVersion: v1
kind: Pod
metadata:
  name: wasm-worker
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 65534
    seccompProfile:
      type: Localhost
      localhostProfile: wasmtime-minimal.json
  containers:
  - name: worker
    image: your-registry/wasm-worker:latest
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    resources:
      limits:
        memory: "128Mi"
        cpu: "500m"

WasmEdge Hardening

WasmEdge’s AOT compilation path (wasmedge compile) provides similar JIT-surface-reduction benefits to Wasmtime’s wasmtime compile. For production untrusted-module execution, always use AOT mode:

# AOT compile the module
wasmedge compile plugin.wasm plugin.so

# Run with AOT output (no JIT at load time)
wasmedge --reactor plugin.so _start

Disable the threading extension if your modules do not require it. WasmEdge’s shared memory implementation (--enable-threads) expands the attack surface for data races between modules; disable it unless explicitly needed:

# Do not pass --enable-threads in production unless required
wasmedge --reactor plugin.so _start
# Threads disabled by default — verify with:
wasmedge --version | grep -i thread

Wazero: Interpreter Mode and Reduced JIT Surface

Wazero provides two execution modes: a compiler (machine-code generation for x86-64 and arm64) and a pure interpreter. The interpreter is slower — typically 5–10x on compute-intensive workloads — but has no JIT compiler and therefore no JIT miscompilation attack surface:

package main

import (
    "context"
    "os"

    "github.com/tetratelabs/wazero"
    "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)

func newHardenedRuntime(ctx context.Context, useInterpreter bool) wazero.Runtime {
    var cfg wazero.RuntimeConfig

    if useInterpreter {
        // Interpreter mode: no JIT, no machine-code generation.
        // Use for untrusted modules where JIT attack surface is unacceptable.
        cfg = wazero.NewRuntimeConfigInterpreter()
    } else {
        // Compiler mode: faster, but has JIT-class attack surface.
        // Use only for trusted, validated modules.
        cfg = wazero.NewRuntimeConfigCompiler()
    }

    // Cap linear memory per module
    cfg = cfg.WithMemoryLimitPages(1024) // 1024 * 64KiB = 64 MiB

    return wazero.NewRuntimeWithConfig(ctx, cfg)
}

func executeUntrustedModule(ctx context.Context, wasmBytes []byte) error {
    // Use interpreter for untrusted input — trade performance for no JIT surface
    rt := newHardenedRuntime(ctx, true)
    defer rt.Close(ctx)

    wasi_snapshot_preview1.MustInstantiate(ctx, rt)

    // Module config: no filesystem, no environment, no args
    modCfg := wazero.NewModuleConfig().
        WithName("untrusted-plugin").
        WithStdin(os.Stdin).
        WithStdout(os.Stdout).
        WithStderr(os.Stderr)
        // Deliberately omit: WithFSConfig, WithEnv, WithArgs

    _, err := rt.InstantiateWithConfig(ctx, wasmBytes, modCfg)
    return err
}

Monitoring for Sandbox Violations

Alerting on anomalous worker behaviour is the detection layer for exploits that slip through prevention controls:

# Watch for seccomp violations in auditd logs
# /etc/audit/rules.d/wasm-seccomp.rules
-a always,exit -F arch=b64 -S all -F key=wasm-seccomp-violation
# auditd will log SIGKILL events caused by SECCOMP_RET_KILL with the key

# Watch for unexpected process spawning from worker PIDs
# (execve from a WASM worker process is a strong indicator of escape)
auditctl -a exit,always -F arch=b64 -S execve \
  -F uid=65534 -k wasm-worker-execve

# Prometheus alerting rule for worker crash rate
# High crash rate from a specific module is a fuzzing signal
- alert: WasmWorkerCrashRateHigh
  expr: rate(wasm_worker_exit_nonzero_total[5m]) > 0.1
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "WASM worker crash rate elevated — possible exploit attempt"

Keeping Runtimes Patched

Wasmtime publishes security advisories at https://github.com/bytecodealliance/wasmtime/security/advisories. Subscribe to this feed. WasmEdge advisories appear in the GitHub security tab for WasmEdge/WasmEdge. Both projects publish minor versions with security fixes within days of disclosure.

Automate runtime version tracking in your CI pipeline:

#!/usr/bin/env bash
# check-wasmtime-version.sh: Fail CI if the pinned Wasmtime version
# is not the latest security release.

PINNED_VERSION=$(grep 'wasmtime' Cargo.lock | grep 'version' | head -1 | grep -oP '"[\d.]+"' | tr -d '"')
LATEST_VERSION=$(curl -sL https://api.github.com/repos/bytecodealliance/wasmtime/releases/latest | jq -r '.tag_name' | tr -d 'v')

if [ "${PINNED_VERSION}" != "${LATEST_VERSION}" ]; then
  echo "WARN: Pinned Wasmtime ${PINNED_VERSION} is behind latest ${LATEST_VERSION}"
  echo "Review https://github.com/bytecodealliance/wasmtime/security/advisories"
  exit 1
fi

Expected Behaviour

The following table maps attack scenarios against the protection offered at each defence-in-depth layer:

Attack scenario No defence-in-depth Process isolation + seccomp AOT + no JIT (precompiled)
JIT miscompilation OOB read Attacker reads host process memory; no detection Worker process killed by seccomp on unexpected syscall; alert fires No JIT invoked at runtime; miscompilation cannot occur in worker (still possible in build pipeline)
Linear memory OOB write (correct bounds check omitted) Attacker overwrites host memory; arbitrary code execution possible Worker process killed or crashes; host supervisor restarts worker; crash logged Guard page fault kills worker process; host receives SIGCHLD; alert fires
Cross-module isolation breach (module A reads module B’s memory) Modules share process address space; read succeeds silently Each module in separate process; no shared address space; read requires IPC or ptrace (blocked) Same as process isolation; AOT does not change cross-module isolation without process separation
Runtime type confusion escalation Module bug + runtime bug → host code execution Worker killed by seccomp when exploit attempts execve or socket; no host code execution Reduces compiler attack surface; type confusion in host function ABI still possible
Resource exhaustion (memory bomb) Host process OOM-killed; affects all tenants Worker process memory-limited by ulimit/cgroup; only worker dies Same as process isolation; AOT does not limit runtime memory consumption

Trade-offs

Control Isolation benefit Operational cost When to accept the cost
Process-per-module isolation Strong: each module in separate OS process; JIT escape, cross-module breach, and type confusion all contained at process boundary Startup overhead: 50–200 ms per module invocation; IPC complexity for passing data in/out; higher memory overhead from per-process runtime state Any deployment running untrusted or third-party WASM modules in production; multi-tenant serverless; plugin hosts
AOT vs. JIT compilation Moderate: eliminates runtime JIT compiler invocation; attacker cannot trigger JIT miscompilation through module content at runtime Compiled artefact tied to engine version; must recompile on runtime updates; no dynamic optimisation feedback; build pipeline must be secured Modules with known-stable content; trusted supplier with signed module artefacts; deployments where runtime update automation is reliable
Seccomp filter on worker High for post-escape scenario: blocks execve, ptrace, socket, mount even after WASM sandbox escape Initial profiling effort to build correct allow list; risk of overly-restrictive profile breaking legitimate module behaviour; requires per-module or per-workload profiling All deployments running untrusted modules; mandatory when process isolation is not feasible
wazero interpreter mode Eliminates JIT miscompilation class entirely; pure Go, no native code generation at runtime 5–10x performance reduction for compute-heavy workloads; not practical for CPU-intensive modules Low-throughput plugin execution; trusted-but-unaudited modules; environments where JIT attack surface is unacceptable and performance budget allows
Linux namespace isolation Eliminates filesystem and network access from worker; limits post-escape pivot options Requires privilege to create namespaces (or user namespaces enabled); adds container orchestration complexity; debugging harder Production deployments on Linux hosts; pairs naturally with seccomp and process isolation

Failure Modes

Failure mode How it manifests Detection Remediation
Seccomp allowlist too permissive (includes execve or clone) Attacker escapes WASM sandbox, calls execve to spawn shell; seccomp does not block it because execve is in the allow list auditd execve event from WASM worker UID; unexpected child processes; no seccomp KILL event Re-profile worker syscall requirements; remove execve and clone from allow list; use SECCOMP_RET_LOG before switching to SECCOMP_RET_KILL to validate
Worker crash goes unmonitored (silent DoS) Malicious module crashes worker process repeatedly; supervisor does not alert; tenant requests fail silently or with opaque errors Increased 5xx error rate; no alert from supervisor process on SIGCHLD Instrument supervisor to emit metrics on worker exit status; configure alert on elevated crash rate per module or tenant
AOT compilation of malicious module retains host function attack surface Module is AOT-compiled and JIT risk is eliminated, but module calls host-imported functions with malformed arguments; host function has a memory safety bug No runtime JIT telemetry; host function crash or memory corruption in host process AOT does not eliminate host function ABI risk; validate all arguments in host functions regardless of module trust level; use Rust for host functions with safe bounds checks
Runtime update breaks precompiled module compatibility Wasmtime minor version update changes the .cwasm format; precompiled modules fail to load; production service fails to start Module load failure with “incompatible precompiled module” error at startup Pin Wasmtime version in deployment; automate AOT recompilation on runtime version bump; test precompiled module loading in staging before production rollout
cgroup memory limit too high (ineffective against exhaustion) Module allocates up to cgroup limit before being killed; limit set to “safe” 1 GiB is still enough to cause host memory pressure Gradual host memory increase; worker OOM kill after significant allocation Set per-module memory limits conservatively (64–256 MiB for most plugins); measure module memory usage in staging; alert on cgroup memory usage above 80% of limit
Cross-module isolation assumption without process separation Operator assumes WASM module isolation prevents cross-module data access; modules run in same process with separate Store instances; runtime bug allows cross-Store access No direct detection without cross-module data tainting; discovered only on runtime CVE disclosure Never rely on within-process isolation for security boundaries between mutually distrustful modules; separate processes are required

Conclusion

CVE-2026-3910 and CVE-2026-2796 are primarily notable as browser security incidents. They are equally notable as a reminder that the JIT compiler is the structural weakest point of any WASM runtime, and that the mitigations browsers deploy around their WASM runtimes — renderer process isolation, seccomp filtering, reduced-privilege execution — exist because browser vendors learned a decade ago that the JIT alone cannot be trusted as a security boundary.

Server-side WASM deployments have not yet learned that lesson uniformly. The WASM sandbox is a strong tool. It is not a sufficient tool for untrusted input. The controls that make it sufficient — process isolation, seccomp, namespace isolation, AOT compilation, and runtime update automation — are each available and individually deployable. The question is whether operators treat WASM as a sandbox that requires surrounding infrastructure, or as a sandbox that replaces it.

Browser CVEs are the answer to that question. They come from the most intensively scrutinised JIT compilers in existence, operated by organisations with more security engineering capacity than most server-side runtime operators. If those compilers produce sandbox escapes under adversarial input, the assumption that server-side runtimes do not should not be a matter of trust — it should be a matter of defence-in-depth.