cert-manager Security Testing: ACME Account Key Exposure, CertificateRequest Privilege Escalation, and Issuer Misconfiguration

cert-manager automates TLS certificate issuance and renewal in Kubernetes, integrating with ACME (Let's Encrypt), Vault, Venafi, and self-signed CA issuers. While it removes certificate management toil, it also centralizes sensitive credential material: ACME account private keys, DNS01 solver cloud credentials, and CA private keys all reside as Kubernetes secrets. A compromise of cert-manager's service account or the credential secrets it manages can enable certificate forgery, DNS manipulation, or cluster-wide PKI compromise. This guide covers systematic cert-manager security assessment.

Table of Contents

  1. Issuer and ClusterIssuer Audit
  2. ACME Account Key Exposure
  3. DNS01 Solver Credential Testing
  4. CA Issuer Private Key Exposure
  5. CertificateRequest Approval Escalation
  6. ClusterIssuer Namespace Isolation Testing
  7. Certificate Expiry and Rotation Gaps
  8. cert-manager Security Hardening

Issuer and ClusterIssuer Audit

# Enumerate all Issuers and ClusterIssuers
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    meta = i['metadata']
    spec = i.get('spec',{})
    kind = list(spec.keys())[0] if spec else 'unknown'
    print(f\"Issuer: {meta['namespace']}/{meta['name']} type: {kind}\")
    if 'acme' in spec:
        acme = spec['acme']
        print(f\"  ACME server: {acme.get('server','?')}\")
        print(f\"  Account key: {acme.get('privateKeySecretRef',{}).get('name','?')}\")
    if 'ca' in spec:
        ca = spec['ca']
        print(f\"  CA secret: {ca.get('secretName','?')}\")
"

kubectl get clusterissuers -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    meta = i['metadata']
    spec = i.get('spec',{})
    kind = list(spec.keys())[0] if spec else 'unknown'
    print(f\"ClusterIssuer: {meta['name']} type: {kind}\")
    # ClusterIssuer: cluster-scoped, can be referenced from any namespace
    # Credential secrets are read by cert-manager controller, stored in cert-manager namespace
"

ACME Account Key Exposure

# ACME issuers store the ACME account private key as a Kubernetes secret
# The private key is used to sign all ACME certificate requests to Let's Encrypt
# Theft allows registering new certificates under the compromised account

# Find ACME account key secrets
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    acme = i.get('spec',{}).get('acme',{})
    if acme:
        ns = i['metadata']['namespace']
        key_secret = acme.get('privateKeySecretRef',{}).get('name','?')
        print(f\"{ns}/{key_secret} — ACME account key for {acme.get('server','?')}\")
"

# Also check ClusterIssuers (key stored in cert-manager namespace)
kubectl get clusterissuers -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    acme = i.get('spec',{}).get('acme',{})
    if acme:
        key_secret = acme.get('privateKeySecretRef',{}).get('name','?')
        print(f\"cert-manager/{key_secret} — ClusterIssuer ACME key for {i['metadata']['name']}\")
"

# Extract the ACME account private key
kubectl get secret letsencrypt-prod-acme-key -n cert-manager -o json | python3 -c "
import json,sys,base64
secret = json.load(sys.stdin)
for k,v in secret.get('data',{}).items():
    print(f'{k}: {base64.b64decode(v).decode()[:100]}...')
"
# tls.key contains the ACME JWK private key (EC P-256 or RSA)
# With this key: forge ACME signed requests to issue certificates for domains the account controls

DNS01 Solver Credential Testing

# DNS01 ACME challenge solvers need DNS provider credentials to create TXT records
# These credentials often have broad DNS zone permissions

# Find DNS01 solver credentials
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    solvers = i.get('spec',{}).get('acme',{}).get('solvers',[])
    for s in solvers:
        dns01 = s.get('dns01',{})
        if dns01:
            meta = i['metadata']
            print(f\"Issuer: {meta['namespace']}/{meta['name']} uses DNS01\")
            # Route53 auth
            if 'route53' in dns01:
                r53 = dns01['route53']
                secret_ref = r53.get('secretAccessKeySecretRef',{})
                role_arn = r53.get('role','')
                print(f\"  Route53 region: {r53.get('region','?')}\")
                print(f\"  Access key secret: {secret_ref.get('namespace',meta['namespace'])}/{secret_ref.get('name','?')}\")
                if role_arn:
                    print(f\"  IAM role ARN: {role_arn}\")
            # Cloudflare auth
            if 'cloudflare' in dns01:
                cf = dns01['cloudflare']
                token_ref = cf.get('apiTokenSecretRef',{})
                print(f\"  Cloudflare token secret: {token_ref.get('namespace',meta['namespace'])}/{token_ref.get('name','?')}\")
"

# Extract DNS credentials and check their scope
# Route53 credentials with iam:GetPolicy
kubectl get secret route53-credentials -n cert-manager -o json | python3 -c "
import json,sys,base64
s = json.load(sys.stdin)
for k,v in s.get('data',{}).items():
    print(f'{k}: {base64.b64decode(v).decode()}')
"
# Dangerous if IAM policy allows:
# - route53:ChangeResourceRecordSets on *.example.com (entire zone)
# - route53:ListHostedZones (exposes all DNS zones)
# Should be scoped to only the specific hosted zone ID and TXT record type

CA Issuer Private Key Exposure

# CA Issuers use a CA private key stored in a Kubernetes secret
# Theft = ability to sign arbitrary certificates as that CA

# Find CA Issuer secrets
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
    ca = i.get('spec',{}).get('ca',{})
    if ca:
        meta = i['metadata']
        print(f\"CA Issuer: {meta['namespace']}/{meta['name']} CA secret: {ca.get('secretName','?')}\")
"

# Extract CA private key and certificate
CA_SECRET=example-ca-secret
NAMESPACE=cert-manager

kubectl get secret $CA_SECRET -n $NAMESPACE -o json | python3 -c "
import json,sys,base64
s = json.load(sys.stdin)
tls_crt = base64.b64decode(s['data']['tls.crt']).decode()
tls_key = base64.b64decode(s['data']['tls.key']).decode()
print('Cert (first 200 chars):', tls_crt[:200])
print('Key (first 50 chars):', tls_key[:50])  # Never log full private key
"

# With CA private key: forge certificates for any service in the cluster
# that trusts this CA (often used for internal mTLS)
openssl x509 -in /tmp/ca.crt -text -noout | grep -A 5 "Basic Constraints"
# CA:TRUE = full certificate authority, can sign any cert
# pathLen:0 = can sign end-entity certs but not subordinate CAs

# Check which namespaces trust this CA (mounted as CA bundle)
kubectl get configmaps --all-namespaces -o json | python3 -c "
import json,sys
cms = json.load(sys.stdin)['items']
for cm in cms:
    if 'ca-bundle' in cm['metadata'].get('name','').lower() or \
       'ca.crt' in cm.get('data',{}):
        print(f\"{cm['metadata']['namespace']}/{cm['metadata']['name']}: CA bundle\")
"

CertificateRequest Approval Escalation

# cert-manager v1.3+ introduced CertificateRequest approval
# CertificateRequests must be approved before cert-manager issues a certificate
# Test who can approve and whether self-approval is possible

# Check CertificateRequest approval RBAC
kubectl auth can-i update certificaterequests/approval \
  --as=system:serviceaccount:developer:developer-sa \
  -n developer

# If allowed: a developer can create AND approve their own CertificateRequest
# = issue certificates for any domain the Issuer is authorized to sign

# Test: self-approval of a CertificateRequest for a sensitive domain
kubectl apply -f - </dev/null | base64 | tr -d '\n')
  issuerRef:
    name: cluster-ca  # References ClusterIssuer
    kind: ClusterIssuer
EOF

# Then attempt self-approval
kubectl certificate approve escalation-test -n developer
# If succeeds: developer gets a cert for admin.cluster.local signed by cluster CA

# Check auto-approver configuration
# cert-manager-approver-policy or built-in cert-manager approver may auto-approve
kubectl get certificaterequestpolicies --all-namespaces 2>/dev/null
kubectl get clusterrolebindings | grep "cert-manager:cert-manager-approver"

cert-manager Security Hardening

cert-manager Security Hardening Checklist:
Security TestMethodRisk
CA Issuer private key readable by developer SAkubectl auth can-i get secrets -n cert-managerCritical
Developer can self-approve CertificateRequestskubectl auth can-i update certificaterequests/approvalCritical
DNS01 IAM credential has full zone ChangeResourceRecordSetsAWS IAM policy check on Route53 credentialsHigh
ACME account private key accessible cluster-widekubectl get secret ACME-KEY-SECRET -n cert-managerHigh
ClusterIssuer without CertificateRequestPolicy restrictionskubectl get certificaterequestpoliciesHigh
cert-manager SA can read all secrets (not just cert namespaces)Check cert-manager ClusterRole resourcesMedium

Automate cert-manager Security Testing

Ironimo tests cert-manager deployments for ACME account key exposure, DNS01 solver IAM over-privilege, CA private key accessibility, CertificateRequest approval escalation paths, ClusterIssuer namespace isolation gaps, and certificate rotation blind spots.

Start free scan