0
Hands-on Labs INTERMEDIATE 13 min read Updated

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.

founder & security researcher · 39 writeups
GraphQL API Exploitation in a Lab

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.

Scope

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.

REST surface versus one GraphQL endpoint
REST /users /orders /products GraphQL /graphql schema queries + mutations per-field resolvers

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.

bash
# 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}"}'
a live endpoint answers with its root type
$ 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.

bash
# detect the endpoint, then fingerprint the engine
python3 graphw00f.py -d -f -t https://lab.0xpriv.dev
graphw00f names the backend engine
$ 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.

graphql
# minimal introspection: list the queries and mutations on offer
{
  __schema {
    queryType { name }
    mutationType { name }
    types { name kind fields { name } }
  }
}
introspection returns the operation entry points
$ 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.

Read the arguments, not just the fields

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.

graphql
# ask for another account by id; a missing resolver check leaks it
query {
  user(id: 3) {
    username
    email
    apiKey
  }
}
an unprotected resolver returns another user's record
$ 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.

graphql
# 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.

Typing is not validation

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.

graphql
# 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.

No DoS against shared targets

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.

the recon-to-finding sequence in one view
$ 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.

How to shut it down
Disable introspection in production. Keep it for staging and local work, but switch it off on the live server so the schema is not handed to every client. Apollo already disables it in production by default.
Set a query depth limit. Reject queries nested past a sane fixed depth with a library such as graphql-depth-limit, which kills the circular-query amplification outright.
Add query cost and complexity analysis. Score each query before running it and refuse anything above a budget, so a cheap-looking request cannot ask for expensive work.
Disable batching or cap it. Turn off array batching if you do not need it, and if you do, limit how many operations one request may carry.
Enforce authorization per resolver. Check identity and ownership inside each resolver, including nested fields, rather than trusting a single gate at the endpoint.
Validate input and parameterize sinks. Treat every argument as untrusted and use prepared statements or safe query builders wherever an argument reaches a database or a shell.
Rate limit by operation, not by request. Count individual operations and aliased fields so one request full of attempts cannot slip under a per-request limit.

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

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Labs →

discussion

0 comments
`code` supported · comments are reviewed before they appear