Zero-Downtime CVE Patching via WebAssembly Module Hot-Reload

Zero-Downtime CVE Patching via WebAssembly Module Hot-Reload

The Problem

Every time a CVE is found in a WebAssembly runtime or a Wasm module used as a plugin, policy engine, or edge function, the typical remediation path is: stop the host process, update the runtime or module, restart. For services with strict availability SLAs, this means coordinating a maintenance window, which adds days to the patch timeline — precisely the time gap that LLM-generated exploits are designed to exploit.

WebAssembly’s isolation model — each module instance is sandboxed, with no shared mutable state between instances except through explicitly-defined interfaces — creates an opportunity that traditional native code does not have: you can load a new, patched module instance alongside the running instance, drain traffic from the old instance, and then deallocate it. The host process never stops.

This hot-reload pattern is supported by:

Wasmtime’s component model: Each Wasm component is independently instantiated and can be swapped by replacing the Store and Instance objects while the host process continues. The component model’s interface types (wit interfaces) provide the typed boundary across which you can swap implementations.

Epoch-based interruption: Wasmtime’s epoch interruption mechanism allows the host to signal a running Wasm instance to stop at the next safe point — analogous to a signal handler — enabling graceful drain without blocking the host thread.

Wasm’s deterministic memory model: Because Wasm instances do not share memory (no shared heap between instances), there is no risk of the old and new instances corrupting each other’s state during the transition.

The hot-reload pattern applies to three common Wasm deployment scenarios:

  1. Wasm plugins (NGINX njs, Envoy WASM filters, Istio WebAssembly plugins): a CVE in a plugin’s Wasm binary is patched by loading the new binary and draining the old filter instance.

  2. OPA/policy engine Wasm bundles: a CVE or policy bug in a compiled OPA policy is patched by loading the new bundle without restarting the OPA server or the admission webhook.

  3. Edge function hosts (Spin, Fastly Compute, custom Wasm function hosts): a CVE in a Wasm function is patched by routing new requests to the new instance while in-flight requests complete on the old instance.

Target systems: Wasmtime 16.0+ with component model enabled; Spin 2.x+; Envoy 1.28+ with Wasm filter support; custom embedding hosts using the Wasmtime Rust crate or C API.

Threat Model

1. CVE in Wasm plugin exploitable via crafted request (external attacker). Objective: exploit a memory safety issue or logic bug in a Wasm plugin (e.g., NGINX Wasm filter) via a crafted HTTP request. Impact: request handling compromised; potential host information disclosure or filter bypass. Hot-reload patches the plugin within seconds of a patched binary being available.

2. Policy engine CVE allowing policy bypass (external or insider attacker). Objective: exploit a bug in a compiled OPA or Cedar policy bundle that allows requests that should be denied to pass. Impact: authorization bypass; privilege escalation to protected resources. Hot-reload deploys the corrected policy bundle without admission webhook downtime.

3. Race condition during module swap (attacker timing a request to the transition window). Objective: send a request during the instant between old module unload and new module load; observe different error behaviour that leaks information. Impact: information disclosure from error handling in the hot-swap boundary. Mitigation: new instance is loaded and validated before old instance receives any drain signal.

4. Patched module verification bypass (attacker who can push to the module registry). Objective: push a malicious “patched” module to the registry; hot-reload picks it up and installs the malicious module. Impact: arbitrary code execution in the Wasm sandbox. Mitigation: module signature verification before load.

Hardening Configuration

Wasmtime Component Model Hot-Reload

The core pattern: maintain two slots (current and next), with an atomic pointer to indicate which is active:

// hot_reload.rs
use std::sync::{Arc, RwLock};
use wasmtime::*;
use wasmtime_wasi::WasiCtx;

pub struct HotReloadHost {
    engine: Engine,
    // RwLock allows concurrent read (active module) while write (swap) is in progress
    active: Arc<RwLock<ActiveModule>>,
}

struct ActiveModule {
    module: Module,
    linker: Linker<WasiCtx>,
    version: String,    // e.g., "sha256:abc..." for verification
}

impl HotReloadHost {
    pub fn new(engine: Engine, initial_wasm: &[u8], version: &str) -> Result<Self> {
        let module = Module::new(&engine, initial_wasm)?;
        let mut linker: Linker<WasiCtx> = Linker::new(&engine);
        wasmtime_wasi::add_to_linker(&mut linker, |s| s)?;

        Ok(HotReloadHost {
            engine: engine.clone(),
            active: Arc::new(RwLock::new(ActiveModule {
                module,
                linker,
                version: version.to_string(),
            })),
        })
    }

    /// Load a patched module without stopping the host.
    /// Returns an error if the new module fails signature verification or instantiation.
    pub fn hot_reload(&self, new_wasm: &[u8], new_version: &str) -> Result<String> {
        // Step 1: Verify the new module's signature before loading
        verify_module_signature(new_wasm, new_version)?;

        // Step 2: Compile the new module (this runs concurrently with active module)
        let new_module = Module::new(&self.engine, new_wasm)?;

        // Step 3: Validate the new module instantiates correctly in a test store
        let test_ctx = wasmtime_wasi::WasiCtxBuilder::new().build();
        let mut test_store = Store::new(&self.engine, test_ctx);
        {
            let active = self.active.read().unwrap();
            active.linker.instantiate(&mut test_store, &new_module)?;
        }

        // Step 4: Swap — write lock held only for the pointer update, not compilation
        let old_version = {
            let mut active = self.active.write().unwrap();
            let old = active.version.clone();
            let mut new_linker: Linker<WasiCtx> = Linker::new(&self.engine);
            wasmtime_wasi::add_to_linker(&mut new_linker, |s| s)?;
            *active = ActiveModule {
                module: new_module,
                linker: new_linker,
                version: new_version.to_string(),
            };
            old
        };

        // Old module is dropped here; memory freed
        Ok(format!("Swapped {} → {}", old_version, new_version))
    }

    /// Handle a request using the currently-active module.
    pub async fn handle_request(&self, input: &[u8]) -> Result<Vec<u8>> {
        let active = self.active.read().unwrap();
        let wasi_ctx = wasmtime_wasi::WasiCtxBuilder::new().build();
        let mut store = Store::new(&self.engine, wasi_ctx);
        store.set_epoch_deadline(100);  // Epoch interruption for safety

        let instance = active.linker.instantiate(&mut store, &active.module)?;
        let process_request = instance.get_typed_func::<(i32, i32), (i32, i32)>(
            &mut store, "process_request"
        )?;

        // Invoke the Wasm function
        let (out_ptr, out_len) = process_request.call(&mut store, (0, input.len() as i32))?;
        Ok(read_wasm_memory(&mut store, out_ptr, out_len))
    }
}

fn verify_module_signature(wasm: &[u8], version: &str) -> Result<()> {
    // Verify using cosign or a pre-distributed public key
    // This must be done before any module is loaded in production
    let sig_path = format!("/etc/wasm-keys/{}.sig", version);
    let pubkey_path = "/etc/wasm-keys/production.pub";

    let output = std::process::Command::new("cosign")
        .args(["verify-blob",
               "--key", pubkey_path,
               "--signature", &sig_path,
               "-"])
        .stdin(std::process::Stdio::piped())
        .output()?;

    if !output.status.success() {
        anyhow::bail!("Module signature verification failed for version {}", version);
    }
    Ok(())
}

Epoch-Based Drain for In-Flight Requests

When the old module is handling a long-running request at swap time, use epoch interruption to request a graceful stop:

use std::sync::atomic::{AtomicBool, Ordering};

static DRAIN_SIGNAL: AtomicBool = AtomicBool::new(false);

// In the store's epoch callback
engine.increment_epoch();   // Advance the epoch counter

// The Wasm module checks the epoch at backward branches (loops)
// When epoch > deadline, Wasmtime traps with EpochDeadlineReached

// Pattern: set a short epoch deadline before drain
// New requests get the new module; old in-flight requests drain naturally
async fn drain_and_swap(
    host: &HotReloadHost,
    new_wasm: &[u8],
    new_version: &str,
    drain_timeout_ms: u64,
) -> Result<()> {
    // Signal that new requests should use the new module
    DRAIN_SIGNAL.store(true, Ordering::SeqCst);

    // Wait for in-flight requests to complete (up to drain_timeout_ms)
    tokio::time::sleep(std::time::Duration::from_millis(drain_timeout_ms)).await;

    // Swap the module
    host.hot_reload(new_wasm, new_version)?;

    DRAIN_SIGNAL.store(false, Ordering::SeqCst);
    Ok(())
}

Envoy Wasm Filter Hot-Reload

Envoy supports hot-reloading Wasm filters via xDS without restarting:

# Envoy xDS: update WasmService to point to patched binary
# Applied via Istio EnvoyFilter or direct xDS push

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: auth-wasm-filter
  namespace: production
spec:
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.wasm
          typed_config:
            "@type": type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
            value:
              config:
                name: "auth-wasm"
                root_id: "auth_root"
                vm_config:
                  runtime: envoy.wasm.runtime.v8
                  code:
                    # Update this digest to the patched module; Envoy reloads automatically
                    remote:
                      http_uri:
                        uri: https://registry.example.com/wasm/auth-filter-patched.wasm
                        cluster: wasm-registry
                      sha256: "abc123..."   # SHA-256 of patched module
# Verify Envoy loaded the new filter
kubectl exec -n production deploy/app -c istio-proxy -- \
  curl -s localhost:15000/stats | grep wasm | grep "auth_root"

# Check that the new filter version is active
kubectl exec -n production deploy/app -c istio-proxy -- \
  curl -s localhost:15000/config_dump | \
  jq '.configs[] | select(.["@type"] | contains("Wasm"))'

CI Pipeline for Automated Hot-Reload on CVE Fix

# .github/workflows/wasm-cve-hotpatch.yml
name: Wasm CVE Hot Patch
on:
  push:
    paths: ["src/wasm-plugins/**"]
    branches: ["security/cve-*"]   # CVE fix branches

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build patched Wasm module
        run: |
          cargo build --target wasm32-wasi --release \
            -p auth-filter
          # Output: target/wasm32-wasi/release/auth_filter.wasm

      - name: Sign the module
        run: |
          DIGEST=$(sha256sum target/wasm32-wasi/release/auth_filter.wasm | awk '{print $1}')
          cosign sign-blob \
            --key gcpkms://projects/.../keyRings/wasm-signing/cryptoKeys/filter-signer \
            --output-signature auth-filter-${DIGEST}.sig \
            target/wasm32-wasi/release/auth_filter.wasm

      - name: Push to registry
        run: |
          oras push registry.example.com/wasm/auth-filter:patched \
            auth_filter.wasm:application/wasm \
            auth-filter-${DIGEST}.sig:application/cosign.signature

      - name: Trigger hot reload via Kubernetes operator
        run: |
          kubectl patch wasmmodule auth-filter \
            --type=json \
            -p='[{"op":"replace","path":"/spec/version","value":"patched-'${GITHUB_SHA:0:8}'"}]'

Expected Behaviour After Hardening

Scenario Without Hot-Reload With Hot-Reload
CVE in Wasm auth plugin, patch available Schedule maintenance window (hours to days) Patch compiled, signed, deployed, hot-swapped in < 5 minutes; zero downtime
Patched module fails to start No impact (old module still running during testing) Verification fails before swap; old module continues serving
In-flight request during swap Request interrupted on host restart Epoch-based drain waits for completion; swap after drain
Malicious “patch” pushed to registry No verification; deployed immediately Signature verification fails; old module continues; alert fires
Rollback needed after bad patch Manual restart required Hot-reload previous version digest from registry

Verification:

# Verify hot-reload without downtime
# Send continuous requests to the service
ab -n 10000 -c 10 http://service.example.com/api/health &

# Trigger hot-reload in another terminal
kubectl patch wasmmodule auth-filter \
  --type=json -p='[{"op":"replace","path":"/spec/version","value":"v2-patched"}]'

# Check that ab completes with 0 failed requests
wait
# Expected: 0 failed requests despite module swap

Trade-offs and Operational Considerations

Aspect Benefit Cost Mitigation
Hot-reload without process restart Zero-downtime patching; enables sub-5-minute CVE remediation Requires stateless Wasm modules (no persistent state in module instances) Design Wasm plugins to externalise state; use host-provided key-value store
Compile-before-swap New module compiled and validated before old is stopped Doubles memory usage briefly during compilation Set module size limits; run compilation off the hot path in a worker thread
Signature verification before load Prevents malicious module injection Requires key management for signing key Use Cloud KMS or HSM for signing key; rotate annually
Epoch-based drain Graceful completion of in-flight requests Long-running Wasm requests may delay swap by up to epoch_deadline Set epoch deadline to match request timeout (e.g., 5s); abort very-long requests

Failure Modes

Failure Symptom Detection Recovery
New module fails signature verification Hot-reload rejected; old module continues serving CI step fails with signature error; alert fires Fix signature or re-sign; retrigger pipeline
Old module memory not freed after swap Memory leak accumulates over many hot-reloads Host process RSS grows monotonically Verify Arc reference count drops to zero after swap; add memory metric
Race: request arrives during write-lock swap Request hangs briefly (< 1ms) Latency spike in p99 during swap Minimise write-lock critical section to pointer update only; no computation inside lock
Wasm module ABI incompatibility after patch New module’s WIT interface changed; host bindings break Instantiation error at test-store step Pin WIT interface version; update host bindings before deploying new module