Bug Hunting Journey

Writeups Auth bypass

Auth bypass · critical

Six digits, one request: brute-forcing an OTP through GraphQL aliases

Severity Critical CVSS 8.1
Impact
Account takeover on any account with a known email
Bounty
$2.5k - $5k
Program
Private

TL;DR

The login flow sent a six-digit code by email and allowed five attempts per minute. The limit was enforced per HTTP request, and the API was GraphQL, so a single request carrying ten thousand aliased mutations counted as one attempt.

Rate limiting is the control people are most confident about and least often test properly. At ██████████ it was real, correctly configured, and completely bypassable.

The flow

Log in with an email, receive a six-digit code, submit it. The UI blocks you after five wrong codes and the API answers 429 on the sixth within a minute. That part works.

The API is GraphQL. And GraphQL lets you ask the same field several times in one document, as long as each one gets its own alias.

POSTThe bypass, shortened to three attemptsrequest
POST /graphql HTTP/2Host: api.target.tldContent-Type: application/json{"query":"mutation { a0: verify(code:\"000000\") { token }                      a1: verify(code:\"000001\") { token }                      a2: verify(code:\"000002\") { token } }"}

One HTTP request. One increment of the counter. Three verification attempts, or ten thousand if you feel like it. A six-digit space is a million codes, so a full sweep takes a hundred requests at ten thousand aliases each, well inside the five-per-minute budget.

200OKresponse
HTTP/2 200 OKcontent-type: application/json{"data":{"a0":null,"a1":null,"a2":{"token":"eyJhbGciOi████"}}}

The response hands back which alias succeeded, so you do not even need to compare timings.

Takeaways

  • A rate limiter that counts HTTP requests does not limit anything on a GraphQL endpoint. The unit of work is the resolver call, not the request.
  • Query depth and alias count are inputs too. Cap them, the same way you cap a request body size.
  • A partial-success response that names the winning alias turns a blind attack into an instant one.

Disclosure timeline

  1. Report sent
  2. Triaged
  3. Fix deployed limit moved to the resolver, alias count capped
  4. Cleared for publication