hunt-graphql について
セキュリティ研究者向けのGraphQL脆弱性調査スキルです。エンドポイント発見、スキーマ把握、認可テストに加え、GraphQLとRESTの連携による状態不整合や権限残存に重点を置きます。
主な機能
- 01GraphQLエンドポイントと技術スタックの調査
- 02スキーマ列挙と関係の可視化
- 03RESTとGraphQLの重複領域分析
- 04認可境界と状態不整合のテスト
- 05バッチクエリと競合状態の調査
- 06影響検証と再現手順の記録
活用シーン
- 01API間でアクセス取り消しが一致するか確認
- 02グローバルIDの不正アクセスをテスト
- 03エイリアスバッチ処理とレート制限を評価
- 04GraphQLの競合バグを再現して記録
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-graphqlインストール後、Claude Code で/hunt-graphqlを入力して読み込まれたか確認します。
詳細
主要な標的
GraphQL の脆弱性は、攻撃対象領域が広く、かつ深いため、高い価値があります。単一のエンドポイントから、データモデル全体、権限昇格の経路、API 間の状態の不整合が露呈する可能性があります。特に高額な報奨金が期待できるのは、次のような標的です。
- プラットフォーム API(GitHub、Shopify、Stripe 級の標的)。GraphQL のミューテーションが、同じリソースを管理する REST API と連携するケースです。
- GraphQL のミューテーションと REST エンドポイント間の競合状態。状態の同期がアトミックでない場合に発生し、安定して中~高程度の深刻度に該当します。
- 認可状態の永続化に関するバグ。チーム/組織/リポジトリのメンバーシップ状態が一方の API で制御され、もう一方の API から読み取りや書き込みが可能なケースです。
- B2B SaaS プラットフォーム。スキーマをたどることで、あるテナントが別のテナントに影響を及ぼせる場合、深刻度はクリティカルです。
- 内部管理用 GraphQL エンドポイント。誤って低権限ユーザーに公開されているケースです。
GitHub の報告事例は、主要な標的に共通するパターンを示しています。2 つの API が真の状態について食い違うため、取り消されるべき権限が残り続けるというものです。
攻撃対象領域の手がかり
URL パターン:
/graphql
/api/graphql
/v1/graphql
/query
/gql
/graph
/api/v2/graphql
/internal/graphql
レスポンスヘッダー:
Content-Type: application/json (with query body)
X-Request-Id + no REST-style path params = likely GraphQL
JavaScript ソースのパターン:
// grep for these in JS bundles
"query {"
"mutation {"
"__typename"
"apollo"
"ApolloClient"
"graphql-tag"
"gql`"
"operationName"
"GRAPHQL_URI"
技術スタックの手がかり:
- JS バンドル内の Apollo Server/Client
- React アプリ内の Relay
grapheneまたはstrawberry(Python)、graphql-ruby、gqlgen(Go)、Lighthouse(Laravel)- Burp の履歴にある、
{"query": "..."}形式の本文を含む POST リクエスト - いずれかのレスポンスに
__schemaまたは__typeがあれば、GraphQL が使われていることを確認できます。
偵察に利用できる情報源:
github.comの検索:"graphql" site:target.com- Wayback Machine で
/graphqlパスを検索 LinkFinderまたはgetallurlsで JS バンドルをスキャン
手順に沿った調査方法
-
エンドポイントを見つける — JS バンドルをクロールし、
/graphql、/api/graphqlを確認します。また、Burp のパッシブスキャンで検出された、クエリフィールドを含むapplication/jsonの POST リクエストも確認します。 -
イントロスペクションをテストする — 完全なイントロスペクションクエリを送信します。ブロックされる場合でも、フィールド単位の列挙を試します。
{ __typename }結果が返る場合、イントロスペクションは部分的にブロックされていても、スキーマを特定できる可能性があります。
-
スキーマ全体をマッピングする —
InQL(Burp 拡張機能)またはgraphql-voyagerを使って、関係を可視化します。特に次の点を探します。- 所有権、権限、メンバーシップを変更するミューテーション
- REST API の機能を再現するミューテーション
-
REST/GraphQL の重複領域を特定する — REST と GraphQL の両方から変更できるリソースをすべて記録します。これらの二重書き込み領域が、競合状態の調査対象です。
-
各ミューテーションの認可境界をテストする — 低権限ユーザーとしてミューテーションを再実行します。サーバーは、同等の REST 呼び出しと同じ認可を適用していますか?
-
API 間の状態不整合を調査する — 次のような手順を見つけます。
- REST の操作でアクセスを取り消すべきである
- GraphQL のミューテーションでアクセスが再付与される、または維持される
- 実行順序をテストする:REST を先に実行 → GraphQL を実行 → 状態を確認。その後、GraphQL を先に実行 → REST を実行 → 状態を確認
-
ロールやメンバーシップの変更後も権限が残るかテストする — REST 経由でユーザーを削除し、その後、対象リソースに対応する GraphQL ミューテーションを呼び出します。両方の API から現在の状態を照会して比較します。
-
ノード ID の IDOR を調査する — GraphQL のグローバル ID には、オブジェクトの型と ID がエンコードされていることがよくあります。オブジェクトの境界をまたいで、またアカウントのコンテキストを変えて ID を入れ替えます。
-
バッチクエリの悪用を確認する — 操作の配列を送信して、レート制限を回避したり、列挙を拡大したりできるかテストします。
-
再現手順を正確に記録する — 競合状態のバグでは、時間に依存する手順を確実に再現できるようにする必要があります。
ペイロードと検出パターン
完全なイントロスペクションクエリ:
{
__schema {
types {
name
fields {
name
type {
name
kind
}
}
}
}
}
最小限のイントロスペクションプローブ(バイパス試行):
{ __typename }
curl によるイントロスペクションテスト:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"query":"{ __schema { queryType { name } } }"}' | jq .
フィールド候補プローブ(イントロスペクションの全面的なブロックをバイパス):
{ unknownField }
レスポンスに "Did you mean: [realFieldName]?" が含まれている場合、イントロスペクションが無効でもスキーマを列挙できます。
バッチクエリの増幅:
[
{"query": "{ user(id: 1) { email } }"},
{"query": "{ user(id: 2) { email } }"},
{"query": "{ user(id: 3) { email } }"}
]
サブスクリプションの乗っ取り(他ユーザーのチャネルへのアクセス):
subscription { messageAdded(channelId: "OTHER_USERS_CHANNEL") { content sender { email } } }
サブスクリプションがユーザーごとにスコープされていない場合、攻撃者は他ユーザーのチャネルや会話からリアルタイムイベントを受信できます。
エイリアスのバッチ処理による複数コード OTP/2FA の総当たり攻撃:
mutation {
v1: verifyOtp(code:"000001"){token}
v2: verifyOtp(code:"000002"){token}
v3: verifyOtp(code:"000003"){token}
}
1 回の GraphQL リクエストで、異なる OTP コードを指定して同じミューテーションにエイリアスを付けます。これを並列 HTTP と組み合わせると、リクエストごとのレート制限を回避でき、少ないネットワーク往復で総当たり攻撃を実行できます。
RC デシンクテストパターン(疑似シーケンス):
# Step 1: Grant access via REST
curl -X PUT https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN" \
-d '{"permission":"admin"}'
# Step 2: Revoke via REST
curl -X DELETE https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN"
# Step 3: Re-assert via GraphQL mutation
curl -X POST https://api.target.com/graphql \
-H "Authorization: bearer ATTACKER_TOKEN" \
-d '{"query":"mutation { updateTeamsRepository(input: {repositoryId: \"REPO_ID\", teamId: \"TEAM_ID\", permission: ADMIN}) { clientMutationId } }"}'
# Step 4: Verify persistent access
curl https://api.target.com/repos/ORG/REPO/teams \
-H "Authorization: token ADMIN_TOKEN"
JS バンドル内の GraphQL を grep で検索:
grep -Eo '(query|mutation|subscription)\s+\w+\s*[\({]' bundle.js
grep -Eo '"(/[a-z0-9/_-]*graphql[a-z0-9/_-]*)"' bundle.js
GraphQL イントロスペクションと監査ツール:
# InQL (Burp extension) — visualize GraphQL schema and relationships
inql -t https://target/graphql --generate-queries
# Clairvoyance — brute-force field names when introspection is disabled
python3 clairvoyance.py -u https://target/graphql -H "Authorization: Bearer TOKEN" -w wordlist.txt -o schema.json
# GraphQL Cop — scan for common misconfigurations (introspection enabled, no depth limits, etc.)
graphql-cop -t https://target/graphql
よくある根本原因
-
アトミックロックのないデュアルライト — 開発者が、同じリソースの変更処理を REST と GraphQL の両方で個別に実装します。どちらのシステムも、そのリソースについて相手の存在を認識していません。状態の更新は直列化も比較もされません。
-
認可ミドルウェアの不整合 — REST エンドポイントはある認証レイヤー(例:ミドルウェアチェーン)を通る一方で、GraphQL のリゾルバーは別のリゾルバーレベルのチェックを通ります。同じアクションでも、適用される制御が異なります。
-
「新しい REST」としての GraphQL 移行 — チームが権限モデルを監査せずに、REST の機能を反映した GraphQL ミューテーションを追加します。GraphQL 版は成熟度が低く、REST 版に時間をかけて蓄積されたチェックが抜けています。
-
本番環境でイントロスペクションが有効なまま — デフォルトのフレームワーク設定(Apollo、Graphene)では、すべての環境でイントロスペクションが有効になります。開発者はこれを「単なるドキュメント」と見なし、無効化を忘れてしまいます。
-
再認可を行わず Node ID を信頼 — GraphQL のグローバル ID(
base64("ObjectType:123"))をデコードした後、リクエストしたユーザーがそのオブジェクトにアクセスできるか確認せずに信頼します。 -
ミューテーションの副作用が反映されない — REST のアクションで連鎖的な処理(例:チームからの削除に伴う権限の取り消し)が発生する場合でも、対応する GraphQL ミューテーションでは同じ連鎖処理が実行されません。
バイパス手法
防御:イントロスペクションの無効化
- 無効なフィールド名を送り、「did you mean X?」というレスポンスを解析して、フィールド候補エラーを利用する
clairvoyanceを使い、ワードリストに対してフィールド名を総当たりする- JS バンドルを調べ、スキーマが漏れるハードコードされたクエリ文字列を探す
防御:深さ制限
- フラグメントスプレッドを使って、制限に達することなく実効的な深さを増やす:
fragment F on User { repos { teams { members { ...F } } } }
防御:IP ごとのレート制限
- バッチ操作を使う(1 つの POST にクエリの配列を含める)
- 認証済みセッションを分散させる
防御:ミューテーションの認証チェック
- 権限レベルの異なるトークン(閲覧者、メンバー、管理者)でテストする
- 未認証でテストする — セッションをまったくチェックしないミューテーションもあります
- 異なる組織 のトークンでテストする — マルチテナントの IDOR
防御:__schema をブロックする WAF
- イントロスペクションフィールドにエイリアスを付ける:
{ s: __schema { t: types { n: name } } }
- HTTP パラメーター汚染や、別の Content-Type ヘッダーを使う
防御:操作のホワイトリスト登録(永続化クエリ)
extensions.persistedQueryのハッシュが一致しない場合に、サーバーがアドホッククエリへフォールバックするか確認する- ホワイトリストに登録されていないエンドポイント(開発環境、ステージング環境、内部プロキシ)を探す
エイリアスバッチ処理:競合に勝つ場合と勝たない場合
「エイリアスバッチ処理は、ユーザーごとのレート制限や二重使用防止を回避する」という主張がよくあります。実際に効果があるかどうかは、リゾルバーの実行モデルによって異なります。
| リゾルバーの種類 | エイリアス付きミューテーションの動作 | エイリアスバッチ処理で競合に勝てるか |
|---|---|---|
マルチスレッド/DataLoader でバッチ処理される非同期処理 | エイリアスは並行して実行され、バッチを介して状態を共有します | YES — 1 回の HTTP リクエストで、競合対象を N 回に増幅できます |
| シングルスレッド/リクエストごとに DB 接続が 1 つ | エイリアスは順次実行され、最初のミューテーションで後続が阻止されます | NO — 並列 HTTP と組み合わせてください |
| 分散ゲートウェイ(Apollo Federation) | サブクエリーがサブグラフに並行してディスパッチされます | 各サブグラフによります |
検証例(シングルスレッドの Flask + SQLite リゾルバー):
- 1 回のリクエストで
redeemCouponミューテーションを 10 個エイリアス指定 → 成功するのはr1のみで、r2〜r10 はalready_redeemedで失敗します。エイリアスバッチ処理だけでは不十分です。 - 同じ 10 個のミューテーションを 20 件の並列 HTTP POST として送信 → 20 件すべて成功します(100 ドルのクーポンから 2,000 ドル)。
運用ルール: エイリアスバッチ処理は、単一 RTT の偵察プリミティブとして扱ってください。競合対象を狙った悪用では、hunt-race-condition の並列 HTTP/Turbo Intruder シングルパケット攻撃と組み合わせます。docs/verification/phase2e-jwt-graphql-race.md のテスト 11 とテスト 12 で検証済みです。
ゲート 0 の検証
-
攻撃者は今、何ができますか? 具体的な行為である必要があります。見てはいけないデータへのアクセス、権限取り消し後の特権の保持、他のユーザーのリソースの変更などです。「スキーマが見える」だけでは不十分です。そのスキーマによって何が可能になりますか?
-
被害者は何を失いますか? 実際の資産である必要があります。データの機密性、アクセス制御の完全性、組織のセキュリティ保証などです。RC パターンでは、チームを削除してもすべてのアクセスが取り消されるという保証を、組織管理者が失います。これはセキュリティ上の契約違反です。
-
何もない状態から 10 分以内に再現できますか? RC/非同期化バグの場合は、正確な curl の手順を記述してください。それを 2 回実行します。権限が確実に残る(タイミングに依存して不安定にならない)場合は、報告可能です。ミリ秒単位のタイミング運に左右される場合は、発生する時間幅を記録し、負荷の低い時間帯にテストしてください。
実際の影響事例
シナリオ A — チーム削除後も密かに管理者アクセスを維持(GitHub パターン)
リポジトリにチームメンバーとして正当に管理者アクセスを持っていた攻撃者が、組織管理者によって REST API 経由でチームから削除されます。削除が完了する前に、攻撃者は updateTeamsRepository GraphQL ミューテーションを呼び出し、自分のチームに管理者権限を再付与します。REST による削除と GraphQL による再付与の間に不整合が生じ、UI ではチームが削除されたように表示されますが、GraphQL の状態では管理者レベルのアクセス権が維持されます。攻撃者は、チームのメンバー一覧に表示されないまま、リポジトリへの秘密の書き込みアクセスを無期限に保持し、コードのプッシュや CI/CD のシークレットの読み取りを行えます。この状態は組織の監査を経ても継続します。
シナリオ B — リポジトリ移管時の競合を利用した秘密のアクセス(GitHub パターン)
管理者アクセス権を持つ攻撃者が、REST API 経由でリポジトリを別の組織へ移管します。移管中または移管後に、攻撃者は移管済みリポジトリの ID に対して updateTeamsRepository を呼び出します。GraphQL ミューテーションが現在の組織の所有状態を REST の移管イベントと一貫して検証しないため、攻撃者の元のチームは、別の組織が所有するリポジトリへの管理者アクセスを維持します。移管先の組織は、このチームの関連付けを確認できません。攻撃者は、正当な関係のない組織から知的財産を持ち出すことができます。
シナリオ C — イントロスペクションを調査の前提条件として利用(Shopify パターン)
プログラムのルール上、イントロスペクションが意図的に有効化されているプラットフォームで、ハンターがスキーマ全体を調査し、公開ドキュメントには記載されていない fulfillmentOrderMove および inventoryAdjust のミューテーションを発見します。これらのミューテーションは、スキーマ上で確認できるスコープ検証なしに、引数としてマーチャント ID を受け取ります。この調査により、マーチャント間のデータ分離を対象とした IDOR テストを直接行えるようになります。イントロスペクション自体の深刻度はゼロですが、重大な脆弱性の発見につながる入口となります。
公開済みレポートの引用(追加調査 +9 件 — 2019〜2024 年)
以下の実在が確認されたバグバウンティ/協調的開示の事例は、当初の内部参照 3 件を補完し、このスキルの対象範囲を広げるものです。それぞれ異なる GraphQL のサブクラスであり、引用先のレポートには実際に動作する PoC が記載されています。
-
HackerOne — GraphQL
User型を介したユーザーデータの機密情報漏えい(H1 #489146)- サブクラス:フィールドレベルの認可不備(PII の漏えい)
- ペイロード:
email、backup_codes_hash、facebook_user_id、account_recovery_phone_number_verified_at、totp_enabledを返す直接のuser(id:...)クエリ - 根本原因:バックエンドの移行でフィールドレベルの認可がない GraphQL
User型が導入され、認証済みのユーザーなら誰でも、すべてのユーザーの PII を列挙できました - 年:2019 年 — $20,000、賛成票 1,028 件
-
HackerOne —
DestroyLlmConversationミューテーションの IDOR(Copilot のプレリリース)(H1 #2218334)- サブクラス:AI/LLM 機能におけるミューテーションの IDOR
- ペイロード:
mutation { destroyLlmConversation(input:{id:"<victim_conv_id>"}) { … } } - 根本原因:新しい LLM 会話ミューテーションに認可デコレーターがないままリリースされ、任意のユーザーがあらゆる会話を削除できました
- 年:2023 年 — リリース前に発見されたため、報奨金なし(賛成票 202 件)
-
Shopify — テナントをまたぐ
BillingDocumentDownloadの IDOR(H1 #2207248)- サブクラス:テナントをまたぐ relay GID の IDOR
- ペイロード:
query { billingDocumentDownload(id:"gid://shopify/BillingInvoice/<other_shop_id>") { url } } - 根本原因:
BillingInvoiceリゾルバーはリクエスト元のショップを認可していましたが、請求書がそのショップに属するかどうかを検証していませんでした - 年:2024 年 — $5,000、賛成票 175 件
-
Shopify — 負のコストによるレート制限の回避 (H1 #481518)
- サブクラス:クエリコスト計算の悪用(エイリアスのバッチ処理と類似するパターン)
- ペイロード:
query { products(first:-100) { … } }— 負のfirstによりクエリコストへの寄与が負になり、呼び出すたびにリーキーバケットが補充されました - 根本原因:クエリコスト計算で下限値がゼロに設定されておらず、負の値が消費済みの予算から差し引かれていました
- 年:2019 — $1,000
-
Stripe —
UpdateAtlasApplicationPersonを介したテナント間 IDOR (H1 #1066203)- サブクラス:ミューテーションにおけるテナント間 IDOR
- ペイロード:
mutation { updateAtlasApplicationPerson(input:{personId:"<victim_person_id>", …}) }— 他の加盟店の Stripe Atlas 申請に共同創業者を追加・変更します - 根本原因:ミューテーションでは「何らかの加盟店の管理者であること」だけが確認され、「この人物が所属する加盟店の管理者であること」は確認されていませんでした
- 年:2020 — 報奨金は非公開(解決済み)
-
EXNESS — GraphQL の
allTicksクエリにおける SSRF (H1 #1864188)- サブクラス:GraphQL 引数を介した SSRF
- ペイロード:
query { allTicks(source:"http://169.254.169.254/latest/meta-data/") { … } }—source引数がサーバー側の HTTP クライアントに渡されます - 根本原因:GraphQL フィールドが URL 引数を受け付け、スキームやホストの許可リストなしにアクセスしていました
- 年:2023 — $3,000、249 件の賛成票
-
EXNESS — GraphQL 属性のバッチ処理による DoS (H1 #2293642)
- サブクラス:未認証エンドポイントに対するバッチ処理/深い属性の増幅を介した DoS
- ペイロード:深くネストした属性ツリーをそれぞれ要求する N 個のバッチ処理オペレーションを含む単一の HTTP リクエストを、オリジンサーバーのメモリ不足が続くまで送信します
- 根本原因:未認証の
/graphqlにクエリの深さ、複雑さ、バッチサイズの制限がありませんでした - 年:2024 — 報奨金は非公開(解決済み)
-
GitLab —
runnerUpdateを介した悪意のあるランナーの関連付け(CVE-2023-2478) (アドバイザリー)- サブクラス:ミューテーションの認証回避/プロジェクトのスコープ確認漏れ
- ペイロード:
mutation { runnerUpdate(input:{id:"<attacker_runner_gid>", associatedProjects:["<victim_project_gid>"]}) } - 根本原因:
runnerUpdateで、呼び出し元が対象プロジェクトのメンテナーであることが確認されていませんでした。任意のユーザーが悪意のあるランナーを紐付けて CI ジョブを傍受できました(ビルドシークレットやコード実行が対象)。 - 年:2023 — 重大、CVSS 9.6(H1 の報奨金は非公開。GitLab の重大度「Critical」層は通常 $20k–$35k)
-
AS Watson — 制限のない
createAdminUserミューテーションを介した認証回避 (HackerOne ブログ)- サブクラス:認証なしで機密性の高いミューテーションにアクセス可能(イントロスペクションを活用した発見)
- ペイロード:スキーマをイントロスペクションで列挙した後、未認証で
mutation { createAdminUser(input:{email:"x@x", role:"ADMIN", password:"…"}) { token } }を実行します - 根本原因:スキーマにフィールド単位の認可ディレクティブがなく、
createAdminUserが公開ロールに公開されていました - 年:2023 — HackerOne Ambassador World Cup で「Best Bug」賞を受賞
関連スキルと攻撃チェーン
hunt-idor— GraphQL のnode(id:)とグローバル Relay ID のリゾルバーは IDOR の温床です。同じ形式でスコープ制御がありません。攻撃の基本形:GraphQL イントロスペクション+IDOR(node()リゾルバー)→ Base64 でデコードした type:id を再利用し、テナントをまたいでデータを取得します。hunt-api-misconfig— GraphQL のミューテーションはマスアサインメントの温床です。クライアントが入力オブジェクト全体を送信し、サーバーがマージするためです。攻撃の基本形:GraphQL ミューテーション+追加フィールド(isAdmin:true、verified:true)→ マスアサインメント → 権限昇格。hunt-business-logic— GraphQL エイリアスを使うと、同じミューテーションを 1 回のリクエストで N 回呼び出せるため、リクエスト単位のレート制限を回避できます。攻撃の基本形:エイリアス付きミューテーション+ビジネスロジックの欠陥 → 1 回のネットワーク往復でクーポンを N 回引き換えます。hunt-race-condition— GraphQL のバッチ処理では N 件のミューテーションを 1 つの HTTP パケットにまとめられるため、1 パケットで競合状態を引き起こすのに最適です。攻撃の基本形:GraphQL バッチ+競合状態 → アトミックな更新の欠如 → 残高の二重消費。security-arsenal— GraphQL ペイロードパックを読み込みます。イントロスペクションクエリ、スキーマ候補のエラープローブ、エイリアス増幅テンプレート、深度爆弾型 DoS ペイロード、バッチ攻撃テンプレートが含まれます。triage-validation— Body-Diff ルールを適用します。イントロスペクションだけでは情報提供にとどまります。提出前に、テナントをまたぐ具体的なデータ読み取り、または影響を伴うミューテーションの PoC を確認してください。