Service Mesh Security Testing: Istio, Linkerd, and Consul Connect Vulnerabilities
The pitch for service meshes is compelling: automatic mutual TLS between every service, cryptographic workload identity, observable traffic, and centralized policy enforcement — all without changing application code. Teams deploy Istio or Linkerd and conclude that east-west traffic is secured. That conclusion is usually premature.
Service meshes secure the transport layer. They encrypt bytes in transit and authenticate workload identities at the network level. What they do not do is validate that application logic is correct, that API endpoints enforce authorization at the business layer, or that the mesh control plane itself is properly locked down. Worse, the complexity of mesh policy resources means misconfiguration is common, and a single missing or permissive policy can silently open plaintext paths through an otherwise encrypted mesh.
This guide covers the full attack surface of production service meshes: Istio PeerAuthentication and AuthorizationPolicy gaps, Envoy sidecar escape, Linkerd Server and ServerAuthorization weaknesses, Consul ACL misconfiguration, and the high-value target that sits at the center of it all — the control plane.
Istio Security Architecture
Understanding Istio's security model is prerequisite to testing it. Istio has two planes: the control plane and the data plane.
The control plane is istiod, a single binary that bundles Pilot (service discovery and xDS configuration), Citadel (the certificate authority), and Galley (configuration validation). Istiod watches the Kubernetes API server for service and policy changes, computes xDS configuration, and pushes it to Envoy sidecars via gRPC. It also runs an internal CA that issues SPIFFE SVIDs — X.509 certificates whose Subject Alternative Name encodes the workload identity as a URI of the form spiffe://cluster.local/ns/<namespace>/sa/<serviceaccount>.
The data plane is a fleet of Envoy sidecar proxies injected alongside every application container. Each Envoy intercepts all inbound and outbound traffic via iptables rules installed by an init container. The sidecar holds the workload's SPIFFE certificate and negotiates mTLS with peers using it.
Three resource types govern security behaviour:
- PeerAuthentication — controls whether mTLS is required for inbound connections. Modes: STRICT (mTLS only), PERMISSIVE (mTLS and plaintext), DISABLE (plaintext only).
- AuthorizationPolicy — controls which sources, methods, and paths are allowed to reach a workload. No policy means allow-all by default.
- DestinationRule — controls outbound TLS settings when calling a service, including whether the client side initiates mTLS.
Each of these can be misconfigured independently, and the interaction between them creates a surprisingly wide gap between "I have Istio deployed" and "my traffic is actually secured."
Istio PeerAuthentication Misconfiguration
PERMISSIVE mode is the default when Istio is first installed into a namespace. It exists so that teams can migrate incrementally — non-mesh clients can still connect in plaintext while mesh-injected services negotiate mTLS. In production, PERMISSIVE mode means an attacker with a foothold anywhere in the cluster can reach any service over plaintext, bypassing certificate-based workload identity entirely.
PeerAuthentication policies follow an inheritance hierarchy. A mesh-wide policy in the istio-system namespace applies as the baseline. Namespace-level policies override the mesh baseline for that namespace. Workload-level policies (using a selector) override the namespace policy for matched pods. If no policy exists at any level, Istio defaults to PERMISSIVE.
To test for permissive mode, check existing PeerAuthentication resources:
# List all PeerAuthentication policies across all namespaces
kubectl get peerauthentication --all-namespaces
# Check the mesh-wide policy (must be in istio-system, name must be "default")
kubectl get peerauthentication default -n istio-system -o yaml
# Check namespace-level policy
kubectl get peerauthentication default -n production -o yaml
If no mesh-wide policy exists, or if the policy shows mtls.mode: PERMISSIVE, traffic can flow in plaintext. Confirm by connecting from a debug pod that is not mesh-injected:
# Deploy a pod without sidecar injection
kubectl run attacker --image=curlimages/curl:latest \
--labels="sidecar.istio.io/inject=false" \
--restart=Never -- sleep 3600
# Attempt plaintext HTTP to a mesh-enrolled service
kubectl exec attacker -- curl -v http://payment-service.production.svc.cluster.local:8080/api/health
A successful response confirms that PERMISSIVE mode is active and that workload identity is not being enforced for this service. An attacker with pod exec capability — achievable through a compromised container or a misconfigured CI runner — can reach any PERMISSIVE service without presenting a valid certificate.
The Envoy metrics port 15090 is an additional information disclosure vector. It exposes Prometheus-format metrics including upstream cluster names, request counts, error rates, and circuit breaker state — a detailed map of service topology that aids further lateral movement planning:
kubectl exec -it <pod-name> -c istio-proxy -- curl -s http://localhost:15090/metrics | grep cluster_name
Istio AuthorizationPolicy Bypass
PeerAuthentication handles authentication — establishing that a peer holds a valid certificate. AuthorizationPolicy handles authorization — deciding whether that authenticated peer is allowed to make this specific request. In Istio's default installation, no AuthorizationPolicies are deployed. The absence of any policy means allow-all: every authenticated (and in PERMISSIVE mode, unauthenticated) workload can reach every other workload on any port and path.
When policies do exist, common bypass conditions include:
rules field or an empty rules array combined with action ALLOW behaves as allow-nothing, but a policy with action ALLOW and a single empty rule object allows everything. The distinction is subtle enough that it appears regularly in production.
# This denies everything (no rules means nothing matches the ALLOW action)
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: deny-all
namespace: production
spec:
{}
---
# This allows everything (one empty rule matches all requests)
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: accidental-allow-all
namespace: production
spec:
action: ALLOW
rules:
- {}
Namespace-scoped policies without a selector apply to all workloads in the namespace, but workload-specific policies take precedence. If a team adds an overly broad workload policy to a sensitive service, the namespace deny-all does not protect it.
Header-based authorization is particularly dangerous when headers can be injected by the caller:
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: admin-header-check
spec:
selector:
matchLabels:
app: admin-service
rules:
- when:
- key: request.headers[x-internal-user]
values: ["true"]
Any pod in the cluster — mesh-enrolled or not in PERMISSIVE mode — can set this header and gain access. The correct pattern is to authorize based on source principal (the SPIFFE identity from the client's certificate), not on headers that the application layer controls:
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: admin-service-policy
namespace: production
spec:
selector:
matchLabels:
app: admin-service
rules:
- from:
- source:
principals:
- "cluster.local/ns/production/sa/internal-gateway"
- to:
- operation:
methods: ["GET", "POST"]
paths: ["/admin/*"]
To audit authorization policy coverage, enumerate all workloads and check which have policies:
# List all services in a namespace
kubectl get svc -n production
# List AuthorizationPolicies and their selectors
kubectl get authorizationpolicy -n production -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.selector}{"\n"}{end}'
# Use istioctl to analyze policy gaps
istioctl analyze -n production
The allow-nothing pattern is the safe baseline: deploy a namespace-wide deny-all policy first, then explicitly allow only required paths between specific principals.
Envoy Sidecar Escape and Control Plane Access
Each Envoy sidecar exposes an admin interface on port 15000, bound to localhost within the pod. From a compromised container, this interface provides detailed intelligence about the mesh:
# Access the Envoy admin interface from within a compromised pod
kubectl exec -it <pod-name> -c <app-container> -- sh
# Once inside the container:
curl -s http://localhost:15000/config_dump | python3 -m json.tool | head -200
# List all upstream clusters the sidecar knows about
curl -s http://localhost:15000/clusters
# List all listeners (inbound and outbound interception rules)
curl -s http://localhost:15000/listeners
# Dump active TLS certificates held by this sidecar
curl -s http://localhost:15000/certs
The /config_dump endpoint returns the full xDS configuration: every upstream cluster, every route, every listener filter, and the complete TLS context including the workload's certificate chain and its configured CA bundle. This is a complete map of what this workload can communicate with, and the certificate material needed to impersonate it.
The istiod debug endpoints on port 8080 (exposed within the cluster, not externally by default) provide mesh-wide intelligence if reachable from the attacker's position:
# From within the cluster, test reachability of istiod debug endpoint
curl -s http://istiod.istio-system.svc.cluster.local:8080/debug/syncz
curl -s http://istiod.istio-system.svc.cluster.local:8080/debug/config_dump
curl -s http://istiod.istio-system.svc.cluster.local:8080/debug/registryz
If these endpoints are accessible, an attacker can enumerate every service registration in the mesh, every connected Envoy proxy and its sync state, and the full configuration being pushed to each proxy. The debug interface was unauthenticated in older Istio versions; recent versions require an Istio-signed token, but misconfigurations exist.
mTLS certificate extraction is possible from a compromised sidecar process. The SPIFFE SVID private key is held in memory by the Envoy process and also written to a mounted volume (/var/run/secrets/workload-spiffe-credentials/ in newer Istio versions). With access to that key material, an attacker can impersonate the workload identity to any service that trusts the same mesh CA.
Linkerd Security Model and Weaknesses
Linkerd takes a deliberately simpler approach than Istio. Its automatic mTLS is on by default for all meshed pods with no policy configuration required — a better default posture than Istio. Linkerd's control plane (linkerd-proxy) issues certificates via a trust anchor (root CA) and an intermediary issuer. The data plane proxies are written in Rust rather than C++, which reduces memory-safety attack surface.
However, Linkerd's authorization model has its own gap patterns. In Linkerd 2.x, policy is expressed via Server and ServerAuthorization resources (or the newer HTTPRoute/AuthorizationPolicy in the Gateway API model). A Server resource defines a port on a set of pods. A ServerAuthorization resource grants access to that Server from specified clients.
apiVersion: policy.linkerd.io/v1beta1
kind: Server
metadata:
name: payment-api
namespace: production
spec:
podSelector:
matchLabels:
app: payment-service
port: 8080
proxyProtocol: HTTP/2
---
apiVersion: policy.linkerd.io/v1beta1
kind: ServerAuthorization
metadata:
name: payment-api-allow-checkout
namespace: production
spec:
server:
name: payment-api
client:
meshTLS:
serviceAccounts:
- name: checkout-service
namespace: production
The weakness: if no Server resource is defined for a port, Linkerd's default policy applies — and the default can be all-unauthenticated or all-authenticated depending on installation configuration. Check the default policy:
# Check linkerd installation configuration
kubectl get configmap linkerd-config -n linkerd -o jsonpath='{.data.values}' | grep -A5 proxy
# Enumerate Server resources
kubectl get server --all-namespaces
# Identify ports with no Server resource (and therefore governed only by default policy)
# Cross-reference against all pod port definitions
kubectl get pods --all-namespaces -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\t"}{range .spec.containers[*].ports[*]}{.containerPort}{","}{end}{"\n"}{end}'
The Linkerd viz dashboard is an information disclosure risk in many deployments. It exposes service topology, success rates, latency percentiles, and traffic volumes for every meshed service. The dashboard defaults to no authentication in many configurations:
# Check if the viz dashboard is accessible without authentication
kubectl port-forward -n linkerd-viz svc/web 8084:8084
# Then browse http://localhost:8084 — if accessible without credentials, it's a finding
# The tap API (traffic inspection) is also worth testing
linkerd viz tap deploy/payment-service -n production
The linkerd viz tap command can capture live request headers, paths, and response codes in real time. If the tap RBAC permissions are too broad, any user with kubectl access can intercept request-level traffic across the mesh.
# Check RBAC permissions on the tap API
kubectl auth can-i watch pods.tap.linkerd.io --all-namespaces
kubectl get clusterrolebinding | grep linkerd-viz
Consul Connect Security
HashiCorp Consul Connect uses a different trust model than Kubernetes-native meshes. Consul's security depends heavily on its ACL system, and when ACLs are disabled or use default-allow tokens, the attack surface is significant.
The first thing to check in a Consul deployment is whether ACLs are enabled and the default policy:
# Check Consul ACL configuration
kubectl exec -it consul-server-0 -n consul -- consul acl policy list
# Check if the anonymous token (used for unauthenticated requests) has broad permissions
kubectl exec -it consul-server-0 -n consul -- \
consul acl token read -id anonymous
# List all ACL tokens (requires management token)
kubectl exec -it consul-server-0 -n consul -- consul acl token list
The management token is the highest-privilege credential in a Consul cluster — equivalent to cluster-admin in Kubernetes. It's created during ACL bootstrapping and must be stored securely. A common finding is the management token stored in a Kubernetes Secret with overly broad RBAC access:
# Check for Consul tokens stored as Kubernetes Secrets
kubectl get secrets --all-namespaces | grep consul
# Read the bootstrap token if accessible
kubectl get secret consul-bootstrap-acl-token -n consul -o jsonpath='{.data.token}' | base64 -d
Consul intentions control which services can communicate. An intention is a whitelist or blacklist rule: "service A is allowed to connect to service B." The bypass vector is service registration. If Consul's ACL system allows any authenticated client to register a service with an arbitrary name, an attacker can register a malicious service under a name that existing intentions permit:
# Register a rogue service claiming to be an internal trusted service
curl -X PUT http://consul.service.consul:8500/v1/agent/service/register \
-H "X-Consul-Token: <low-privilege-token>" \
-d '{
"Name": "internal-auth-service",
"Address": "10.0.0.attacker",
"Port": 8080
}'
If intentions allow any instance of internal-auth-service to reach payment services, the rogue registration inherits that permission. Proper mitigations require ACL policies that restrict which tokens can register which service names.
The Consul HTTP API and UI, when ACLs are disabled or using the anonymous default-allow policy, expose the full service catalog, key-value store, and health endpoint data without authentication. Test with:
# Enumerate services without authentication
curl -s http://consul.service.consul:8500/v1/catalog/services | python3 -m json.tool
# Read key-value store (may contain secrets, config data)
curl -s http://consul.service.consul:8500/v1/kv/?recurse=true | python3 -m json.tool
# List all nodes
curl -s http://consul.service.consul:8500/v1/catalog/nodes | python3 -m json.tool
Control Plane Compromise
The control plane — istiod, the Linkerd control plane, or Consul servers — is the highest-value target in a service mesh deployment. Compromise of the control plane means control over the mesh CA, the ability to issue arbitrary workload certificates, and the ability to modify xDS configuration pushed to every sidecar.
The most common path to control plane compromise is not a direct exploit of istiod itself but a pivot through the Kubernetes API server:
- Compromise a pod via application vulnerability (RCE, SSRF to metadata endpoint, etc.)
- Read the pod's service account token from
/var/run/secrets/kubernetes.io/serviceaccount/token - Use that token to query the Kubernetes API
- If the service account has RBAC permissions over istio-system resources, escalate to control plane access
# From a compromised pod — check what the service account can do
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://kubernetes.default.svc
# Check permissions
curl -s $APISERVER/apis/authorization.k8s.io/v1/selfsubjectaccessreviews \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","spec":{"resourceAttributes":{"namespace":"istio-system","verb":"get","resource":"secrets"}}}'
# If the service account can read secrets in istio-system, extract the CA key
curl -s $APISERVER/api/v1/namespaces/istio-system/secrets/istio-ca-secret \
-H "Authorization: Bearer $TOKEN"
The istio-ca-secret contains the root CA private key and certificate for the mesh. With this material, an attacker can mint SPIFFE SVIDs for any workload identity in the mesh — effectively impersonating any service. Every AuthorizationPolicy that grants access based on principal becomes bypassable.
Certificate Signing Request manipulation is a subtler attack. Envoy sidecars request new certificates from istiod via a gRPC CSR flow. An attacker with network access to istiod's gRPC port (15010, or 15012 for TLS) who can present a valid bootstrap credential can submit a CSR for an identity they don't legitimately hold. Istio's certificate signing path validates that the requesting pod actually holds the service account identity it claims — but bugs in this validation have appeared historically.
Lateral Movement Through the Mesh
After gaining a foothold in a meshed pod, the priority is understanding what east-west connectivity exists and which services are reachable. The mesh policy audit tools make this faster than port scanning:
# Use istioctl to show effective access policies for a workload
istioctl x authz check <pod-name>.production
# Show what a specific service account is allowed to reach
istioctl x authz check <pod-name> --all-namespaces
# Use the proxy config command to see upstream clusters from a pod's perspective
istioctl proxy-config cluster <pod-name>.production
# Show all routes the sidecar knows about
istioctl proxy-config route <pod-name>.production
From within a compromised pod, test direct connectivity to map implicit mesh access:
# Test connectivity to every service in the namespace
for svc in $(kubectl get svc -n production -o jsonpath='{.items[*].metadata.name}'); do
echo -n "Checking $svc: "
curl -s -o /dev/null -w "%{http_code}" --max-time 3 \
http://$svc.production.svc.cluster.local/ 2>/dev/null || echo "timeout"
done
If a compromised workload holds a SPIFFE certificate for a highly-trusted identity (e.g., an ingress gateway or an internal admin service), and AuthorizationPolicies are permissive or absent, that certificate can be used to reach services that would be unreachable from a lower-trust workload. The extracted certificate and key from the sidecar's credential volume can be loaded into curl for this purpose:
# Use the workload's SPIFFE certificate to authenticate to a peer service
curl --cert /var/run/secrets/workload-spiffe-credentials/certificates.pem \
--key /var/run/secrets/workload-spiffe-credentials/private_key.pem \
--cacert /var/run/secrets/workload-spiffe-credentials/ca_certificates.pem \
https://payment-service.production.svc.cluster.local:8080/admin/users
Service Mesh Penetration Testing Methodology
Structure a service mesh engagement as an ordered sequence of phases:
Phase 1: Reconnaissance
# Identify which mesh is deployed and its version
kubectl get pods -n istio-system
kubectl get pods -n linkerd
kubectl get pods -n consul
# For Istio, check version
istioctl version
# Enumerate all mesh-enrolled namespaces (Istio label)
kubectl get namespace -L istio-injection
# Enumerate all Linkerd-meshed pods
kubectl get pods --all-namespaces -l linkerd.io/proxy-injector.linkerd.io/init-container-name
Phase 2: Policy Audit
# Istio — audit all security resources
kubectl get peerauthentication --all-namespaces -o yaml
kubectl get authorizationpolicy --all-namespaces -o yaml
kubectl get destinationrule --all-namespaces -o yaml
# Check for mesh-wide deny-all baseline
kubectl get authorizationpolicy -n istio-system
# Run Istio's built-in analyzer
istioctl analyze --all-namespaces
# Linkerd — audit Server and ServerAuthorization resources
kubectl get server --all-namespaces -o yaml
kubectl get serverauthorization --all-namespaces -o yaml
# Check default policy
kubectl get configmap linkerd-config -n linkerd -o yaml
Phase 3: Connectivity Testing
# Deploy test pods with and without sidecar injection
kubectl run meshed-test --image=curlimages/curl:latest \
--labels="sidecar.istio.io/inject=true" --restart=Never -- sleep 3600
kubectl run unmeshed-test --image=curlimages/curl:latest \
--labels="sidecar.istio.io/inject=false" --restart=Never -- sleep 3600
# Test whether unmeshed pod can reach services (PERMISSIVE mode indicator)
kubectl exec unmeshed-test -- curl -v -m 5 http://<service>.<namespace>.svc.cluster.local/
Phase 4: Control Plane Access
# Attempt to reach istiod debug endpoints
kubectl run debug --image=curlimages/curl:latest --restart=Never -- \
curl -sv http://istiod.istio-system.svc.cluster.local:8080/debug/registryz
# Check if the Envoy admin port is reachable from adjacent pods
kubectl exec -it <adjacent-pod> -- curl -sv http://<target-pod-ip>:15000/stats
Phase 5: Certificate Inspection
# Dump SPIFFE certificate from sidecar
kubectl exec <pod> -c istio-proxy -- \
openssl s_client -connect <peer-service>:<port> -showcerts 2>/dev/null | \
openssl x509 -text -noout
# Extract certificate from Envoy admin
kubectl exec <pod> -c istio-proxy -- \
curl -s http://localhost:15000/certs | python3 -m json.tool
# Check certificate expiry across the mesh
istioctl proxy-config secret <pod>.<namespace>
Phase 6: Monitoring Evasion Assessment
- Check whether Envoy access logs are collected and where they go
- Verify that the mesh metrics (Prometheus/Grafana) cover east-west traffic
- Test whether internal lateral movement generates alerts distinct from normal service-to-service traffic
- Assess whether control plane audit logs (istiod, Consul API calls) are captured in SIEM
Hardening Recommendations
| Control | Recommendation | Priority |
|---|---|---|
| Istio PeerAuthentication | Deploy mesh-wide STRICT mode policy in istio-system. Never run PERMISSIVE in production. | Critical |
| Istio AuthorizationPolicy | Deploy namespace-wide deny-all as default, then add explicit ALLOW rules per workload and source principal. | Critical |
| Envoy admin port | Block port 15000 at the network policy layer. Restrict 15090 (metrics) to the monitoring namespace only. | High |
| istiod debug endpoints | Disable or restrict port 8080 to the istio-system namespace only via NetworkPolicy. | High |
| Linkerd default policy | Set defaultInboundPolicy to deny or cluster-authenticated in the Helm values. Define explicit Server and ServerAuthorization for all exposed ports. |
High |
| Linkerd viz dashboard | Enable authentication on the viz dashboard. Restrict the tap API RBAC to named users or service accounts only. | High |
| Consul ACLs | Enable ACLs with default-deny policy. Rotate the bootstrap management token. Store it in a secrets manager, not a Kubernetes Secret. | Critical |
| Consul service registration | Restrict service registration ACL policies so that tokens can only register services matching their intended name. | High |
| Control plane RBAC | Audit service account permissions. No application workload should be able to read istio-system secrets or modify mesh configuration resources. | Critical |
| Certificate material | Use short-lived certificates (default Istio is 24h). Monitor for anomalous CSR activity. Consider external CA (cert-manager + Vault) for the root. | Medium |
Minimum Viable Secure Istio Baseline
# 1. Enforce STRICT mTLS mesh-wide
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
mtls:
mode: STRICT
---
# 2. Deny all traffic by default (apply per namespace)
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: deny-all
namespace: production
spec:
{}
---
# 3. Then explicitly allow required paths
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: allow-checkout-to-payment
namespace: production
spec:
selector:
matchLabels:
app: payment-service
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/production/sa/checkout-service"
- to:
- operation:
methods: ["POST"]
paths: ["/api/v1/charge"]
Apply these three resources in sequence — mesh-wide STRICT, namespace deny-all, then explicit allows — and you have a defensible baseline. The audit and penetration testing process above is how you verify that the baseline is complete and that no workload has slipped through without coverage.
Service meshes secure the network layer — but your microservices still expose HTTP and API endpoints with their own authentication logic, input handling, and business rules. A misconfigured mTLS policy and an injectable parameter in your payment API are both critical findings, and the mesh won't catch the second one.
Ironimo scans the HTTP and API surfaces of your microservices — the application-layer endpoints that sit behind the mesh — for injection vulnerabilities, authentication weaknesses, and API security issues that the mesh itself doesn't address. No manual setup required.
Start free scan