đź§ GraphQL - Introspection Exploitation
What is it?
- Concept: GraphQL schemas are self-documenting via a built-in reflection API (
__schema,__typemeta-fields). If introspection isn’t disabled in production, you can query the API for its own blueprint — every query, mutation, type, field, and argument — without any docs, source code, or prior knowledge of the app. It turns a black-box API into a fully documented one, for free. - Impact: Introspection itself isn’t a vulnerability — it’s recon. But it routinely surfaces the exact vulnerabilities that get you in: hidden/debug mutations never wired into the frontend, IDOR-shaped arguments (
userId: Inton a query that should be session-scoped), mass-assignment surface in input types, and the precise call shape for every mutation — including the ones the UI never exposes.
How it works
- GraphQL servers implement a standard reflection schema (
__schema,__type,__Field,__InputValue, …) so tools like GraphiQL/Playground/Apollo Studio can auto-generate docs and autocomplete. - That reflection API is just more GraphQL — queryable over the exact same endpoint, same POST request, no special auth unless the server explicitly locks it down.
- A shallow
__schemaquery only returns field names by default. Arguments, return types, and input-object shapes each need to be queried explicitly. Most people stop after the first shallow dump — that’s where the real exploitation planning is actually done, so don’t stop there.
Security Quirks and Niche Facts
- “Introspection disabled” usually just means the
__schemaquery errors out — it doesn’t make the schema unknowable. Tools likeclairvoyancereconstruct schemas field-by-field via error-suggestion leakage even with introspection off. - Field names alone tell you almost nothing actionable. The payoff is in a second pass pulling
args(with full nestedtype { kind name ofType {...} }) andinputFieldsfor every input object — that’s what turns “there’s a mutation calledupdateProfile” into “here’s exactly what I can send it.” - Naming conventions leak intent.
devForgotPasswordsitting next toforgotPassword,debugLogin,_internal*,adminOnly*,v2*next to the “real” field — these are almost always leftover debug/staging endpoints that skip validation the real one enforces. Read the field list like a suspicious directory listing. - An argument that shouldn’t be there is the #1 tell for IDOR.
userProfile(userId: Int!)on something that should silently resolve from the caller’s session is a broken object-level authorization bug, spottable straight from the schema before you send a single request. See (incomplete) GraphQL - IDOR via Insecure Resolvers. - Return type matters as much as argument shape. If a field’s introspected return
kindisSCALAR(plainString/Boolean) instead ofOBJECT, sending a{ selection }on it throws a validation error (must not have a selection since type "String" has no subfields) — that error message alone confirms the return type without even checking__type. This is exactly the kind of quirk that reveals a raw secret (e.g. a password-reset token) being returned as a bare string instead of safely wrapped. - The global
types { name }list can contain orphaned types not actually wired to any field (leftover from refactors). Don’t assume a type in that list is reachable — always confirm via theMutation/Queryfield’s owntypeentry.
Exploitation
Prerequisites
- A reachable GraphQL endpoint (commonly
/graphql,/api/graphql,/v1/graphql,/gql) acceptingPOSTwith a JSON{"query": "..."}body - Introspection not disabled server-side (check first — it’s one request)
Attack Vectors
Step 1a — confirm it’s actually a GraphQL endpoint.
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ __typename }"}'__typename is a meta-field that’s always queryable, introspection on or off — it only proves you’re talking to a GraphQL engine, nothing about introspection specifically. Expected output:
{"data":{"__typename":"Query"}}A 404, an HTML error page, or {"errors":[...]} about the query being malformed means wrong path/method — try /api/graphql, /v1/graphql, /gql, or a GET with ?query=... before giving up on the target.
Step 1b — the actual introspection check. This is the one that matters — __schema/__type are the fields introspection-disable flags specifically block, __typename won’t tell you this.
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ __schema { queryType { name } } }"}'Introspection enabled — you get real schema data back:
{"data":{"__schema":{"queryType":{"name":"Query"}}}}Introspection disabled — a GraphQL-level error instead of data, data is usually null or absent. Exact wording varies by server/framework, but it names __schema/introspection explicitly:
{"errors":[{"message":"GraphQL introspection is not allowed, but the query contained __schema or __type","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}If you see this, jump to the “introspection is disabled” fallback further down instead of proceeding to Step 2 — a full __schema{types{...}} dump will just return the same error.
Step 2 — full type/field name dump. Cheap, gives you the lay of the land: every type, every query, every mutation.
{"query":"{ __schema { types { name fields { name } } } }"}Skim the Query and Mutation type entries first — that’s your attack surface. Flag anything named dev*, debug*, test*, internal*, _*, or a near-duplicate of a “real” field (forgotPassword / devForgotPassword is a textbook example — see Notes below).
Step 3 — pull full argument detail for Mutation and Query. This is the step people skip — don’t. You need real argument names/types to call anything, and this is where IDOR-shaped arguments jump out.
{"query":"{ __type(name: \"Mutation\") { fields { name args { name type { kind name ofType { kind name ofType { kind name } } } } } } }"}{"query":"{ __type(name: \"Query\") { fields { name args { name type { kind name ofType { kind name ofType { kind name } } } } } } }"}(The triple-nested ofType unwraps GraphQL’s NON_NULL/LIST wrapper kinds down to the actual named scalar/object — add another level if a leaf name still comes back null.)
Step 4 — get mutation/query return types, so you know whether to write a selection set { field } or call the field bare.
{"query":"{ __schema { mutationType { fields { name type { kind name ofType { kind name ofType { kind name } } } } } } } }"}Step 5 — get input object shapes (mass-assignment / broken object-level auth surface). Any INPUT_OBJECT used as a mutation argument (updateProfile(input: ProfileInput!)) deserves a dedicated pull:
{"query":"{ __type(name: \"ProfileInput\") { inputFields { name type { kind name ofType { kind name } } } } }"}Check specifically for fields that shouldn’t be client-controllable on an “update my own profile” style mutation: userId, id, role, isAdmin, twoFactorAuthEnabled, verified, balance. If present, you may be able to edit another user’s record or escalate privileges through a field the frontend never surfaces.
Full recon script — dumps the schema, then automatically pulls args/return types for every Query and Mutation field in one pass:
import requests, json
GQL = "http://target/graphql"
def gql(query):
return requests.post(GQL, json={"query": query}, timeout=15).json()
TYPE_DETAIL = "kind name ofType { kind name ofType { kind name ofType { kind name } } }"
def dump_operation_type(op_type_name):
q = f'{{ __type(name: "{op_type_name}") {{ fields {{ name args {{ name type {{ {TYPE_DETAIL} }} }} type {{ {TYPE_DETAIL} }} }} }} }}'
return gql(q)["data"]["__type"]["fields"]
schema = gql("{ __schema { queryType { name } mutationType { name } } }")["data"]["__schema"]
print("=== QUERY FIELDS ===")
print(json.dumps(dump_operation_type(schema["queryType"]["name"]), indent=2))
print("=== MUTATION FIELDS ===")
print(json.dumps(dump_operation_type(schema["mutationType"]["name"]), indent=2))If introspection is disabled: you’re not dead in the water. Use clairvoyance or graphql-cop to reconstruct the schema field-by-field from error-suggestion leakage (“Did you mean user?”), or brute-force common field/mutation names by hand on a small target. Also try GET with the query in the URL, and alternate paths (/graphiql, /playground, /v1/graphql) — some setups only lock down introspection on the primary route.
Burp Suite users: the InQL extension automates steps 2-5 and generates a clickable schema map plus a ready-made query template per field — much faster than hand-rolling the above against a large schema.
Notes
- Introspection is recon, not the vulnerability — always chain it into something. Real chain, from (incomplete) HTB Challenge - SpeedNet: introspection → spotted a leftover debug mutation
devForgotPasswordnext to the realforgotPassword→ its introspected return type turned out to be a bareStringcontaining the reset token in-band (no email needed at all) →resetPassword→ full account takeover →userProfile(userId: Int!)(spotted straight from the introspected args) confirmed an IDOR and leaked the admin’s email → chained into a GraphQL - Batching Attack againstverifyTwoFactorto brute-force the OTP → admin JWT → flag sitting ininvoiceHistory. - When a field errors with
Cannot query field "X" on type "Y", don’t assume it’s gone — re-check casing (type names are PascalCase, field names camelCase by convention) or that you’re introspecting the right parent type. ofTypenesting depth needed varies with how the field is wrapped (StringvsString!vs[String]!vs[String!]!). If a leafnamestill showsnull, add anotherofType { kind name }level rather than assuming the field is unnamed.- Don’t stop at field names —
descriptionon types/fields/args (__type(name:"X"){ description fields { name description } }) sometimes carries developer comments never meant to ship: leftover TODOs, internal notes, occasionally credentials on dev/staging instances.
Mitigation
- Disable introspection in production (
introspection: falsein Apollo Server, the equivalent flag in most GraphQL frameworks). Doesn’t stop a determined attacker (seeclairvoyance) but removes the free, complete map. - Never ship debug/dev-only fields to production — gate them at schema-composition time behind an environment check, not just by omitting them from frontend docs. If it’s in the schema, it’s reachable.
- Enforce authorization inside every resolver, independent of what arguments the client supplies. A
userIdargument on a query is fine if the resolver still checks the authenticated caller is allowed to see thatuserId’s data — the bug is missing object-level authorization, not the argument existing. - Validate input objects against an explicit allowlist of client-settable fields server-side — don’t blindly spread/merge an entire
inputobject onto a database record.
Related Usage
| File | Created |
|---|
References: