Securing Kubernetes Sidecar Injection Against Rogue Container Injection

Securing Kubernetes Sidecar Injection Against Rogue Container Injection

Problem

Sidecar injection via Kubernetes mutating admission webhooks is the standard pattern for transparently adding capabilities to pods: service mesh proxies (Istio Envoy, Linkerd), distributed application runtimes (Dapr), secrets managers (Vault agent), and observability collectors (OTel collector) all use this mechanism. When a pod is submitted to the API server, a webhook intercepts the request, modifies the pod spec to add one or more sidecar containers, and returns the modified spec. The application team sees no evidence of the injection in their manifests.

This transparency is the mechanism’s strength and its security weakness. A compromised or misconfigured injector webhook can:

Inject arbitrary containers. An injector webhook that is compromised via a supply chain attack, a SSRF in its own endpoint, or a misconfiguration can return a pod spec that includes a container image of the attacker’s choice — running alongside the legitimate workload, with access to the pod’s network, filesystem mounts, and service account token.

Modify existing containers. Mutating webhooks can change the image, command, args, and env of existing containers, not just add new ones. A malicious injector could change an application image to a backdoored version while preserving the appearance of the original.

Add privileged volumes or capabilities. A webhook can add host path mounts, privileged flags, or additional Linux capabilities to a pod spec. If the injector is compromised, it can add these to every pod that requests injection — effectively escalating every workload in the cluster.

Operate across all namespaces. Webhooks with namespaceSelector: {} match all namespaces, including kube-system. A compromised webhook with cluster-wide scope can inject into system pods, potentially reaching the most privileged workloads in the cluster.

The attack surface is not purely theoretical. Research in 2024 demonstrated:

  • Istio’s injection webhook, if the istiod pod is compromised, can inject rogue proxy images
  • Dapr’s sidecar injector has had CVEs related to its operator permissions that could be used to influence injection behavior
  • Custom platform-level injectors (for log collectors, security agents) routinely run with cluster-admin equivalent permissions and have weaker security testing than the major open-source projects

The second attack angle is namespace label spoofing. Most sidecar injectors trigger based on namespace labels (e.g., istio-injection: enabled) or pod annotations. An attacker who can modify namespace labels or add annotations to a pod they control can opt in to injection — or opt out of it if the injector enforces security controls.

Target systems: Kubernetes 1.24+ clusters running Istio, Dapr, Vault agent injection, or any custom mutating webhook for sidecar injection; platform teams managing injection-based tooling.


Threat Model

Adversary 1 — Supply chain attack on injector image. Access level: the injector webhook’s container image is compromised via a supply chain attack. Objective: modify the injection logic to add a malicious sidecar container to every pod injected across the cluster.

Adversary 2 — SSRF in injector reaching Kubernetes API. Access level: network access to the injector webhook’s HTTP endpoint (possible from any pod in the cluster if NetworkPolicy is absent). Objective: send a crafted AdmissionReview request to the injector, causing it to return a modified pod spec with a rogue container.

Adversary 3 — Namespace label manipulation. Access level: a developer with namespace modification rights who removes a security-enforcement namespace label, opting the namespace out of security injection (e.g., removing security-agent: required before deploying a vulnerable workload).

Adversary 4 — Pod annotation bypass. Access level: pod-creation rights in a namespace where injection is enabled. Objective: add an annotation like sidecar.istio.io/inject: "false" to a pod to opt out of the service mesh proxy, hiding traffic from the mesh’s observability.

Without controls: injector compromise affects all injected pods; namespace label changes are undetected; injection opt-out is silent. With controls: injector image is pinned and signed; injection output is validated; namespace label changes are alerted; opt-out requires annotation review.


Configuration / Implementation

Step 1 — Audit all mutating webhooks and their scope

# List all mutating webhook configurations
kubectl get mutatingwebhookconfigurations -o json | jq -r '
  .items[] | {
    name: .metadata.name,
    webhooks: [.webhooks[] | {
      name: .name,
      namespaceSelector: .namespaceSelector,
      objectSelector: .objectSelector,
      rules: .rules,
      failurePolicy: .failurePolicy,
      sideEffects: .sideEffects
    }]
  }
'

# Find webhooks with no namespace selector (cluster-wide scope)
kubectl get mutatingwebhookconfigurations -o json | jq -r '
  .items[] | .webhooks[] |
  select(.namespaceSelector == null or .namespaceSelector == {}) |
  "WIDE-SCOPE: " + .name
'

# Check webhook TLS configuration
kubectl get mutatingwebhookconfigurations -o json | jq -r '
  .items[] | .webhooks[] |
  {name, caBundle_present: (.clientConfig.caBundle != null and .clientConfig.caBundle != "")}
'

Step 2 — Pin injector images by digest and verify signatures

# Istio control plane with pinned image digests
# values.yaml for istio helm chart
pilot:
  image: gcr.io/istio-release/pilot@sha256:abc123...  # Pin by digest
  imagePullPolicy: IfNotPresent

# Verify the injector image signature before deploying
cosign verify \
  --certificate-identity "keyless@istio-release.iam.gserviceaccount.com" \
  --certificate-oidc-issuer "https://accounts.google.com" \
  gcr.io/istio-release/pilot:1.21.2

# Apply image signature verification via Kyverno
kubectl apply -f - <<'EOF'
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-injector-images
spec:
  validationFailureAction: Enforce
  rules:
  - name: verify-istiod-image
    match:
      any:
      - resources:
          kinds: [Pod]
          namespaces: [istio-system]
    verifyImages:
    - imageReferences:
      - "gcr.io/istio-release/pilot:*"
      attestors:
      - entries:
        - keyless:
            subject: "keyless@istio-release.iam.gserviceaccount.com"
            issuer: "https://accounts.google.com"
EOF

Step 3 — Restrict injection to approved namespaces

# Limit Istio injection to namespaces explicitly labelled
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: istio-sidecar-injector
webhooks:
- name: rev.namespace.sidecar-injector.istio.io
  admissionReviewVersions: [v1]
  clientConfig:
    service:
      name: istiod
      namespace: istio-system
      path: /inject
    caBundle: BASE64_CA_BUNDLE
  # Only inject into namespaces explicitly opting in
  namespaceSelector:
    matchLabels:
      istio-injection: enabled
  # Do NOT inject into system namespaces
  # Add this explicitly to protect kube-system
  objectSelector:
    matchExpressions:
    - key: security.example.com/no-injection
      operator: DoesNotExist
  failurePolicy: Fail
  sideEffects: None
  rules:
  - apiGroups: [""]
    apiVersions: ["v1"]
    operations: [CREATE]
    resources: [pods]

Prevent namespace label modification by non-admins:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: protect-injection-labels
spec:
  validationFailureAction: Enforce
  rules:
  - name: restrict-injection-label-changes
    match:
      any:
      - resources:
          kinds: [Namespace]
          operations: [UPDATE]
    validate:
      message: "Changes to injection labels require platform team approval"
      deny:
        conditions:
          all:
          # Detect change to istio-injection label
          - key: "{{ request.object.metadata.labels.\"istio-injection\" || 'absent' }}"
            operator: NotEquals
            value: "{{ request.oldObject.metadata.labels.\"istio-injection\" || 'absent' }}"
          - key: "{{ request.userInfo.groups[] | contains(@, 'platform-team') }}"
            operator: NotEquals
            value: true

Step 4 — Validate injector output with a validating webhook

Add a validating webhook that checks every pod spec after mutation to ensure no rogue containers were injected:

#!/usr/bin/env python3
# injection-validator.py — validating webhook that audits injected pod specs

from flask import Flask, request, jsonify
import base64, json, logging

app = Flask(__name__)
logger = logging.getLogger(__name__)

# Known-good injected container image patterns per injector
ALLOWED_SIDECAR_IMAGES = {
    "istio-proxy": [
        "gcr.io/istio-release/proxyv2@sha256:",  # Digest-pinned
        "docker.io/istio/proxyv2@sha256:",
    ],
    "dapr-sidecar": [
        "ghcr.io/dapr/daprd@sha256:",
    ],
    "vault-agent": [
        "hashicorp/vault@sha256:",
        "vault@sha256:",
    ],
}

@app.route("/validate", methods=["POST"])
def validate():
    review = request.json
    pod_spec = review["request"]["object"]["spec"]
    uid = review["request"]["uid"]
    
    warnings = []
    allowed = True
    
    containers = pod_spec.get("containers", []) + pod_spec.get("initContainers", [])
    
    for container in containers:
        name = container.get("name", "")
        image = container.get("image", "")
        
        # Check if this looks like an injected sidecar (name matches known patterns)
        is_known_sidecar = any(
            name.startswith(prefix) or name == prefix
            for prefix in ALLOWED_SIDECAR_IMAGES
        )
        
        if is_known_sidecar:
            # Verify the image is from an approved source and pinned by digest
            sidecar_key = next(
                (k for k in ALLOWED_SIDECAR_IMAGES if name.startswith(k) or name == k),
                None
            )
            allowed_prefixes = ALLOWED_SIDECAR_IMAGES.get(sidecar_key, [])
            
            if not any(image.startswith(prefix) for prefix in allowed_prefixes):
                warnings.append(
                    f"Sidecar '{name}' uses unexpected image: {image}"
                )
                logger.warning(f"Unexpected sidecar image: {name}={image}")
            
            if "@sha256:" not in image:
                warnings.append(
                    f"Sidecar '{name}' image not pinned by digest: {image}"
                )
        
        # Check for dangerous capabilities in injected containers
        sec_ctx = container.get("securityContext", {})
        if sec_ctx.get("privileged"):
            warnings.append(f"Container '{name}' has privileged: true")
            allowed = False
        
        # Check for host path mounts added by injection
        for vm in pod_spec.get("volumes", []):
            if "hostPath" in vm:
                warnings.append(f"HostPath volume present: {vm['name']}")
    
    return jsonify({
        "apiVersion": "admission.k8s.io/v1",
        "kind": "AdmissionReview",
        "response": {
            "uid": uid,
            "allowed": allowed,
            "warnings": warnings if warnings else None,
            "status": {
                "message": "; ".join(warnings) if warnings and not allowed else "OK"
            }
        }
    })

Step 5 — Alert on injection opt-out anomalies

Detect when workloads opt out of injection unexpectedly:

# Falco rule — alert on injection opt-out annotation
- rule: Sidecar Injection Opt-Out
  desc: A pod was created with an annotation that opts out of sidecar injection
  condition: >
    k8s.verb = create and
    k8s.target.resource = pods and
    (
      k8s.pod.annotation["sidecar.istio.io/inject"] = "false" or
      k8s.pod.annotation["dapr.io/enabled"] = "false"
    ) and
    not k8s.user.name startswith "system:serviceaccount:platform:"
  output: >
    Pod created with injection opt-out
    (pod=%k8s.pod.name namespace=%k8s.ns.name user=%k8s.user.name
     annotation=%k8s.pod.annotation["sidecar.istio.io/inject"])
  priority: WARNING
  tags: [injection, sidecar, policy]

Expected Behaviour

Signal Before hardening After hardening
Injector webhook scope namespaceSelector: {} (all namespaces) Restricted to namespaces with istio-injection: enabled label
Injection label changed by developer No alert Kyverno blocks unless requester is in platform-team group
Injected sidecar uses mutable tag Accepted silently Validating webhook warns; digest-pinning required
Pod with privileged: true injected by compromised webhook Reaches pods Validating webhook blocks the admission
Injection opt-out annotation No visibility Falco WARNING fires

Trade-offs

Aspect Benefit Cost Mitigation
Validating webhook post-injection Catches rogue containers after mutation Adds one more webhook round-trip; latency Validate asynchronously for warnings; only block for confirmed violations
Digest pinning for sidecar images Prevents supply chain substitution Injector images must be updated on each release Automate digest updates via Renovate; test digest validation in staging before production
Namespace label protection Prevents namespace scope changes Slows legitimate namespace configuration Platform team can make label changes via GitOps; no manual namespace labelling outside GitOps

Failure Modes

Failure Symptom Detection Recovery
Validating webhook rejects legitimate injection Pod stays in Pending; events show validation denial kubectl describe pod shows admission denial Inspect rejection reason; update allowed image list for new injector version
Kyverno blocks label change for new namespace onboarding Platform team cannot label a new namespace for injection Kyverno audit log shows block Platform team runs the label change via the approved GitOps pipeline which has appropriate group membership
Injector digest mismatch after emergency update All pods in injected namespace fail admission during rolling update Widespread pod admission failures Temporarily disable digest requirement; update allowed digests; re-enable