hunt-graphql について
hunt-graphqlは、ペネトレーションテスターやバグバウンティ研究者向けのGraphQLセキュリティ調査スキルです。GraphQLとREST API間の認可不備や状態の不整合に重点を置きます。
主な機能
- 01GraphQLエンドポイントの発見
- 02スキーマとミューテーションの把握
- 03認可境界のテスト
- 04API間の状態不整合の確認
- 05競合状態の評価
活用シーン
- 01セキュリティテストでのGraphQL API評価
- 02RESTでの権限削除後にアクセスが残るか確認
- 03テナント間のオブジェクトアクセス検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ uphiago/ recon-skills --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/graphqlパスの Wayback MachineLinkFinderまたはgetallurlsを使った JS バンドルのスキャン
手順ごとのハンティング方法
-
エンドポイントを特定する — JS バンドルをクロールし、
/graphql、/api/graphqlを確認します。また、Burp のパッシブスキャンで、クエリフィールドを含むapplication/jsonの POST リクエストが検出されていないか確認します -
イントロスペクションをテストする — 完全なイントロスペクションクエリを送信します。ブロックされている場合でも、フィールド単位の列挙を試します。
{ __typename }結果が返る場合、イントロスペクションは部分的にブロックされていても、スキーマを特定できる可能性があります
-
スキーマ全体をマッピングする —
InQL(Burp 拡張機能)またはgraphql-voyagerを使って、関係を可視化します。特に、次のものを探します。- 所有権、権限、またはメンバーシップを変更するミューテーション
- REST API の機能を反映するミューテーション
-
REST/GraphQL の重複箇所を特定する — REST と GraphQL の両方で変更できるリソースをすべて記録します。こうした二重書き込みの箇所が、競合状態(RC)の標的になります。
-
ミューテーションごとに認可境界をテストする — 低権限ユーザーとしてミューテーションを再実行します。同等の 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 --max-time 30 --connect-timeout 10 -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 } }"}
]
競合状態による不整合のテストパターン(擬似シーケンス):
# Step 1: Grant access via REST
curl --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -X DELETE https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN"
# Step 3: Re-assert via GraphQL mutation
curl --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 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
ブラインドなスキーマ列挙のための InQL/clairvoyance:
python3 clairvoyance.py -u https://target.com/graphql \
-H "Authorization: Bearer TOKEN" \
-w wordlist.txt -o schema.json
よくある根本原因
-
アトミックなロックを伴わない二重書き込み — 開発者が同じリソースの変更処理を、REST と GraphQL の両方に個別に実装します。どちらのシステムも、そのリソースについてもう一方の存在を認識していません。状態の更新は直列化も比較もされません。
-
認可ミドルウェアの不整合 — REST エンドポイントはある認証レイヤー(例:ミドルウェアチェーン)を通る一方、GraphQL リゾルバーは別のリゾルバーレベルのチェックを通ります。同じアクションでも、適用される制御が異なります。
-
「新しい REST」としての GraphQL 移行 — チームが権限モデルを監査せずに、REST の機能を模倣する GraphQL ミューテーションを追加します。GraphQL 版は成熟度が低く、REST 版が長年かけて蓄積してきたチェックを省略しています。
-
本番環境でイントロスペクションを有効なままにする — デフォルトのフレームワーク設定(Apollo、Graphene)では、すべての環境でイントロスペクションが有効になっています。開発者はこれを「単なるドキュメント」と捉え、無効化し忘れます。
-
再認可を行わずにノード ID を信頼する — GraphQL のグローバル ID(
base64("ObjectType:123"))をデコードした後、リクエストしたユーザーがその特定のオブジェクトにアクセスできるか確認せずに信頼します。 -
ミューテーションの副作用が同期されない — REST アクションが連鎖的な効果を引き起こす場合(例:チームからの削除に伴う権限の連鎖的な取り消し)、GraphQL の同等のミューテーションでは同じ連鎖処理が実行されません。
バイパス手法
防御:イントロスペクションを無効化
- 無効なフィールド名を送信し、「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のハッシュが一致しない場合に、サーバーがアドホッククエリにフォールバックするか確認する- ホワイトリストに登録されていないエンドポイント(dev、staging、内部プロキシ)を探す
エイリアスバッチ処理:競合に勝つ場合と勝たない場合
「エイリアスバッチ処理はユーザーごとのレート制限や二重支払い防止を無効化する」という主張がよくあります。実際にこれが成功するかどうかは、リゾルバーの実行モデルによって異なります。
| リゾルバーの種類 | エイリアス付きミューテーションの動作 | エイリアスバッチ処理で競合に勝てるか? |
|---|---|---|
マルチスレッド/DataLoader による非同期バッチ処理 | エイリアスが並行実行され、バッチを介して状態を共有する | はい — 1 つの HTTP リクエストで、競合対象を N 回増幅できる |
| シングルスレッド/リクエストごとに単一の DB 接続 | エイリアスは直列実行され、最初のミューテーションで処理が阻止される | いいえ — 並列 HTTP と組み合わせる |
| 分散ゲートウェイ(Apollo Federation) | サブクエリがサブグラフに並行して送信される | 各サブグラフによる |
検証例(シングルスレッドの Flask + SQLite リゾルバー):
- 1 つのリクエストに
redeemCouponミューテーションを 10 個エイリアスで指定 → 成功するのはr1のみで、r2〜r10 はalready_redeemedで失敗します。エイリアスバッチ処理だけでは不十分です。 - 同じ 10 個のミューテーションを 20 件の並列 HTTP POST として送信 → 20 件成功($100 のクーポンから $2000)。
運用ルール: エイリアスバッチ処理は、単一 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によりクエリコストへの寄与が負になり、呼び出すたびにリーキーバケットが補充されました - 根本原因:クエリコスト計算で 0 を下限としておらず、負の値が消費済み予算から差し引かれていました
- 年: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://[REDACTED_IP]/latest/meta-data/") { … } }—source引数がサーバー側の HTTP クライアントに渡されました - 根本原因:GraphQL フィールドが URL 引数を受け付け、スキーム/ホストの許可リストによる制限なしにその URL にアクセスしていました
- 年:2023 — $3,000、賛成票 249 件
-
EXNESS — GraphQL 属性バッチ処理による DoS (H1 #2293642)
- サブクラス:未認証エンドポイントにおけるバッチ処理/深い属性の増幅による DoS
- ペイロード:深くネストした属性ツリーをそれぞれ要求する N 件のバッチ処理操作を単一の HTTP リクエストに含め、オリジンが OOM になるまで継続しました
- 根本原因:未認証の
/graphqlにクエリ深度、クエリ複雑度、バッチサイズの制限がありませんでした - 年:2024 — 報奨金非公開(解決済み)
-
GitLab —
runnerUpdateを介した悪意のある Runner の紐付け(CVE-2023-2478) (Advisory)- サブクラス:ミューテーションの認証回避/プロジェクトスコープの欠如
- ペイロード:
mutation { runnerUpdate(input:{id:"<attacker_runner_gid>", associatedProjects:["<victim_project_gid>"]}) } - 根本原因:
runnerUpdateは呼び出し元が対象プロジェクトの Maintainer であるかを確認していなかったため、任意のユーザーが自身の悪意のある Runner を紐付け、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」賞を受賞
検証
GraphQL のハンティング準備が整っていることを確認するには、このセルフテストを実行してください。
-
スキルの整合性 — スキルファイルが読み取り可能で、正しく構成されていることを確認します。
grep -q "name: hunt-graphql" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
カテゴリの確認 — スキルにカテゴリがあることを確認します。
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
落とし穴のセクション — 落とし穴が記載されていることを確認します。
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
3つのテストすべてで、スキルが適切に構成され、利用できる状態であることを確認します。
落とし穴
- 機密ミューテーションがない状態でイントロスペクションが有効 — イントロスペクションだけでは情報提供にとどまります。公開されたスキーマを通じて、悪用可能なクエリやミューテーションを実証する必要があります。
- フィールドレベルの認可がないのがパブリックデータだけ — 公開フィールドであれば、脆弱性ではありません。ユーザーに表示されるべきでないデータを公開する必要があります。
- レート制限を回避できないバッチング攻撃 — GraphQL のバッチ処理はブルートフォース攻撃を増幅できますが、レート制限が操作単位ではなくリクエスト単位の場合に限られます。
- DoS の証明がない深度/複雑度攻撃 — クエリの深度が高いことは単なる原始的な指標です。実際にサーバーのリソースが枯渇することを実証する必要があります。
- 永続クエリの許可リストのバイパス — 実際の発見事項は、APQ をバイパスしてイントロスペクションに到達することであり、スキーマの可視性ではありません。
関連スキルとチェーン
hunt-idor— GraphQL のnode(id:)とグローバルリレー 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 Payload Pack を読み込みます:イントロスペクションクエリ、スキーマ候補提示エラーのプローブ、エイリアス増幅テンプレート、深度爆弾 DoS ペイロード、バッチ攻撃テンプレート。triage-validation— Body-Diff Rule を適用します。イントロスペクションだけでは情報提供にとどまるため、提出前に、テナント間の具体的な読み取り、または影響を伴うミューテーションの PoC を必須とします。