GraphQL API Exploitation in a Lab
GraphQL API exploitation walked through end to end in a lab: find the endpoint, run introspection, map the schema in InQL, then abuse access control, injection and batching before locking it all back down.
GraphQL API exploitation starts from one fact that reshapes how you test: the whole API usually lives behind a single URL, most often /graphql, and that one endpoint speaks a typed query language the client gets to write. There are no dozens of REST routes to enumerate. Instead the server hands you a schema, and the schema is the map. This walkthrough runs the full loop against a lab GraphQL API you own, from finding the endpoint to pulling data you should not see, and finishes on the defender side with the controls that shut each door.
Work through it once in order to build the model, then keep it open as a checklist. Every request here targets a local copy of Damn Vulnerable GraphQL Application, the kind of deliberately broken app you are meant to break.
Run everything here only against an app you own or are authorized to assess, such as a local Damn Vulnerable GraphQL Application instance or the lab at lab.0xpriv.dev/graphql. Sending these queries at a live API without written permission is a crime.
01What GraphQL is and why one endpoint changes testing
GraphQL is a query language for APIs where the client asks for exactly the fields it wants and the server resolves them from whatever data sources sit behind it. A read is a query, a write is a mutation, and a live stream is a subscription. All three travel as JSON to the same endpoint, almost always by HTTP POST with a body like {"query":"..."}. One URL, one content type, many operations.
That design moves the attack surface around. With REST you map routes and guess at hidden ones. With GraphQL the schema tells you every type, field, argument and mutation up front, so enumeration is replaced by reading. Authorization also moves: instead of guarding each route, the server has to guard each field inside its resolvers, and a single missed check on a nested field leaks data. The client picking its own fields means an endpoint that returns a safe object in the UI can return far more when you ask directly.
02Fingerprinting and finding the endpoint
Finding the endpoint comes first, because you cannot test a GraphQL API until you know where it answers and which engine serves it. The path is usually obvious once you look: /graphql, /api/graphql, /v1/graphql, /query, or an interactive console at /graphiql or /playground. A quick probe is to post the smallest valid query and see if you get a typed response back.
# 0xpriv.dev/labs # probe a candidate path with the smallest valid query curl -s https://lab.0xpriv.dev/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"{__typename}"}'
$ curl -s https://lab.0xpriv.dev/graphql -H 'Content-Type: application/json' -d '{"query":"{__typename}"}' {"data":{"__typename":"Query"}}
Once you have a live endpoint, fingerprint the engine, because the server implementation decides which defaults you face. graphw00f sends a set of discriminating queries and matches the error strings and quirks to a specific engine such as Apollo, graphql-php, Hasura, Graphene or Ariadne. The engine tells you whether introspection is likely on, whether batching is supported, and which injection sinks are plausible behind the resolvers.
# detect the endpoint, then fingerprint the engine python3 graphw00f.py -d -f -t https://lab.0xpriv.dev
$ python3 graphw00f.py -d -f -t https://lab.0xpriv.dev [*] Checking https://lab.0xpriv.dev/graphql [!] Found GraphQL at https://lab.0xpriv.dev/graphql [*] Attempting to fingerprint... [!] Discovered Engine: (Graphene)
03Introspection to dump the full schema
Introspection is the built-in feature that lets any client ask a GraphQL server to describe its own schema, and it is the single richest source of recon you get. Every spec-compliant server answers special meta-fields, __schema and __type, that return every type, field, argument and both the query and mutation entry points. One request hands you the entire API contract.
# minimal introspection: list the queries and mutations on offer
{
__schema {
queryType { name }
mutationType { name }
types { name kind fields { name } }
}
}$ curl -s https://lab.0xpriv.dev/graphql -H 'Content-Type: application/json' \ -d '{"query":"{__schema{queryType{name} mutationType{name}}}"}' {"data":{"__schema":{"queryType":{"name":"Query"},"mutationType":{"name":"Mutation"}}}}
The full introspection query is long and clients send it automatically, so you rarely type it by hand. If introspection is turned off, you are not stuck. Servers often still return field suggestions in error messages ("Did you mean ... ?"), and a tool like Clairvoyance replays those suggestions to rebuild the schema one guess at a time. On many engines the __typename meta-field also stays enabled even when the rest of introspection is blocked, which is enough to confirm GraphQL and keep probing.
04Visualizing and exploring the schema
Exploring the schema is far easier with tools than by reading raw introspection JSON, and two are worth keeping close. InQL, the Doyensec Burp Suite extension, parses introspection for you, generates ready-to-edit query and mutation templates for every operation, and sends them straight into Repeater so you can start poking arguments in seconds. GraphQL Voyager takes the same introspection result and draws the schema as an interactive graph, so the relationships between types jump out and sensitive objects like User or Paste are easy to spot.
Load introspection into InQL
Point InQL at the endpoint or paste a saved introspection result. It builds a tree of every query and mutation with their arguments filled in.
Send templates to Repeater
Right-click an operation and push it to Burp Repeater, then swap in your own argument values and resend.
Map relationships in Voyager
Open the schema in GraphQL Voyager to see which types reference which, and trace a path from a public type to a private one.
The interesting bugs hide in arguments: an id you can change, a role a mutation accepts, a filter string that reaches a database. The field names tell you what exists; the arguments tell you what you can push on.
05Access-control and IDOR flaws through queries
Access-control flaws appear when a resolver returns data the current user should never reach, and GraphQL makes them common because the check has to live in every resolver. A classic insecure direct object reference is a query that takes an id argument and returns the matching record with no ownership check. Change the number and you read someone else's object.
# ask for another account by id; a missing resolver check leaks it query { user(id: 3) { username email apiKey } }
$ curl -s https://lab.0xpriv.dev/graphql -H 'Content-Type: application/json' \ -d '{"query":"{user(id:3){username email apiKey}}"}' {"data":{"user":{"username":"admin","email":"[email protected]","apiKey":"a1b2c3d4"}}}
The same gap shows up on mutations. A mutation that edits a profile but trusts a client-supplied userId lets you change records that are not yours, and a field that returns a sensitive object inside a larger query can leak even when the top-level operation looks harmless. Walk the schema for every argument that names an identity or a scope, then test whether the resolver behind it checks who you are.
06Injection reaching the resolvers
Injection in GraphQL happens below the query language, when an argument you control flows into a database query or a shell command inside a resolver. GraphQL is strongly typed, but that typing is not a security boundary: a String argument is still an arbitrary string, and if the resolver pastes it into SQL, a NoSQL filter, or an OS command, the usual injection classes apply. The query language just carries the payload to the sink.
# a search argument that reaches a string-built SQL query in the resolver query { pastes(filter: "x' OR 1=1-- -") { id content } }
NoSQL backends have their own version. When an argument is passed through as an object into a MongoDB query, operator injection such as {"username":{"$ne":null}} can bypass a filter or a login check. Command injection shows up where a resolver shells out, for example an argument fed into a ping or an import routine. The fix lives in the resolver, not the schema, which is why parameterized queries and strict input validation matter here exactly as they do in any other backend.
A field declared as String only guarantees the client sent a string. It says nothing about quotes, operators or shell metacharacters reaching whatever the resolver calls. Treat every argument as untrusted input at the sink.
07Alias and batching abuse for brute force and denial of service
Aliases and query batching let a single HTTP request carry many operations, and that turns into both brute force and denial of service. An alias renames a field so you can request it more than once in one query. Point the same mutation at different inputs and you fire dozens of attempts inside a single request, which slides past rate limiting that counts requests instead of operations.
# aliases pack many attempts into one request; brute-force signature mutation { a1: login(username: "admin", password: "spring2024") { token } a2: login(username: "admin", password: "summer2024") { token } a3: login(username: "admin", password: "autumn2024") { token } }
Query batching does the same thing at the transport level: some servers accept a JSON array of operations, [{"query":"..."},{"query":"..."}], and run them all. On top of that, GraphQL schemas with circular relationships allow deeply nested queries where each level multiplies the work, so a modest-looking query can ask the server to resolve an enormous result. In a lab you confirm the behaviour with a small nested query and stop there. The point is to show the server has no depth or cost ceiling, never to knock anything over.
Depth, alias and batching amplification are easy to demonstrate and easy to overdo. Prove the missing limit with one small query on a lab you control. Do not run amplification at anything shared or production, ever.
08A worked walkthrough against the lab app
The walkthrough below strings the steps together against the lab GraphQL API at lab.0xpriv.dev/graphql, a copy of Damn Vulnerable GraphQL Application running on a box you own. Each step feeds the next: recon narrows to a schema, the schema points at arguments, and the arguments lead to the bugs.
Confirm and fingerprint
Probe /graphql with {__typename}, then run graphw00f to name the engine and its likely defaults.
Dump the schema
Run introspection, or fall back to field suggestions, and load the result into InQL and Voyager.
Hunt access control
From the type graph, find queries that take an id and test whether changing it reaches records that are not yours.
Probe the resolvers
Feed quotes and operators into string and object arguments and watch for SQL or NoSQL errors that show a reachable sink.
Show the missing limits
Send a small aliased mutation and one nested query to prove there is no rate, depth or cost ceiling, then write it up.
$ curl -s https://lab.0xpriv.dev/graphql -d '{"query":"{__typename}"}' -H 'Content-Type: application/json' {"data":{"__typename":"Query"}} $ python3 graphw00f.py -d -f -t https://lab.0xpriv.dev [!] Discovered Engine: (Graphene) $ curl -s https://lab.0xpriv.dev/graphql -d '{"query":"{user(id:3){email apiKey}}"}' -H 'Content-Type: application/json' {"data":{"user":{"email":"[email protected]","apiKey":"a1b2c3d4"}}}
Written up, that chain is a single report with clear impact: introspection exposed the schema, a resolver leaked another account through an unchecked id, and the server enforced no limits on aliases or depth. Each of those maps straight to a fix in the next section.
09Detecting GraphQL abuse in logs
Detection on the defender side keys on the shapes these attacks leave in logs and metrics, because the request language itself is a strong signal. Introspection stands out: any query containing __schema or a long block of __type meta-fields is recon, and legitimate clients rarely send it in production. Brute force and amplification show up as single requests carrying many aliases of the same field, or JSON arrays of batched operations, or queries whose nesting depth is far past anything the UI would generate.
Log the operationName, the measured query depth and an estimated cost for every request, then alert on the outliers rather than trying to match payload strings. A sudden run of login mutations under aliases, or a spike in response time tied to one deep query, is easy to catch once those fields are in your logs.
10Shutting it down
Defense comes down to a short list of controls, each closing one of the doors above, and most ship as a few lines of server configuration. The combination matters more than any single item: turn off what attackers read, cap what they can ask for, and check authority where the data actually leaves.
11FAQ
Is GraphQL less secure than REST?FAQ
No, it just moves the risk. The single endpoint and self-describing schema remove route guessing, but they push authorization into every resolver and make aliases, batching and deep nesting easy to abuse. A GraphQL API with per-resolver checks and query limits is as safe as a well-built REST API.
If introspection is disabled, is the API safe from mapping?FAQ
Turning off introspection raises the cost but does not stop mapping. Servers often leak field suggestions in error messages, and tools like Clairvoyance rebuild much of the schema from those hints. Treat disabled introspection as one layer, not a fix for the bugs underneath.
Does GraphQL's type system prevent injection?FAQ
No. Typing guarantees the shape of what the client sent, not that it is safe. A string argument can still carry SQL, NoSQL operators or shell metacharacters, and if the resolver pastes it into a query or command the injection lands. Parameterize the sink and validate input in the resolver.
Why are aliases a problem for rate limiting?FAQ
An alias lets one query include the same field many times, so a single HTTP request can hold dozens of login attempts or OTP guesses. Rate limiting that counts requests sees one request and lets it through. Count operations and aliased fields instead.
Which tools should I start with on a GraphQL target?FAQ
graphw00f to find the endpoint and fingerprint the engine, InQL inside Burp Suite to parse introspection and generate query templates, and GraphQL Voyager to see the schema as a graph. Those three cover recon and give you a Repeater workflow for the rest.
references
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.