
무슨 일이 가능했나
인증되지 않은 원격 공격자가, 피해자의 아이디(또는 이메일)만 알고 있으면 이메일로 온 재설정 링크를 단 한 번도 클릭하지 않고도 그 계정의 비밀번호를 임의로 설정할 수 있었다. 사용자 상호작용도, 사전 자격증명도 필요 없다. CVSS 3.1 기준 9.1점(Critical)이 매겨진 이유다.
| 항목 | 내용 |
| CVE ID | CVE-2026-18963 |
| 컴포넌트 | keycloak-services (reset-credentials 인증 플로우) |
| CVSS 3.1 | 9.1 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-640 (Weak Password Recovery Mechanism for Forgotten Password) |
| 패치 버전 | 26.7.2 / 26.6.6 / 26.4.15(LTS) / 26.8.0 |
아래에서는 "공격자가 실제로 무엇을 순서대로 밟았을지"를 기준으로, 각 단계마다 그 단계를 가능하게 만든 실제 소스코드를 그 자리에서 함께 짚어본다.

알아야 할 최소한의 배경 지식
Keycloak의 로그인/비밀번호 재설정 절차는 여러 Authenticator를 순서대로 실행하는 "플로우(Flow)"다. 각 Authenticator는 화면을 보여줄 때 호출되는 authenticate(context)와, 사용자가 폼을 제출했을 때 호출되는 action(context) 두 메서드를 가진다. reset-credentials 플로우는 reset-credentials-choose-user(사용자 지정) → reset-credential-email(이메일 발송 및 대기) 순서로 구성된다.
이메일 링크를 클릭하면 별도의 Action Token 검증 경로가 같은 플로우를 다시 실행시키면서, 인증 세션에 ACTION_TOKEN_USER_ID라는 note를 남긴다. 이 값이 현재 세션의 대상 사용자와 일치하는지가, "이메일 인증을 실제로 완료했는가"를 판단하는 유일한 근거다. 이 글의 핵심은 결국 이 값을 확인하는 지점이 코드 한 곳에서 누락되어 있었다는 것이다.
1단계 — 피해자 계정 지정
공격자는 reset-credentials-choose-user 단계에 피해자의 아이디를 제출한다. 이 단계는 실제로 아래 코드만 수행한다.

context.setUser(user)가 호출되는 시점에는 아직 어떤 이메일도 발송되지 않았고, 어떤 인증도 이뤄지지 않았다. 그냥 "이 세션의 대상은 이 사용자다"라는 포인터 하나가 세워질 뿐이다. 이 포인터가 뒤 단계에서 그대로 신뢰된다는 점이 이 취약점 전체의 전제 조건이다.
2단계 — 이메일 발송·대기 단계로 진입
플로우는 reset-credential-email로 넘어간다. 정상적인 사용자라면 여기서 다음 코드를 거쳐 메일을 받고 대기 화면만 보게 된다.

이 시점에서 ACTION_TOKEN_USER_ID는 세션에 존재하지 않는다. 공격자는 이메일을 열어볼 수 없으므로 이 경로로는 더 나아갈 수 없다 — authenticate()만 놓고 보면 여기서 막힌다. 공격자가 노린 것은 이 화면으로 이어지는 action() 쪽이었다.
3단계 — 화면 상태를 조작해 검증 없는 경로로 우회 진입
Keycloak은 URL의 execution 파라미터로 "이번 요청이 어느 Authenticator를 대상으로 한 것인지" 판단한다. 여기서 두 번째 결함이 등장한다. DefaultAuthenticationFlow.java의 "다른 방법으로" 화면 전환 로직이다.

AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED는 세션 전역에 걸린 "true"/"false" 값 하나일 뿐, 어떤 execution에서 이 화면이 떴는지에 대한 정보를 담고 있지 않다. 반면 CURRENT_AUTHENTICATION_EXECUTION은 플로우가 진행되며 계속 갱신되는 값이다. 이 둘을 조합해 화면을 재구성하는 로직은, "예전에 어디선가 셀렉터 화면이 뜬 적이 있다"는 사실과 "지금 마지막으로 다뤄진 execution이 무엇인가"라는 사실을 잘못 엮어버린다 — 이 둘은 원래 같은 execution을 가리켜야 한다는 보장이 없었다.
이 상태 조작을 통해, 공격자는 reset-credential-email execution을 대상으로 한 요청이 authenticate()를 거치지 않고 곧바로 action()으로 라우팅되도록 세션 상태를 유도할 수 있었다.
검증 없이 통과되는 지점: ResetCredentialEmail.action()
이 취약점의 실질적인 몸통이다. 패치 전 코드는 다음이 전부였다.

한 줄이다. action()이 어떤 경위로 호출되든 — ACTION_TOKEN_USER_ID가 세션에 있는지, 있다면 지금의 대상 사용자와 일치하는지 — 아무것도 확인하지 않고 무조건 성공 처리한다. authenticate()에서 하던 검증(Objects.equals(user.getId(), actionTokenUserId))이 action()에는 존재하지 않았다.
3단계에서 만든 우회 경로와 이 지점이 만나는 순간, 이메일 인증 단계 전체가 사실상 통과 처리된다.
5단계 — 비밀번호 재설정, 계정 탈취
플로우는 다음 단계인 비밀번호 설정 화면으로 넘어간다. 1단계에서 context.setUser(victim)으로 고정된 사용자가 그대로 대상이므로, 공격자는 피해자 계정의 새 비밀번호를 직접 입력해 세션을 완료할 수 있다. 이메일 링크를 클릭한 적이 없는데도 계정을 완전히 장악하게 되는 지점이다.
패치는 무엇을 바꿨나
① ResetCredentialEmail.java — 4단계에서 빠졌던 검증을 그대로 추가

② AuthenticationProcessor.java / DefaultAuthenticationFlow.java — 3단계의 상태-실행 단계 바인딩 결함 수정


단순 "true" 대신 셀렉터 화면을 띄운 execution의 모델 ID 자체를 저장하고, 재진입 시 문자열이 정확히 일치할 때만 화면을 재구성하도록 바꿨다. 화면 상태와 실행 단계가 1:1로 바인딩된 것이다. 두 결함이 각각 독립적으로 막히면서 5단계로 이어지는 경로 전체가 차단된다.
실습 영상
영향 범위 및 조치
| 스트림 | 취약 버전 | 패치 버전 |
| Keycloak 26.7.x | 26.7.2 미만 | 26.7.2 |
| Keycloak 26.6.x | 26.6.6 미만 | 26.6.6 |
| Keycloak 26.4.x (LTS) | 26.4.15 미만 | 26.4.15 |
| Keycloak 26.8.x | 26.8.0 미만 | 26.8.0 |
지원 종료된 26.5.x 이하 버전은 별도 패치가 없으므로 지원 스트림으로의 업그레이드가 유일한 해결책이다. 즉시 패치가 어렵다면 realm 설정에서 resetPasswordAllowed를 비활성화해 진입점 자체(2단계)를 차단할 수 있지만, 이는 정상 사용자의 셀프서비스 비밀번호 재설정도 함께 막는 임시 조치일 뿐이다.
'CVE 취약점' 카테고리의 다른 글
| Dovecot SQL 기반 인증 우회 (auth_username_chars 회귀 취약점) CVE-2026-24031 (0) | 2026.09.02 |
|---|---|
| Adminer MSSQL 드라이버 Pre-Auth RCE 분석 (CVE-2026-56705) (0) | 2026.09.01 |
| PostgreSQL pgcrypto pgp_pub_decrypt_bytea() 힙 버퍼 오버플로우 (RCE) (CVE-2026-2005) (0) | 2026.08.28 |
| GEO my WP 플러그인 미인증 SQL 인젝션 (CVE-2026-52715) (0) | 2026.08.25 |
| Rails Active Storage libvips 임의 파일 읽기 및 RCE 분석 — KindaRails2Shell (CVE-2026-66066) (0) | 2026.08.21 |