
Exploit für Keycloak CVE-2026-18963, der eine nicht authentifizierte Übernahme von Konten über einen Reset-Credentials-Bypass ermöglicht. Enthält sichere Erkennung, nicht-destruktiven Nachweis, vollständige Übernahme, Benutzernamen-Enumeration sowie ein Labor mit verwundbaren und gepatchten Versionen.
Mit nur einem Benutzernamen oder einer E-Mail-Adresse setzt ein nicht authentifizierter Angreifer ein beliebiges Passwort auf einem beliebigen Keycloak-Konto. Die E-Mail zum Zurücksetzen des Passworts wird an das echte Opfer zugestellt und wird nie benötigt — der Angreifer liest nie ein Postfach, klickt nie auf einen Link und besitzt weder vorherige Anmeldedaten noch eine Sitzung.
Betroffen: Keycloak 26.0.0 – 26.7.1. Behoben in 26.7.2.
Ein Befehl. Kein gültiger Benutzername erforderlich und keine Nebenwirkungen — es sendet keine E-Mail, schreibt in kein Konto und stoppt vor dem ausnutzbaren Schritt.```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
Python 3.9+, nur Standardbibliothek. Nichts zu installieren.
| Exit | Befund | Bedeutung |
|:---:|---|---|
| `0` | 🔴 **VERWUNDBAR** | das geparkte E-Mail-Gate wurde ausgeliefert — der Bug selbst |
| `2` | 🟢 **GEPATCHT** | der Ablauf verzweigte zum Login und blieb dort (Fix #51844 vorhanden) |
| `2` | 🟡 **ENTSCHÄRFT** | Reset-Anmeldedaten nicht erreichbar — *Passwort vergessen* ist deaktiviert. **Kein Patch.** |
| `3` | ⚪ **UNKLAR** | nicht erkannte Antwort — **nicht als bestanden werten** |
Pro Realm ausführen — *Passwort vergessen* ist eine Einstellung pro Realm, und `master` zählt.
Details und warum der Check keinen Benutzer benötigt und nichts verändert, in
[§4a](#4a-safe-detection---safe-check--start-here).
**Wissen Sie bereits, dass Sie exponiert sind?** Springen Sie zu [Behebung](#8-remediation) und
[Erkennung / Threat Hunting](#9-detection).
### Ohne Ziel ausprobieren
Das Repo enthält ein Labor, das eine verwundbare **26.7.1** und eine gepatchte **26.7.2**
nebeneinander gegen einen identischen Realm startet, plus ein Postfach, um die Reset-E-Mail
ankommen und ungelesen bleiben zu sehen, während das Konto übernommen wird:```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
⚠️ Nur für autorisierte Tests
Dieses Repository existiert für Verteidiger, Incident Responder und autorisierte Penetrationstester. Führen Sie es nur gegen Systeme aus, die Ihnen gehören oder für die Sie eine schriftliche Genehmigung zum Testen besitzen. Alles hier wird mit einem eigenständigen verwundbaren Labor (
lab/) geliefert, sodass keine externen Systeme angefasst werden müssen, um zu lernen, wie der Fehler funktioniert. Die Ausrichtung auf Infrastruktur Dritter ohne Autorisierung ist in den meisten Rechtsordnungen illegal und wird von diesem Projekt nicht unterstützt.
Referenzen: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · Fix keycloak#51844
Zwei Defekte, die verkettet sind. Keiner ist allein ausnutzbar.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — jedes POST, das den Formularschlüssel tryAnotherWay trägt:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
Die Notiz ist ein **nackter boolescher Wert ohne Aufzeichnung darüber, zu welchem Ausführungssatz sie gehört**. Sie wird
nur in dem Zweig gelöscht, der einen übermittelten `authenticationExecution`-Parameter verarbeitet. Lässt man diesen Parameter weg — wie es dieser PoC durchgängig tut — bleibt das Flag für die gesamte Lebensdauer der Authentifizierungssitzung gesetzt.
`processFlow()` — solange das Flag wahr ist, wird die normale Flussauswertung übersprungen:```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
}
}
Es rendert ein absendbares Formular, das auf die aktuell geparkte Ausführung abzielt, anstatt die Sitzung auf „Warten auf die E-Mail" festzunageln.
Der Klebstoff ist processResult() case FORK: — wenn Send Reset Email ausgelöst wird, stempelt es
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> und verzweigt
den Browser zur Anmeldeseite. Die geparkte Ausführung ist genau das E-Mail-Gate.
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
Unconditional. Nothing verifies that the flow was resumed by a valid action token,
so *reaching* `action()` is treated as equivalent to proving mailbox control.
### The chain```
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
Sechs HTTP-Anfragen, keine Authentifizierung, an keiner Stelle ein authenticationExecution-Parameter.
model.getId(), und processFlow() berücksichtigt sie nur, wenn sie
gleich CURRENT_AUTHENTICATION_EXECUTION ist, andernfalls entfernt sie sie. Im Angriff
unterscheiden sich die beiden (choose-user-ID vs. e-mail-gate-ID) — genau das erkennt der Patch,
und genau das ist das Signal, auf das --safe-check aufbaut.ResetCredentialEmail.action() erfordert nun
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
und schlägt andernfalls mit INVALID_USER fehl.6a9e60bb eingeführt, der den „Try another way"-Authenticator-Auswahlbildschirm zum
Reset-Flow hinzufügte. Alles Ältere hat den Codepfad schlicht nicht. Das umfasst die alten
WildFly-basierten Distributionen und RH-SSO 7.x, die von diesem Bug nicht betroffen sind,
während sie weiterhin End-of-Life sind und für viele andere anfällig bleiben. Auf einem
Legacy-Build zu bleiben ist keine Abhilfe.26.7.2 ist das einzige veröffentlichte
Fix-Release im Community-Zug. Wenn eine Bereitstellung auf 26.0 – 26.6 läuft, gibt es
kein Patch-Release auf dieser Linie — der Fix erfordert ein Minor-Versions-Upgrade, nicht
ein Point-Release. Die Tags 26.4.15 / 26.6.6 sind Vendor-Backports und nicht mit
Community-Images austauschbar.Voraussetzungen: Der Realm hat Forgot password aktiviert und sein gebundener
Reset-Credentials-Flow verwendet den eingebauten reset-credential-email-Authenticator.
Das Repo enthält sowohl ein verwundbares als auch ein gepatchtes Keycloak, die den identischen Realm importieren, sowie Mailpit zum Abfangen der Reset-Mail — so können Sie zusehen, wie sie eintrifft und ungelesen bleibt, während das Konto übernommen wird.```bash cd lab docker compose up -d
| Service | URL | Version |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **verwundbar** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **gepatchte Kontrolle** |
| `kc-mailpit` | http://localhost:8025 | Postfach des Opfers |
Realm `poc`, öffentlicher Client `poc-app`, Benutzer `victim` / `OriginalPassw0rd!`, Keycloak
Admin `admin` / `admin`.
Verschiedene Builds mit `KC_VULN_VERSION` / `KC_PATCHED_VERSION` festlegen:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Zwischen den Läufen stellt lab/reset-victim.sh das Passwort des Opfers wieder her
(KC=http://localhost:8100 lab/reset-victim.sh zielt auf die gepatchte Instanz).
lab/legit_reset.py führt einen echten Reset durch, indem es den Action-Token-Link
aus Mailpit zieht und darauf klickt. Es ist die Kontrollprobe für die Erkennungsarbeit in
§9 — führen Sie es und den Exploit gegen dasselbe Realm aus und vergleichen Sie dann die Traces.
Teardown: docker compose down -v.
Python 3.9+, nur Standardbibliothek — keine Abhängigkeiten, läuft auf jeder Jump-Box.``` --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` kann jeder aktivierte öffentliche Client mit dem Standard-Flow sein. Der integrierte
`account`-Client existiert in jeder Realm und ist die zuverlässige Wahl, schränkt jedoch
Redirect-URIs ein, daher **muss** `--redirect-uri` dann
`<base>/realms/<realm>/account/` sein — der Standardwert wird abgelehnt und Schritt 1 schlägt fehl.
### 4a. Sichere Erkennung (`--safe-check`) — hier beginnen
Benötigt **keinen gültigen Benutzernamen** und hat **keine Nebenwirkungen**. Dies ist die Sonde, die du verwenden solltest,
wenn du das Ziel nicht stören darfst.```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
Warum es keinen Benutzer benötigt und keine E-Mail sendet. ResetCredentialEmail.authenticate()
verzweigt auch bei unbekanntem Benutzer:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` parkt daher `CURRENT_AUTHENTICATION_EXECUTION`
auf der E-Mail-Ausführung, **obwohl niemand gefunden wurde** — und es wird keine Mail
gesendet, weil es niemanden gibt, an den man mailen könnte. Der Probe stoppt am Diskriminator und
POSTet das Gate nie, sodass `action()` nie läuft: kein NPE auf dem Ziel, kein `emailVerified`-
Write, keine Mail, kein Konto wird berührt.
**Es prüft nur auf das positive Signal.** VERWUNDBAR ⟺ Schritt 5 liefert ein Formular
zurück, das noch innerhalb von `login-actions/reset-credentials` liegt und dessen `execution`
sich von der Choose-User-Ausführung unterscheidet. Das *ist* der Bug: die veraltete
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED`-Notiz, die das geparkte E-Mail-Gate bedient.
Beide Hälften zählen — der Pfad beweist, dass wir uns noch im Reset-Flow befinden, die abweichende
Execution-ID beweist, dass es das E-Mail-Gate und kein Re-Render ist.
Alles andere wird **nicht** stillschweigend als gepatcht bezeichnet. PATCHED erfordert eigene Belege
(Verzweigung zu `login-actions/authenticate` *und* ein vorhandenes Passwort-Eingabefeld); alles
Übrige ist UNKLAR und braucht einen Menschen. Ein früheres Design behandelte „nicht das
Gate-Formular" als gepatcht, was stillschweigend jedes Custom-Theme, jede Fehlerseite, jeden WAF-
Block und jedes Interstitial in einen falschen Gesundheitsnachweis verwandelt.
### 4b. Nicht-destruktiver Nachweis (`--check`)
Treibt die gesamte Kette, stoppt aber am Update-Password-Formular. Das Erreichen dieses Formulars
ohne Action-Token ist eindeutig.```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
Zwei Nebeneffekte sind unvermeidbar, da sie vorgelagert zum Passwortformular auftreten — nenne sie im Umfang des Engagements:
action() setzt emailVerified = true auf dem Konto.Es wird keine Anmeldedaten geändert. Bevorzuge ein dediziertes Testkonto.
Nur für Labore oder ausdrücklich autorisierte Demonstrationen.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Exit `0` verwundbar · `2` nicht ausnutzbar · `1` Passwort geändert, aber die Bestätigung
des Grants schlug fehl (weisen Sie `--verify-client-id` auf einen Client mit Direct Access Grants).
Der Abschluss des Flows liefert außerdem einen **OIDC-Autorisierungscode für das Opfer**, sodass
die Übernahme sofort erfolgt — kein zweiter Login mit dem neuen Passwort ist erforderlich.
### 4d. Benutzernamen-Enumeration (`--enum`)
Derselbe Fehler ist ein Benutzernamen-Orakel und ein stärkeres als Keycloak normalerweise
zulässt. `ResetCredentialEmail.authenticate()` gibt absichtlich eine identische
*„Sie sollten in Kürze eine E-Mail erhalten“*-Meldung für echte und unbekannte Benutzer zurück, sodass
das Reset-Formular selbst nicht zur Enumeration verwendet werden kann — diese Verteidigung gilt weiterhin bei Schritt 4. Sie bricht
bei **Schritt 6**, wo `action()` den Benutzer bedingungslos dereferenziert
(`context.getUser().setEmailVerified(true)`).
| Kennung | Schritt 6 | Ergebnis |
|---|---|---|
| echter Benutzer | `200`, erreicht das Passwort-Änderungsformular | GÜLTIG |
| unbekannter Benutzer | `400` (NPE-Fehlerseite) | UNGÜLTIG |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Ändert niemals ein Passwort. Beendet mit 0, wenn eine Kennung aufgelöst wurde, andernfalls mit 2.
Kosten pro Probe – vor der Ausführung lesen. Das Erreichen des Orakels erfordert das Abschließen von Schritt 4, daher sendet jede Probe gegen ein echtes Konto dieser Person eine echte Passwort-Reset-E-Mail und setzt emailVerified = true in ihrem Datensatz. Es ist keine stille Prüfung: Sie ist für den Kontoinhaber sichtbar und verändert dessen Daten. Eine Wortliste mit 5.000 Namen bedeutet 5.000 E-Mails an echte Personen und 5.000 veränderte Konten.
Verwenden Sie es, um zu demonstrieren, dass das Orakel existiert, an einer Handvoll Kennungen für den Bericht – nicht, um ein Verzeichnis zu ernten. Die Schutzmechanismen sind bewusst konservativ:
--enum-max N verweigert Listen länger als N (Standard 25)--enum-delay SEC pausiert zwischen den Proben (Standard 2.0)Das Erhöhen einer dieser Werte sollte eine bewusste Entscheidung sein, die in den Engagement-Notizen festgehalten wird.
Berichtswinkel: Dies überwindet eine Anti-Enumeration-Kontrolle, die Keycloak absichtlich implementiert hat. Es lohnt sich, dies als eigenen Befund neben der Übernahme zu dokumentieren, und es entkräftet „unsere Benutzernamen sind nicht erratbar" als mildernden Faktor.
Jede ernsthafte Bereitstellung bringt ein benutzerdefiniertes Login-Design mit, und benutzerdefinierte Designs benennen die Standard-Element-IDs um oder entfernen sie (kc-form-login, kc-reset-password-form, kc-select-credential-form, kc-passwd-update-form). Ein Tool, das auf diese IDs setzt, meldet ein falsches Negativ genau bei den Bereitstellungen, die am wichtigsten sind – dieses hier tat das, bevor es neu geschrieben wurde. In freier Wildbahn gesehene Designs verwenden IDs wie id="login-form" und liefern einen Passwort vergessen-Anker mit einem leeren href.
Dieser PoC setzt daher auf nichts, was ein Design steuert:
action= der Formulare auf der Seite getroffen – login-actions/reset-credentials, login-actions/authenticate, login-actions/required-action – sowie aus dem execution-Query-Parameter darin. Diese Pfade werden von Keycloaks eigenem LoginActionsService erzeugt, nicht vom Design.grep Sie den Quellcode: Es gibt keine einzige kc-*-ID darin./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… und testet es direkt, wobei tab_id aus dem Formular übernommen wird, das die Login-Seite tatsächlich bereitstellt.Wenn ein Ziel weiterhin UNKLAR zurückgibt, führen Sie es mit --verbose --dump out.html aus und lesen Sie die Antwort – das Tool weigert sich bewusst zu raten.
Ein Client, der PKCE erzwingt, lehnt Schritt 1 mit Missing parameter: code_challenge_method ab. Dies wird als UNKLAR (Exit 3) gemeldet, niemals als bestanden. Bis PKCE-Unterstützung eintrifft, kann ein Realm, dessen einziger nutzbarer öffentlicher Client PKCE vorschreibt, mit diesem Tool nicht geprüft werden – versuchen Sie den eingebauten account-Client, der dies normalerweise nicht erzwingt.
Jeder Lauf unten erfolgt gegen das Labor in diesem Repository, unter Verwendung des Codes wie veröffentlicht.
Zwei Befunde, die über den Advisory-Text hinaus erwähnenswert sind:
ResetCredentialEmail.authenticate() nimmt den forkWithSuccessMessage-Pfad, wenn user.getEmail() null ist, was die Ausführung weiterhin über case FORK: parkt. Dasselbe gilt für einen SMTP-Sendefehler – ein defekter oder fehlender Mail-Server ist keine Entschärfung. Dies ist direkt relevant für AD/LDAP-föderierte Realms, in denen Konten häufig kein Mail-Attribut tragen.MFA ist keine Entschärfung. Der Standard-Reset-Credentials-Flow enthält keinen OTP-Schritt, und einmal durchgekommen kann der Angreifer die registrierten Faktoren des Opfers entfernen.
Fix: Upgrade. 26.7.2 für Community-Builds oder das zum Abonnement passende Vendor-Backport-Tag. Alles unten ist ein Notbehelf.
Zwischenlösungen, beste zuerst:
master.Realms, deren gebundener Reset-Flow vollständig benutzerdefiniert ist und reset-credential-email niemals aufruft, sind über diesen Pfad nicht ausnutzbar.
Keycloak gibt kein „Action-Token übersprungen"-Ereignis aus, daher ist die Erkennung heuristisch. Führen Sie lab/legit_reset.py neben dem Exploit aus, um beide Spuren zu erzeugen und zu vergleichen.
GET /login-actions/action-token?... (das Opfer klickt auf die Mail) vor der Passwortänderung. Der Bypass hat kein solches GET. Stattdessen zeigt er ein POST an login-actions/reset-credentials, dessen Body tryAnotherWay enthält, gefolgt von einem zweiten POST an denselben Pfad mit leerem Body, dann das Passwortformular. Ein tryAnotherWay-POST innerhalb des Reset-Flows wird von der Standard-UI im normalen Gebrauch nicht erzeugt.SEND_RESET_PASSWORD gefolgt von UPDATE_PASSWORD, die innerhalb weniger Sekunden dieselbe code_id teilen – im Labor unter einer Sekunde. Ein Benutzer mit bereits geöffneter Mail kann ebenfalls schnell aussehen, daher mit den Proxy-Logs abgleichen.emailVerified auf true umgeschaltet wurde ohne entsprechendes VERIFY_EMAIL-Ereignis sind ein nützlicher unterstützender Indikator und einer, den der Angreifer nicht vermeiden kann zu hinterlassen.Das Fehlen von Ereignissen beweist nichts, wenn Ereignisprotokollierung oder -aufbewahrung deaktiviert war. Prüfen Sie das Aufbewahrungsfenster, bevor Sie schlussfolgern, dass eine Bereitstellung nicht betroffen war.
Snizi — github.com/Snizi — [email protected]
Veröffentlicht unter der MIT-Lizenz. Issues und PRs willkommen – insbesondere PKCE-Unterstützung und zusätzliche Eigenheiten realer Designs.
| Linie | Verwundbar | Community-Fix |
|---|
| Legacy (WildFly-basiertes Keycloak, ≤ 17) | nicht betroffen | — |
| Quarkus 17 – 25.x | nicht betroffen | — |
| 26.0 | 26.0.0 – 26.0.17 | keine |
| 26.1 | 26.1.0 – 26.1.5 | keine |
| 26.2 | 26.2.0 – 26.2.16 | keine |
| 26.3 | 26.3.0 – 26.3.5 | keine |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (Vendor-Backport-Tag) |
| 26.5 | 26.5.0 – 26.5.7 | keine |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (Vendor-Backport-Tag) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| Exit | Verdict | Bedeutung |
|---|
0 | VERWUNDBAR | der geparkte E-Mail-Gate wurde ausgeliefert — der Bug selbst |
2 | GEPATCHT | der Ablauf verzweigte zum Login und blieb dort (Fix #51844 vorhanden) |
2 | ENTSCHÄRFT | Reset-Credentials nicht erreichbar — Passwort vergessen ist deaktiviert. Kein Patch. |
3 | UNKLAR | nicht erkannte Antwort — nicht als bestanden werten |
| Test | Ziel | Ergebnis |
|---|
--safe-check | 26.7.1 | VERWUNDBAR, Exit 0 – E-Mail-Gate bedient (execution ≠ choose-user) |
--safe-check | 26.7.2 | GEPATCHT, Exit 2 – zum Login verzweigt und dort geblieben |
--safe-check, Passwort vergessen aus | 26.7.2 | ENTSCHÄRFT, Exit 2 – HTTP 400, Flow nicht erreichbar |
| Vollständige Übernahme | 26.7.1 | Exit 0 – Passwort gesetzt, OIDC-Code ausgestellt, Password-Grant bestätigt |
| Vollständige Übernahme | 26.7.2 | Exit 2 – bei Schritt 5 blockiert, Konto unberührt |
--check | 26.7.1 | UPDATE_PASSWORD erreicht; Passwort danach als unverändert verifiziert |
--enum | 26.7.1 | victim und [email protected] GÜLTIG, does-not-exist UNGÜLTIG |
| Anmeldedatenstatus nach Übernahme | 26.7.1 | neues Passwort → 200, altes Passwort → 400 |
| Anmeldedatenstatus nach blockiertem Lauf | 26.7.2 | altes Passwort → 200, Angreifer-Passwort → 400 |
| Opfer-Postfach | Mailpit | Reset-Mails zugestellt und ungelesen; der Action-Token-Link wird nie abgerufen |