
CVE-2026-18963(Keycloakのreset-credentialsフローにおける未認証のアカウント乗っ取り)の概念実証および脆弱性チェッカー。単一ファイルのPython製で、オプションのPlaywrightブラウザコンポーネント付き。
Keycloakのreset-credentialsフローバイパスに対する概念実証(PoC)および脆弱性チェッカーです。オプションのブラウザコンポーネントを使用すると、結果の状態を実際のChromeウィンドウでレンダリングできます。
このツールは単一ファイル cve_2026_18963_poc.py で、Python標準ライブラリに対して記述されています。サードパーティの依存関係はPlaywrightのみで、これは遅延インポートされるため、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 のパスワード忘れフローにおける認証バイパスです。 これは 2 つの欠陥が組み合わさって動作します。
スコープされていないノート。 ユーザーが認証器選択画面を開くと、
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 アドバイザリにクレジット) |
唯一の前提条件は、レルムが未認証ユーザーにパスワード回復を提供していることです。 資格情報、トークン、または事前のセッションは必要ありません。
順番に 8 つのリクエスト。タグはツールが出力するステップラベルおよび --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 が設定されたレルムでは、メールアドレスを持つユーザーに解決される識別子は、影響を受けるビルドでもパッチ済みビルドでも、本物のパスワード回復メールを受け取ります。素のプローブではこれが起きません。ユーザーが null の場合、ResetCredentialEmail.authenticate()は何も送信しないためです。--check --usersは実在の人物に通知を行うフローとして扱い、実行前にこれがあなたの権限の範囲内であることを確認してください。
このツールはステップ 6(空の POST)への応答に基づいて分類します。
| 応答 | 結果 | 意味 |
|---|---|---|
| 400 | account-absent | ハンドラがアクショントークンなしで実行され、ユーザーを解決しなかった。ビルドは影響を受けている。 |
Location 付きの 302 | form-reached | ハンドラが実行され、アカウントが解決された。影響を受けており、この識別子は実在する。 |
| それ以外 | not-vulnerable | 空の POST が進まなかった。 |
| ステップ 5 でセレクタなし | not-vulnerable | 古いノートが尊重されなかった。 |
| ステップ 8 後の 302 | password-set | パスワードが変更され、レルムがセッションを発行した。 |
| リトライ予算を超えた 429 | rate-limited | 判定なし。実行は停止した。 |
| スロットリング後に到達した否定結果 | inconclusive | 以下を参照。 |
400 が重要なものです。これはビルドが影響を受けていることとこのアカウントが存在することを切り分けるため、--check は実在のユーザーを一人も列挙することなく、インスタンスに対する露出を確認できます。識別子の解決は、それとは別のオプトインの問いとなります。
否定結果は、何もスロットリングされなかった場合にのみ妥当です。 Keycloak は独自のクロックで認証セッションを失効させます。チェーン途中の 429 はバックオフを要し、長時間のスリープ後に再開したチェーンは、パッチ済みビルドが返すのとまったく同じページを返します。それを影響なしと報告することは、チェッカーが決して犯してはならない唯一の失敗モードです。そのため、スロットルイベント後に到達した否定結果は、スロットル回数とより低速で再実行するよう促す助言とともに inconclusive に格下げされます。
デプロイメントについてハードコードされたものはありません。ツールは必要なものを読み取ります。
--url と --realm から導出されるか、--discovery-url で直接指定される、レルムの 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
その行がこのメカニズムの要点である。1回の実行でエッジが何を許容するかを測定し、次の実行はそこから開始する。
--check でリストを指定した場合、各識別子にはそれぞれ独自の cookie jar を持つ新しい Exploit インスタンスが割り当てられるが、すべてが1つの pacer を共有するため、学習されたレートは識別子ごとに再発見されるのではなく、リスト全体に引き継がれる。
--address は Host ヘッダーと TLS SNI をホスト名にバインドしたまま、すべての接続を1つの IP に固定する。これはカスタムの HTTPSConnection であり、その connect() は固定されたアドレスにダイヤルし、wrap_socket(server_hostname=self.host) を呼び出す。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/ が使われるため、リスト実行では単一のサマリーではなくリクエストごとのマニフェストが生成されます。
このモジュールは何かを書き込む前に umask(0o077) を設定するため、キャプチャは所有者のみアクセス可能な状態で保存されます。キャプチャにはセッション Cookie と完全なレスポンスボディが含まれます。このディレクトリは制限された資料として扱ってください。