If Kubernetes is the operating system of the cloud, Helm is its package manager — and like most package managers, it was designed for developer convenience first and security second. Today, Helm 3 manages the majority of production Kubernetes workloads, and chart misconfigurations are so widespread that they show up in virtually every Kubernetes penetration test.
This guide walks through a structured security audit of Helm deployments: from enumerating releases and extracting leaked secrets, to auditing template security contexts, evaluating supply chain integrity, and identifying RBAC escalation paths through Helm hooks. Each phase includes the exact commands security engineers and pentesters use in real engagements.
Why Helm Is a High-Value Attack Surface
Helm abstracts Kubernetes manifests behind a templating layer that makes complex deployments repeatable. This is powerful — and dangerous. When a chart author encodes a secret as a default value, embeds a privileged container, or ships with a permissive RBAC binding, every cluster that installs that chart inherits the vulnerability.
The threat model for Helm security testing covers four distinct layers:
- Release metadata and secret exposure — Helm stores release state as Kubernetes Secrets. Anyone who can read secrets in a namespace can reconstruct the full rendered manifest, including all values passed at install time.
- Template misconfiguration — Default chart templates frequently omit security contexts, run containers as root, and grant excess RBAC permissions to service accounts.
- Supply chain integrity — Helm chart repositories, like npm or PyPI, can serve tampered packages. Unsigned charts from public repositories are a legitimate supply chain attack vector.
- Hook and upgrade abuse — Pre-install and post-upgrade hooks run arbitrary workloads with the permissions of the deploying service account, which is often cluster-admin in CI/CD pipelines.
Phase 1: Helm Reconnaissance — Enumerating the Attack Surface
The starting point for any Helm security audit is understanding what is deployed and where. With appropriate RBAC permissions, this information is accessible to any pod running in the cluster.
Listing All Releases
List all Helm releases across all namespaces:
# All releases across all namespaces
helm ls --all-namespaces
# Show releases including failed and superseded states
helm ls --all-namespaces --all
# Output as JSON for scripting
helm ls --all-namespaces -o json | jq '.[] | {name: .name, namespace: .namespace, chart: .chart, status: .status, revision: .revision}'
From an attacker's perspective, a cluster with many third-party charts is high-value reconnaissance. Chart names and versions map directly to known CVEs. A cluster running an unpatched ingress-nginx version, for example, may be vulnerable to IngressNightmare (CVE-2025-1974).
Enumerating Release History
Helm stores every revision of a release as a Kubernetes Secret in the target namespace. The release history often contains previous values — including secrets that were rotated but never purged:
# View revision history for a specific release
helm history <release-name> -n <namespace>
# Get a specific historical revision's values
helm get values <release-name> -n <namespace> --revision 1
# Get all data from a specific revision
helm get all <release-name> -n <namespace> --revision 1
Helm 3 stores release secrets with the label owner=helm and the naming convention sh.helm.release.v1.<release-name>.v<revision>. Enumerating these directly via kubectl bypasses Helm CLI access controls entirely:
# Enumerate all Helm release secrets directly
kubectl get secrets --all-namespaces -l owner=helm -o json | \
jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) [\(.metadata.labels.status)]"'
Phase 2: Secret Exposure in Helm Releases
This is the most critical phase. Helm secrets are not encrypted — they are gzip-compressed and base64-encoded Kubernetes Secrets. Any principal with get or list on Secrets in a namespace can read the full release state, including every value passed at install time.
Extracting Helm Release Secrets
# Extract and decode a Helm release secret
kubectl get secret sh.helm.release.v1.<release>.v1 \
-n <namespace> -o jsonpath='{.data.release}' | \
base64 -d | base64 -d | gunzip | jq .
# Extract just the config values (user-supplied values)
kubectl get secret sh.helm.release.v1.<release>.v1 \
-n <namespace> -o jsonpath='{.data.release}' | \
base64 -d | base64 -d | gunzip | jq '.config'
The decoded release contains the full chart manifest, the computed values, and the rendered Kubernetes resources. Credentials, API keys, database passwords, and TLS private keys that were passed as --set flags or values files at install time are all present in plaintext within this structure.
Helm Get Values — Direct Credential Exposure
# Retrieve user-supplied values (may contain secrets)
helm get values <release-name> -n <namespace>
# Retrieve all values including chart defaults
helm get values <release-name> -n <namespace> --all
# Look for common secret patterns across all releases
for release in $(helm ls --all-namespaces -q); do
ns=$(helm ls --all-namespaces | grep "^$release" | awk '{print $2}')
echo "=== $release ($ns) ==="
helm get values "$release" -n "$ns" 2>/dev/null | \
grep -iE 'password|secret|key|token|credential|auth'
done
Helm 2 Tiller History Attacks (Legacy Environments)
Helm 2's Tiller component ran as a server-side pod with cluster-admin privileges. If any legacy Helm 2 deployments survive in your environment, the Tiller service account is an immediate privilege escalation path:
# Check for surviving Tiller deployments (Helm 2 legacy)
kubectl get pods --all-namespaces | grep tiller
kubectl get serviceaccounts --all-namespaces | grep tiller
kubectl get clusterrolebindings | grep tiller
# If Tiller exists, check its permissions
kubectl describe clusterrolebinding tiller
Tiller in default configurations holds cluster-admin. Any pod that can reach the Tiller gRPC endpoint (typically tiller-deploy.kube-system:44134) can issue arbitrary Helm commands as cluster-admin.
Values.yaml Secrets Committed to Git
A large proportion of secret exposure in Helm environments originates not in the cluster but in the chart repository. Teams frequently commit production values files with live credentials:
# Search for Helm values files with potential secrets in git history
git log --all --full-history -- '**/values*.yaml' | head -20
git grep -i 'password\|secret\|token\|apiKey' -- '*.yaml' HEAD
# Scan for secrets across entire repo history using trufflehog
trufflehog git file://. --only-verified
# Or gitleaks
gitleaks detect --source . --verbose
Check for values-prod.yaml, values.override.yaml, and environment-specific overrides that are frequently gitignored inconsistently across team members.
Phase 3: Misconfigured Chart Templates
Chart template misconfigurations translate directly into container escape vectors and privilege escalation paths. The following patterns appear most frequently in security audits.
Rendering Templates for Inspection
Before deploying, or as part of a running deployment audit, render the full manifest to inspect security settings:
# Render a chart locally without deploying
helm template <release-name> ./my-chart -f values.yaml | \
kubectl-score score -
# Render and grep for security-relevant fields
helm template <release-name> ./my-chart -f values.yaml | \
grep -E 'privileged|hostPath|hostNetwork|hostPID|runAsRoot|runAsUser: 0|allowPrivilegeEscalation'
# Get the live manifest of a deployed release
helm get manifest <release-name> -n <namespace>
Privileged Containers and Host Namespace Access
Privileged containers have access to the host kernel and can escape to the node. Check deployed manifests for these configurations:
# Find privileged containers in all running pods
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.containers[].securityContext.privileged == true) |
"\(.metadata.namespace)/\(.metadata.name)"'
# Find pods with hostPath volume mounts (filesystem access to the node)
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.volumes[]?.hostPath != null) |
"\(.metadata.namespace)/\(.metadata.name): \(.spec.volumes[].hostPath.path // empty)"'
# Find pods with host network access
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.hostNetwork == true) |
"\(.metadata.namespace)/\(.metadata.name)"'
Missing Security Contexts and Root Containers
# Find containers running as root (UID 0 or no runAsNonRoot enforcement)
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | . as $pod | .spec.containers[] |
select((.securityContext.runAsUser == 0) or (.securityContext.runAsNonRoot != true)) |
"\($pod.metadata.namespace)/\($pod.metadata.name): \(.name)"'
# Find containers missing allowPrivilegeEscalation: false
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | . as $pod | .spec.containers[] |
select(.securityContext.allowPrivilegeEscalation != false) |
"\($pod.metadata.namespace)/\($pod.metadata.name): \(.name)"'
Default Service Accounts with Excess RBAC
Many Helm charts create service accounts with overly broad permissions — or worse, bind workloads to the default service account that already has accumulated bindings from other deployments:
# Enumerate RBAC bindings across all service accounts
kubectl get rolebindings,clusterrolebindings --all-namespaces -o json | \
jq -r '.items[] | select(.subjects[]?.kind == "ServiceAccount") |
"\(.kind) \(.metadata.name): \(.subjects[].name) in \(.subjects[].namespace // "cluster-wide")"'
# Check what permissions the default service account has in a namespace
kubectl auth can-i --list --as=system:serviceaccount:<namespace>:default -n <namespace>
# Enumerate all service account tokens mounted in pods (auto-mount)
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.automountServiceAccountToken != false) |
"\(.metadata.namespace)/\(.metadata.name) [automount: true]"'
Service accounts with automountServiceAccountToken: true (the default) expose a bearer token at /var/run/secrets/kubernetes.io/serviceaccount/token inside the container. A container escape from any such pod yields immediate API server access with that account's permissions.
Phase 4: Supply Chain Risks in Helm Chart Repositories
The Helm chart supply chain extends from the chart author's repository through the packaging and signing pipeline to your cluster's chart repository configuration. Each link can be tampered with.
Chart Repository Poisoning
Helm repositories serve an index.yaml that lists available charts and their download URLs. If that index or the referenced tarballs are served over HTTP (not HTTPS), or if a repository's DNS is compromised, an attacker can serve a malicious chart:
# Audit configured Helm repositories
helm repo list
# Check for HTTP (non-TLS) repositories
helm repo list | grep 'http://'
# Fetch a chart and inspect its contents before installing
helm fetch <repo>/<chart> --untar --untardir /tmp/chart-inspect
find /tmp/chart-inspect -name '*.yaml' -exec cat {} \;
# Verify chart checksums against index.yaml
helm fetch <repo>/<chart> --prov # download provenance file
helm verify /path/to/chart.tgz # verify against provenance
Unsigned Charts and Artifact Hub Verification
Helm supports chart signing via PGP provenance files (.prov). Most publicly available charts are not signed. When evaluating a third-party chart, check Artifact Hub for security annotations:
# Check if a chart has a provenance file
helm fetch <repo>/<chart> --prov 2>&1 | grep -i 'provenance\|error'
# Inspect Artifact Hub security annotations (via their API)
curl -s "https://artifacthub.io/api/v1/packages/helm/<repo>/<chart>" | \
jq '.security_report_summary'
# For charts using Cosign (OCI-signed charts)
cosign verify <oci-registry>/charts/<chart>:<tag> \
--certificate-identity-regexp '.*' \
--certificate-oidc-issuer-regexp '.*'
Dependency Tampering: Chart.lock vs Chart.yaml Hash Mismatches
Helm chart dependencies are declared in Chart.yaml and locked in Chart.lock. A mismatch between these files — or a Chart.lock missing from source control — creates dependency confusion risk:
# Check for dependency lock file presence
find . -name 'Chart.yaml' | while read chart; do
dir=$(dirname "$chart")
if [ ! -f "$dir/Chart.lock" ] && grep -q 'dependencies:' "$chart"; then
echo "WARNING: $chart has dependencies but no Chart.lock"
fi
done
# Verify dependency digests match the lock file
helm dependency verify ./my-chart
# Audit dependency sources for non-official repositories
cat Chart.yaml | yq '.dependencies[] | "\(.name): \(.repository)"'
Pay particular attention to charts that pull dependencies from file:// paths or from unauthenticated internal repositories — these are common vectors for dependency confusion attacks in monorepo CI/CD environments.
Phase 5: Helm Hooks and RBAC Escalation
Helm hooks (pre-install, post-install, pre-upgrade, post-upgrade, etc.) run Jobs or Pods as part of the deployment lifecycle. These hooks inherit the permissions of the deploying service account — which in most CI/CD pipelines is a high-privilege account.
Enumerating Hook Resources
# List hook resources in a deployed chart
helm get hooks <release-name> -n <namespace>
# Find hook annotations in a chart's templates
find ./my-chart/templates -name '*.yaml' | \
xargs grep -l '"helm.sh/hook"' | \
xargs grep -E 'helm.sh/hook|helm.sh/hook-weight|helm.sh/hook-delete-policy'
# Inspect what Jobs were created by hooks in the cluster
kubectl get jobs --all-namespaces -o json | \
jq -r '.items[] | select(.metadata.annotations["helm.sh/chart"] != null) |
"\(.metadata.namespace)/\(.metadata.name)"'
Hook Privilege Escalation Patterns
The most dangerous hook pattern is a database migration job or schema initializer that runs with a service account bound to cluster-admin. An attacker who can modify the chart values or template at upgrade time can inject arbitrary workloads:
# Check the service account used by hook jobs
kubectl get jobs --all-namespaces -o json | \
jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): SA=\(.spec.template.spec.serviceAccountName // "default")"'
# For each hook SA, enumerate its effective permissions
kubectl auth can-i --list \
--as=system:serviceaccount:<namespace>:<hook-service-account>
Helm Upgrade Injection
In environments where an attacker controls chart values (via a compromised values repository, a misconfigured GitOps pipeline, or a chart that exposes arbitrary image configuration), helm upgrade becomes a remote code execution primitive. Testing this involves verifying that:
- Image references in values are constrained to approved registries via OPA/Gatekeeper policies
- The CI/CD pipeline validates chart source integrity before upgrade
- Values files are not sourced from user-controllable storage without integrity verification
# Check for open image configuration in chart values
helm get values <release-name> -n <namespace> --all | \
grep -A3 'image:'
# Audit OCI registry admission policies
kubectl get constrainttemplates.templates.gatekeeper.sh
kubectl get constraints --all-namespaces | grep -i image
Phase 6: Automated Audit Tools
Manual review does not scale across a cluster with dozens of Helm releases. These tools accelerate the audit and should be part of every Kubernetes security engagement.
Trivy — Chart and Image Scanning
# Scan a Helm chart directory for misconfigurations
trivy config ./my-chart --helm-values values.yaml
# Scan a specific chart release from a repository
helm fetch stable/nginx --untar --untardir /tmp/trivy-scan
trivy config /tmp/trivy-scan/nginx
# Scan container images referenced in a chart
helm template <release> ./my-chart | \
grep 'image:' | awk '{print $2}' | sort -u | \
xargs -I{} trivy image {}
Checkov — Static Policy Scanning
# Run Checkov against a Helm chart
checkov -d ./my-chart --framework helm
# Check specifically for Kubernetes security misconfigurations
checkov -d ./my-chart --check CKV_K8S_8,CKV_K8S_9,CKV_K8S_28,CKV_K8S_30
# Generate a SARIF report for CI integration
checkov -d ./my-chart --framework helm --output sarif \
--output-file-path ./checkov-results.sarif
Kubesec — Manifest Risk Scoring
# Score a rendered manifest
helm template <release> ./my-chart | kubesec scan -
# Score each resource separately
helm get manifest <release> -n <namespace> | \
kubesec scan - | jq '.[] | {object: .object, score: .scoring.score, critical: .scoring.critical}'
Datree — Policy Validation
# Validate a chart against Datree policies
datree helm test ./my-chart -- --values values.yaml
# Use a custom policy set
datree helm test ./my-chart --policy production-hardening -- -f values.yaml
Kube-bench — Node and Cluster CIS Compliance
# Run kube-bench against the master node
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-master.yaml
kubectl logs job/kube-bench-master | grep -E '\[FAIL\]|\[WARN\]'
# Run against worker nodes
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-node.yaml
kubectl logs job/kube-bench-node
Remediation: Hardening Helm Deployments
Secret Management: Stop Storing Secrets in Helm Values
The root cause of most Helm secret exposure is treating Helm as a secrets distribution mechanism. It was not designed for this. The correct architectures are:
Helm Secrets Plugin — Encrypts sensitive values files using SOPS (AWS KMS, GCP KMS, Azure Key Vault, or age keys). The values are decrypted at deploy time, never committed in plaintext:
# Install
helm plugin install https://github.com/jkroepke/helm-secrets
# Encrypt a values file
helm secrets encrypt values-prod.yaml > values-prod.yaml.enc
# Deploy using encrypted values
helm secrets upgrade <release> ./chart -f values-prod.yaml.enc
Sealed Secrets — Bitnami's controller encrypts Kubernetes Secrets with a cluster-specific key. The encrypted form is safe to commit to git; the controller decrypts at admission time:
# Seal a secret for a specific cluster
kubeseal --controller-namespace kube-system \
< my-secret.yaml > my-sealed-secret.yaml
# The sealed secret is safe to commit and deploy
kubectl apply -f my-sealed-secret.yaml
External Secrets Operator — Pulls secrets from external vaults (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) at runtime. No secret values ever live in the cluster or git:
# Example ExternalSecret resource
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: db-credentials
data:
- secretKey: password
remoteRef:
key: production/database
property: password
Chart Signing with Cosign
For internally developed charts stored in OCI registries, sign every chart artifact as part of the CI/CD pipeline:
# Sign a chart pushed to an OCI registry
cosign sign <registry>/charts/<chart>:<tag>
# Verify before deployment (enforce via admission webhook)
cosign verify <registry>/charts/<chart>:<tag> \
--certificate-identity=<ci-identity> \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com
OPA Gatekeeper Policies for Helm-Deployed Workloads
OPA Gatekeeper enforces security policies at admission time, catching misconfigured Helm deployments before they reach the scheduler:
# Example: Block privileged containers
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8spspprivilegedcontainer
spec:
crd:
spec:
names:
kind: K8sPSPPrivilegedContainer
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8spspprivileged
violation[{"msg": msg}] {
c := input_containers[_]
c.securityContext.privileged
msg := sprintf("Privileged container is not allowed: %v", [c.name])
}
input_containers[c] {
c := input.review.object.spec.containers[_]
}
Hardening Checklist for Chart Templates
Every chart template should be validated against this baseline before deployment:
- Set
runAsNonRoot: trueand an explicitrunAsUser(non-zero) in all containersecurityContextblocks - Set
allowPrivilegeEscalation: falsein all containers - Set
readOnlyRootFilesystem: truewhere the application supports it - Drop all capabilities with
capabilities.drop: ["ALL"] - Set
automountServiceAccountToken: falseon pods that do not need API server access - Remove
hostPath,hostNetwork,hostPID, andhostIPCunless absolutely required - Scope service account RBAC to the minimum required verbs and resources
- Set resource
limitson all containers to prevent denial-of-service
Key Takeaways
Helm security testing is not optional for any organization running Kubernetes in production. The attack surface is broad: release history exposes every value ever passed at install time, chart templates frequently ship with dangerous defaults, supply chain integrity is unenforced by default, and hook mechanisms create privilege escalation paths that bypass most security reviews.
The practical path forward combines automated scanning (Trivy, Checkov, Kubesec) with targeted manual audit of secret storage patterns, hook RBAC, and dependency chain integrity. For teams adopting GitOps workflows, chart signing with Cosign and policy enforcement via OPA Gatekeeper are the two highest-leverage controls to implement.
The commands in this guide represent the methodology used in professional Kubernetes penetration tests. The findings they surface — plaintext credentials in release history, hook service accounts with cluster-admin, unsigned dependencies from public repositories — are consistently among the most critical issues identified in cloud-native security assessments.
Automate Your Kubernetes Attack Surface Assessment
Ironimo brings professional-grade security scanning to your CI/CD pipeline and cloud-native stack. Stop discovering Helm misconfigurations in post-incident reviews — find them before attackers do.
Start free scan