
CVE-2026-18963에 대한 개념 증명 및 취약점 검사기로, Keycloak reset-credentials 흐름에서 발생하는 인증되지 않은 계정 탈취 취약점입니다. 단일 파일 Python이며, 선택적 Playwright 브라우저 구성 요소가 포함되어 있습니다.
Keycloak reset-credentials 흐름 우회에 대한 개념 증명(proof of concept) 및 취약점 검사기로, 결과 상태를 실제 Chrome 창에 렌더링하는 선택적 브라우저 구성 요소를 포함합니다.
이 도구는 Python 표준 라이브러리를 기반으로 작성된 단일 파일 cve_2026_18963_poc.py입니다. Playwright가 유일한 서드파티 의존성이며 지연 임포트(lazy import)되므로, HTTP 측은 아무것도 설치되지 않은 기본 인터프리터에서 실행됩니다.
cve_2026_18963_poc.py 1009 lines, Python 3.10 or newer
크레딧. CVE-2026-18963은 James Paremain에 의해 Red Hat에 보고되었으며, 벤더 권고문에 크레딧이 명시되어 있습니다. Orevim은 이 취약점을 발견하거나 보고하지 않았습니다. 이 저장소는 공개된 권고문을 바탕으로 작성된 독립적인 개념 증명입니다.
CVE-2026-18963은 Keycloak의 비밀번호 찾기 흐름에서 발생하는 인증 우회입니다. 이는 두 가지 결함이 함께 작용한 결과입니다.
범위가 지정되지 않은 노트. 사용자가 인증자 선택 화면을 열면,
Keycloak은 인증 세션에 AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED 노트를
기록합니다. 이 노트는 단순한 불리언 값입니다. 이는 어떤 선택 화면이
표시되었는지는 기록하지만, 어느 실행이 그것을 표시했는지는 기록하지
않습니다. 이 노트는 그것을 설정한 실행보다 오래 유지됩니다.
보호되지 않은 핸들러. ResetCredentialEmail.action()은 확인된 사용자에
대해 emailVerified를 설정하고, 이메일 링크가 전달해야 하는 액션 토큰을
확인하지 않은 채 success()를 호출합니다. 이 메서드는 해당 링크를 따라야만
도달할 수 있다고 가정합니다.
연쇄적으로, 인증되지 않은 호출자가 사용자 이름 실행에 대해 노트를 설정하고,
흐름을 진행시켜 이메일 실행이 현재가 되도록 한 다음, 재설정 URL에 다시
진입합니다. 오래된 노트로 인해 선택 화면이 이메일 실행에 바인딩되어
렌더링됩니다. 해당 실행의 loginAction에 빈 본문을 POST하면 보호되지 않은
핸들러가 직접 호출됩니다. 이메일을 전혀 읽지 않은 채 흐름이
UPDATE_PASSWORD 필수 액션으로 진행됩니다.
| 식별자 | CVE-2026-18963, GHSA-4gv3-mc9p-5wqc |
| 분류 | CWE-640, 비밀번호 분실에 대한 취약한 비밀번호 복구 메커니즘 |
| CVSS v3.1 | 9.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 영향 받는 버전 | >= 26.0.0, < 26.4.15, >= 26.5.0, < 26.6.6, >= 26.7.0, < 26.7.2 |
| 패치된 버전 | 26.4.15, 26.6.6, 26.7.2. 26.8.0 라인은 수정 사항과 함께 배포됨 |
| 패키지 | org.keycloak:keycloak-services (Maven) |
| 공개일 | 2026년 8월 18일 |
| 보고자 | James Paremain, Red Hat 권고문에 크레딧 명시 |
유일한 전제 조건은 해당 렐름이 인증되지 않은 사용자에게 비밀번호 복구를 제공한다는 것입니다. 자격 증명, 토큰 또는 사전 세션은 필요하지 않습니다.
순서대로 여덟 개의 요청입니다. 태그는 도구가 출력하는 단계 레이블 및
--out 아래에 기록하는 파일 이름과 일치합니다.
| # | 태그 | 메서드 | 수행 작업 |
|---|---|---|---|
| 1 | login | GET | 인증 URL. 인증 세션을 설정하고 재설정 링크를 생성합니다. |
| 2 | reset | GET | 자격 증명 재설정 페이지. 사용자 이름 양식을 포함합니다. |
| 3 | tryanother | POST | tryAnotherWay=on. 범위가 지정되지 않은 노트를 기록합니다. |
| 4 | username | POST | 식별자. 흐름을 진행시켜 이메일 실행이 현재가 되도록 합니다. 식별자가 확인되면 복구 메일을 발송합니다. |
| 5 | reenter | GET | 다시 재설정 URL. 오래된 노트로 인해 선택기가 이메일 실행에 바인딩되어 렌더링됩니다. |
| 6 | action | POST | 해당 실행의 loginAction에 빈 본문. 보호되지 않은 핸들러를 호출합니다. |
| 7 | required-action | GET | Location을 따라 UPDATE_PASSWORD 페이지로 이동합니다. |
| 8 | setpassword | POST | 새 비밀번호. --set-password가 있을 때만 전송됩니다. |
6단계가 익스플로이트이며, 빈 본문이 핵심입니다. 대신
authenticationExecution=<id>를 POST하는 것은 일반적인 선택기 상호작용입니다.
이는 실행을 전환하고 복구 메일을 다시 보내지만, 결코 결함에 도달하지
않습니다. 빈 POST는 어떤 챌린지에도 응답하지 않으므로, Keycloak은 오래된
노트가 현재로 남겨둔 실행에 대해 핸들러를 호출합니다.
# Decide whether the instance is affected. Changes nothing, sends no mail.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check
# Additionally report which identifiers resolve to a real account.
# Resolving accounts receive a recovery email. See the note below.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check --users-file names.txt --rate 0.25
# Reach the set-password form for one account and stop there.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected]
# Complete the takeover. This locks the account holder out.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--set-password 'N3w-passw0rd!'
--check는 <16 hex>@<8 hex>.invalid 형식의 임의 식별자로 프로브합니다. .invalid TLD는 RFC 2606에 의해 예약되어 있어 확인할 수 없으므로, 프로브가 실제 계정과 충돌할 수 없고 메일이 발송될 수도 없습니다. 이는 누구에게도 영향을 주지 않으면서 빌드의 상태를 확인합니다.
--users를 추가하면 상황이 달라집니다. 4단계는 일반적인 비밀번호 찾기 제출입니다. SMTP가 구성된 realm에서 이메일 주소를 가진 사용자로 확인되는 식별자는 취약한 빌드와 패치된 빌드 모두에서 실제 비밀번호 복구 이메일을 받게 됩니다. 단순 프로브는 그렇지 않은데, 사용자가 null일 때ResetCredentialEmail.authenticate()가 아무것도 전송하지 않기 때문입니다.--check --users는 실제 사람에게 알림을 보내는 흐름으로 취급하고, 실행하기 전에 권한 범위 내에 있는지 확인하십시오.
이 도구는 6단계, 즉 빈 POST에 대한 응답을 기준으로 분류합니다.
| 응답 | 결과 | 의미 |
|---|---|---|
| 400 | account-absent | 핸들러가 액션 토큰 없이 실행되었고 사용자를 확인하지 못했습니다. 빌드가 취약합니다. |
Location이 있는 302 | form-reached | 핸들러가 실행되었고 계정이 확인됩니다. 취약하며, 이 식별자는 실제입니다. |
| 그 외 | not-vulnerable | 빈 POST가 진행되지 않았습니다. |
| 5단계에서 선택자 없음 | not-vulnerable | 오래된 노트가 적용되지 않았습니다. |
| 8단계 이후 302 | password-set | 비밀번호가 변경되었고 realm이 세션을 발급했습니다. |
| 재시도 예산을 초과한 429 | rate-limited | 판정 없음. 실행이 중단되었습니다. |
| 스로틀링 이후 도달한 부정 결과 | inconclusive | 아래를 참조하십시오. |
400이 중요한 응답입니다. 이는 빌드가 취약함과 이 계정이 존재함을 구분해 주므로, --check가 실제 사용자를 하나도 열거하지 않고도 인스턴스에 대한 노출 여부를 확인할 수 있습니다. 식별자 확인은 그다음에 별도로 선택적으로 다루는 문제입니다.
부정 결과는 아무것도 스로틀링되지 않았을 때만 타당합니다. Keycloak은 자체 시계에 따라 인증 세션을 만료시킵니다. 체인 중간의 429는 백오프 비용을 발생시키며, 긴 대기 후 재개된 체인은 패치된 빌드가 반환하는 것과 정확히 같은 페이지를 반환합니다. 이를 취약하지 않음으로 보고하는 것은 검사기가 절대 가져서는 안 되는 유일한 실패 모드이므로, 스로틀 이벤트 이후 도달한 모든 부정 결과는 스로틀 횟수와 더 느리게 재실행하라는 조언과 함께 inconclusive로 하향 처리됩니다.
배포 환경에 대해 하드코딩된 것은 없습니다. 이 도구는 필요한 정보를 읽어들입니다:
--url과 --realm에서 도출되거나 --discovery-url로 직접 제공되는 realm의 OIDC 디스커버리 문서에서 가져옵니다. --auth-url을 전달하면 디스커버리를 완전히 건너뜁니다.login-actions/reset-credentials가 포함된 첫 번째 링크입니다.싱글 페이지 애플리케이션 테마(Keycloakify 및 유사한 것)는 서버 측 <form> 요소를 전혀 렌더링하지 않습니다. 액션은 테마가 <script> 블록에 임베드하는 kcContext 객체에만 존재합니다. 이 도구는 해당 객체에서 loginAction과 loginResetCredentialsUrl을 읽어들이는 것으로 폴백하며, \/와 \uXXXX를 이스케이프 해제합니다. 폼이 없으면 이월할 필드도 없으므로, 요청 본문은 해당 단계에서 재정의하는 것만 포함하며, 필드 이름은 --username-field와 --password-fields에서 가져옵니다.
모든 프로토콜 기본값은 플래그로 노출되며, 선택자 필드 자체(--selector-field, 기본값 tryAnotherWay=on)도 포함됩니다.
429는 절대 판정이 아니며, 그 응답을 받은 속도로 재시도하면 또 다른 429를 받을 뿐입니다. 페이서는 토큰 버킷이 아닌 요청 간 간격을 기준으로 강하게 백오프하고 천천히 회복합니다:
--rate 요청/초에서 시작하며, 절대 이를 초과하지 않습니다.
--rate는 상한선이며, 수렴해야 할 목표가 아닙니다.--slow-factor만큼 넓어지고 넓어진 상태를 유지합니다.
다음 요청을 위해 원래대로 되돌아가지 않습니다.Retry-After는 엣지가 이를 보낼 때 존중되며, 해당 대기 시간에 한해 계산된
백오프를 재정의합니다.--recover-after번의 연속적인 정상 응답 이후에만 --recover-factor만큼
좁혀지며, 요청된 속도를 넘지 않습니다.--floor-rate는 실행이 얼마나 느려질 수 있는지 제한합니다.--jitter에 의해 무작위화되어 요청 패턴이 메트로놈처럼 되지 않습니다.--retries는 단일 스로틀된 요청의 재시도를 제한합니다. 이를 초과하면 실행은
추측하지 않고 rate-limited로 중지됩니다.모든 실행은 페이싱이 어느 수준으로 안정화되었는지 보고합니다:
pacing: asked for 0.5 rps, settled at 0.125 rps after 3 throttled request(s);
pass --rate 0.125 next time
그 줄이 이 메커니즘의 핵심이다. 한 번의 실행이 엣지가 무엇을 허용하는지 측정하고, 다음 실행은 거기서부터 시작한다.
목록과 함께 --check를 사용하면 각 식별자는 자체 쿠키 저장소를 가진
새로운 Exploit 인스턴스를 받지만, 모두 하나의 pacer를 공유하므로 학습된
속도가 식별자마다 다시 발견되는 대신 전체 목록에 걸쳐 유지된다.
--address는 Host 헤더와 TLS SNI를 호스트명에
바인딩한 상태로 모든 연결을 하나의 IP에 고정한다. 이는 connect()가
고정된 주소로 연결하고 wrap_socket(server_hostname=self.host)를 호출하는
커스텀 HTTPSConnection이다.None을 반환하는 것으로
교체되는데, 이 체인의 모든 Location이 증거이기 때문이다. 6단계의
302가 판정이며, 8단계의 302는 인가 코드가 있는 곳이다.--insecure가 전달되지 않는 한.--user-agent와 반복 가능한
--header NAME:VALUE. 아무것도 주입되지 않는다. 상관 헤더도, 마커
파라미터도, 합성 트레이서도 없으므로 요청 지문은 그것이 주장하는
클라이언트를 대표하는 상태로 유지된다.--out DIR은 모든 요청과 응답을 번호가 매겨진 쌍으로 기록한다:
DIR/01-login.headers request line, request headers, status, response headers
DIR/01-login.body the response body, verbatim
DIR/02-reset.headers
...
--check에서는 대상별로 네임스페이스가 지정됩니다. 무작위 프로브의 경우 DIR/probe/, 각 식별자의 경우 DIR/id-001/, DIR/id-002/가 사용되므로, 목록 실행은 하나의 요약이 아니라 요청별 매니페스트를 생성합니다.