Contournement d'authentification et RCE dans GPUStack, exploité (CVE-2026-102895)
GPUStack avant 2.2.0 fait confiance à une source locale, donc un proxy inverse sur le même hôte transforme une requête distante en accès administrateur et en exécution de code.
En bref
- GPUStack avant 2.2.0 traite une requête qui semble venir de 127.0.0.1 comme l'administrateur intégré, et un proxy inverse sur le même hôte fait passer toute requête distante pour du loopback. Un attaquant non authentifié obtient alors un accès administrateur complet.
- Tout déploiement antérieur à 2.2.0 avec la valeur par défaut
force_auth_localhost=falsederrière un proxy inverse sur le même hôte est exposé. L'avis cite aussi un en-têteX-GPUStack-Real-IPforgé en mode passerelle intégrée. - L'éditeur signale une exploitation en conditions réelles contre un déploiement 2.1.1, avec des backends d'inférence créés par l'attaquant qui exécutaient des shells inverses. Le dossier CVE a été publié le 4 novembre 2026. Il n'est pas dans le catalogue KEV de la CISA au 5 novembre 2026.
- Mettez à niveau vers GPUStack 2.2.0 ou ultérieur et recréez le conteneur. Sur les versions antérieures, activez
force_auth_localhost=trueet supprimez l'en-têteX-GPUStack-Real-IPfourni par le client au niveau du proxy. Voir « Comment corriger ? ».
Suis-je concerné ?
Vous êtes exposé lorsque toutes ces conditions sont réunies :
- Votre serveur GPUStack exécute une version antérieure à 2.2.0.
force_auth_localhostest faux ou non défini, ce qui est la valeur par défaut sur ces versions.- L'API est joignable via un proxy inverse sur le même hôte configuré avec
proxy_pass http://127.0.0.1:<port>, donc chaque requête distante atteint GPUStack avec le pair TCP 127.0.0.1. En mode passerelle intégrée, un attaquant qui peut définirX-GPUStack-Real-IP: 127.0.0.1atteint la même branche.
La condition du proxy inverse correspond à ce que l'avis appelle le déploiement auto-hébergé canonique : un proxy inverse sur le même hôte avec proxy_pass http://127.0.0.1:<port>. C'est exactement l'organisation à laquelle la branche vulnérable faisait confiance.
| Branche | Versions concernées | Version corrigée |
|---|---|---|
| 2.1.x et antérieures | 2.1.1 et toutes les versions antérieures | 2.2.0, publiée le 2026-06-18 |
| 2.2.x | Aucune (corrigé en 2.2.0) | Continuez à installer la dernière version |
Vérifiez ce que vous exécutez :
# Version inside the server container
docker exec gpustack gpustack version
# Python package version
docker exec gpustack pip show gpustack | head -2
# Is the loopback bypass forced off?
docker exec gpustack env | grep -i FORCE_AUTH_LOCALHOST
La dernière commande n'affiche rien lorsque le réglage est à sa valeur par défaut. Le réglage apparaît aussi dans le fichier de configuration du serveur sous force_auth_localhost.
GPUStack est un gestionnaire de cluster GPU open source pour le service de modèles d'IA et le provisionnement d'instances GPU. Son image Docker avait été tirée 942 824 fois sur Docker Hub le 5 novembre 2026, et le dépôt comptait 5 978 étoiles.
Comment corriger ?
- Mettez à niveau le serveur vers GPUStack 2.2.0 ou ultérieur :
pip install --upgrade "gpustack==2.2.0"
- Recréez le conteneur avec la nouvelle image. Tirer l'image sans recréer le conteneur laisse l'ancien processus en cours.
- Confirmez que le raccourci loopback a disparu. L'option
force_auth_localhostest sans effet en 2.2.0, et chaque requête doit s'authentifier. - Examinez l'état administratif pour des changements que vous n'avez pas faits. L'avis cite les modifications d'utilisateurs et de modèles, les clés API d'administrateur persistantes, les secrets des workers et des fournisseurs cloud, et les backends d'inférence créés via le point de terminaison réservé aux administrateurs.
Pour les déploiements qui ne peuvent pas encore être mis à niveau, l'avis donne trois contournements. Ils réduisent l'exposition mais ne suppriment pas le code vulnérable :
- Activez
force_auth_localhost=truepour que les requêtes loopback exigent aussi une authentification. - Supprimez tout en-tête
X-GPUStack-Real-IPfourni par le client au niveau du proxy inverse ou de la passerelle, et assurez-vous que le proxy ne peut pas faire passer le trafic externe pour du loopback. - Gardez l'API hors des réseaux non fiables, derrière un VPN ou une authentification en périphérie.
Le premier contournement est le plus efficace pour la forme habituelle du proxy inverse. Les deux autres limitent le second chemin et la fenêtre d'exposition.
Quelle est la cause racine ?
La cause racine est une décision d'authentification fondée sur la source réseau de la requête. Un pair local ou un en-tête fourni par le client n'est pas une identité, et un proxy inverse sur le même hôte rend l'adresse du pair inutilisable.
Voici le code vulnérable en v2.1.1, dans gpustack/api/auth.py. La fonction aux lignes 51 à 56 ne renvoie l'en-tête X-GPUStack-Real-IP qu'en mode passerelle intégrée ; sinon elle renvoie le pair de la socket :
def client_ip_getter(request: Request) -> str:
if request.app.state.server_config.gateway_mode == GatewayModeEnum.embedded:
return request.headers.get("X-GPUStack-Real-IP", "")
else:
return request.client.host
La dépendance d'authentification aux lignes 89 à 92 promeut ensuite une source loopback au premier administrateur lorsque l'option est fausse :
if user is None and client_ip_getter(request=request) == "127.0.0.1":
if not server_config.force_auth_localhost:
user = await User.first_by_field(session, "is_admin", True)
La branche s'exécute seulement après que les contrôles basic, cookie, bearer et clé API ont tous échoué à produire un utilisateur. Sans aucun identifiant, user vaut None, client_ip_getter renvoie 127.0.0.1 pour une requête proxifiée, et la requête renvoie l'administrateur intégré. Le code ne vérifie jamais la méthode HTTP, le chemin ou l'appelant.
La même fonction fait confiance à un en-tête en mode passerelle intégrée. Un client qui définit X-GPUStack-Real-IP: 127.0.0.1 atteint la même branche sans proxy. L'avis documente les deux chemins.
Comment la faille est-elle exploitée ?
- L'attaquant envoie une requête non authentifiée à n'importe quel point de terminaison de l'API via le proxy inverse sur le même hôte.
- Le proxy transmet la requête à
127.0.0.1:<port>, donc GPUStack voit un pair TCP loopback. get_current_userne trouve aucun identifiant, appelleclient_ip_getter, obtient127.0.0.1et charge le compte administrateur intégré.- L'attaquant agit désormais en administrateur. L'avis cite la modification d'utilisateurs et de modèles, la lecture des secrets des workers et des fournisseurs cloud, et la création d'une clé API d'administrateur persistante de portée
*. - Avec la clé d'administrateur, l'attaquant appelle le point de terminaison réservé
POST /v2/inference-backends/from-yamlet fournit une définition de backend. Ce point de terminaison sert à exécuter des backends d'inférence, et l'avis indique que les attaquants l'ont utilisé pour exécuter des shells inverses sur les hôtes workers. - L'attaquant conserve la clé API d'administrateur, qui reste valable depuis n'importe quelle adresse source après sa création. Supprimer la règle de proxy ne la révoque pas.
L'éditeur dit avoir observé cette exploitation en conditions réelles contre un déploiement v2.1.1, avec des backends personnalisés créés par l'attaquant qui exécutaient des shells inverses. L'avis ne donne pas la date de cette observation ni le nombre de victimes.
Comment se déroule l'attaque ?
Un proxy inverse transforme la requête réseau de l'attaquant en requête loopback, et le contrôle loopback la transforme en session administrateur.
sans identifiants"] --> B["Proxy inverse local
pair 127.0.0.1"] B --> C["get_current_user
aucun utilisateur"] C --> D["client_ip_getter renvoie loopback
aucun contrôle d'identité"] D --> E["administrateur intégré chargé"] E --> F["clé API admin créée
portée *"] F --> G["backend d'inférence depuis YAML
shell inverse sur les workers"]
Que peut faire un attaquant ?
L'impact est le contrôle administratif complet du déploiement GPUStack, et l'exécution de code sur les hôtes workers qu'il gère.
L'avis énumère les actions administratives qu'un attaquant non authentifié peut mener :
- Énumérer et modifier les utilisateurs, les modèles, les workers et la configuration.
- Créer une clé API d'administrateur persistante de portée
*, valable depuis n'importe quelle adresse IP source après sa création. - Lire les identifiants des workers et les secrets des fournisseurs cloud.
- Appeler le point de terminaison réservé
POST /v2/inference-backends/from-yamlpour exécuter du code sur les hôtes workers.
L'attaquant a besoin d'atteindre l'API à travers le proxy. L'avis indique que l'exploitation a été observée contre un déploiement 2.1.1 avec des backends créés par l'attaquant exécutant des shells inverses. C'est de l'exécution de code sur les hôtes workers, pas seulement sur le serveur GPUStack.
Un déploiement dont l'API n'est joignable que depuis un réseau de confiance reste exposé à quiconque se trouve sur ce réseau, y compris un poste compromis ou un locataire voisin.
Pourquoi le correctif fonctionne-t-il ?
Le correctif supprime entièrement le raccourci loopback, donc aucune requête n'est authentifiée par sa source. Il exige aussi un jeton de passerelle avant de faire confiance à l'en-tête X-GPUStack-Real-IP.
La modification de 2.2.0 dans gpustack/api/auth.py supprime les trois lignes qui promouvaient un appelant loopback :
@@ async def get_current_user(...)
if token is not None:
user, api_key = await get_user_from_api_token(session, token)
- if user is None and client_ip_getter(request=request) == "127.0.0.1":
- if not server_config.force_auth_localhost:
- user = await User.first_by_field(session, "is_admin", True)
if user:
if not user.is_active:
raise UnauthorizedException(message="User account is deactivated")
La même version réécrit client_ip_getter pour que l'en-tête de transfert ne soit lu qu'après la réussite de gateway_token_auth :
def client_ip_getter(request: Request) -> str:
- if request.app.state.server_config.gateway_mode == GatewayModeEnum.embedded:
- return request.headers.get("X-GPUStack-Real-IP", "")
- else:
- return request.client.host
+ if request.app.state.server_config.gateway_mode != GatewayModeEnum.disabled:
+ try:
+ gateway_token_auth(request)
+ real_ip = request.headers.get("X-GPUStack-Real-IP")
+ if real_ip:
+ return real_ip
+ except UnauthorizedException:
+ pass
+ return request.client.host if request.client else ""
Le correctif est complet pour ce contournement. Il ne révoque pas les clés API qu'un attaquant a créées avant la mise à niveau, et il ne nettoie pas un hôte worker qui a exécuté un backend fourni par l'attaquant. Cela relève des contrôles de « Comment vérifier si j'ai été compromis ? ».
Comment vérifier si j'ai été compromis ?
- Listez les utilisateurs administrateurs et les clés API dans l'interface GPUStack. Un administrateur ou une clé que vous n'avez pas créés sont une piste ; une clé de portée
*créée hors de votre processus de changement est une preuve forte. - Listez les backends d'inférence et comparez-les à vos propres enregistrements. Le chemin d'exploitation de l'avis crée des backends via
POST /v2/inference-backends/from-yaml, et l'éditeur a observé des shells inverses depuis ceux-ci. - Sur les hôtes workers, examinez les connexions sortantes et les arbres de processus pour des shells lancés par le processus du backend d'inférence. Un backend qui ouvre une connexion interactive vers une adresse externe est une piste.
- Cherchez dans les journaux du serveur GPUStack et du proxy des requêtes API qui ont réussi sans en-tête d'authentification. La branche vulnérable renvoie une réponse administrative normale, donc une requête réussie sans identifiants est le motif à trouver.
- Traitez les identifiants des workers et des fournisseurs cloud comme lus. Renouvelez-les et examinez les comptes cloud pour des ressources que vous n'avez pas créées.
L'avis ne donne aucun indicateur de compromission ni nombre de victimes. Une requête qui a échoué avant d'atteindre la branche vulnérable peut ne laisser aucune trace.
Chronologie
- 2026-05-19 : le commit de correction, « fix: remove localhost auto-admin bypass », arrive dans le dépôt.
- 2026-06-18 : GPUStack 2.2.0 est publié avec le correctif.
- 2026-07-31 : l'éditeur publie l'avis GHSA-gj9m-f8ph-pmhr.
- 2026-09-29 : le dossier CVE est réservé et l'avis est mis à jour.
- 2026-11-04 : CVE-2026-102895 est publié, avec le signalement d'une exploitation en conditions réelles.
FAQ
L'exploitation est-elle confirmée ?
Oui. L'avis de l'éditeur indique que la vulnérabilité « a été observée exploitée en conditions réelles contre un déploiement v2.1.1 », avec des backends personnalisés créés par l'attaquant exécutant des shells inverses. L'avis ne donne ni date, ni nombre de victimes, ni indicateurs.
La configuration par défaut est-elle concernée ?
Oui, lorsqu'un proxy inverse sur le même hôte est devant l'API. force_auth_localhost vaut faux par défaut, et le proxy fait du pair TCP 127.0.0.1 pour chaque requête distante. Un déploiement direct sans proxy local ne prend pas le chemin loopback, sauf si le mode passerelle intégrée fait confiance à un en-tête forgé.
Le contournement de l'avis corrige-t-il tout ?
Non. Activer force_auth_localhost=true ferme la branche loopback sur les versions concernées, ce qui est le chemin principal. La mise à niveau supprime la branche et la confiance dans l'en-tête, c'est donc le correctif durable.
Pourquoi le score CVSS est-il de 9,8 ?
Le dossier CVE le note CVSS v3.1 9,8, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. L'attaquant n'a besoin d'aucun privilège ni interaction, et l'impact est élevé sur la confidentialité, l'intégrité et la disponibilité.
Est-il dans le catalogue KEV de la CISA ?
Non. CVE-2026-102895 n'est pas dans le catalogue Known Exploited Vulnerabilities de la CISA au 5 novembre 2026. La déclaration d'exploitation de l'éditeur est la source.
La mise à niveau révoque-t-elle les clés créées par l'attaquant ?
Non. La mise à niveau ferme le contournement mais laisse les utilisateurs et les clés API existants en place. Révoquez les clés d'administrateur inconnues et renouvelez les identifiants des workers et du cloud après la mise à niveau.
Références
- Avis GPUStack GHSA-gj9m-f8ph-pmhr : https://github.com/gpustack/gpustack/security/advisories/GHSA-gj9m-f8ph-pmhr (vérifié le 5 novembre 2026)
- Dossier CVE-2026-102895 : https://www.cve.org/CVERecord?id=CVE-2026-102895 (vérifié le 5 novembre 2026)
- Commit de correction GPUStack 53f42a6a : https://github.com/gpustack/gpustack/commit/53f42a6a553542974396d16a4ff6b12bcbedb48d (vérifié le 5 novembre 2026)
- Version GPUStack v2.2.0 : https://github.com/gpustack/gpustack/releases/tag/v2.2.0 (vérifié le 5 novembre 2026)
- Source GPUStack en v2.1.1,
gpustack/api/auth.py: https://github.com/gpustack/gpustack/blob/v2.1.1/gpustack/api/auth.py (vérifié le 5 novembre 2026) - Dépôt GPUStack : https://github.com/gpustack/gpustack (vérifié le 5 novembre 2026)
- Docker Hub,
gpustack/gpustack: https://hub.docker.com/r/gpustack/gpustack (vérifié le 5 novembre 2026)