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:
- No lockout on the verify endpoint. The login form locks after five bad passwords; the OTP verify happily takes ten thousand.
- Per-IP limits defeated by a rotating header. If the limiter keys on a client-supplied
X-Forwarded-For, you rotate it and every request looks fresh. - Race conditions. Even where a counter exists, it is often read-then-incremented without a lock, so a burst of parallel guesses all pass the check before any of them writes back.
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:
- Password reset that logs you straight in. Complete a reset and some apps drop you into an authenticated session without asking for the factor at all. That single gap collapses the whole chain.
- OAuth or SSO login. These paths frequently skip the local second factor because the app assumes the identity provider handled step-up. It usually did not, and it usually was not asked to.
- "Remember this device" cookies. These are the richest target. I check whether the token is bound to the user or is just a global "trusted" marker. A cookie like
trusted_device=1or a value that isbase64(user_id)or a short sequential id is not bound to anything. Forge it, attach it to the victim's login, and the factor is skipped by design.
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.
- Backup codes. If they are 6 to 8 digits, non-random, or generated sequentially, they are just a second brute-force surface with a longer lifetime and usually no rate limit at all.
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:
- Rate-limit and lock the verify endpoint server-side on the account and session, never on a client-supplied IP header, and make the counter atomic so a parallel burst cannot slip through.
- Keep authentication state on the server only. The client's copy of "verified" is a display hint, never an authority; issue the fully-privileged session strictly after the factor passes.
- Enforce the factor on every path that mints a session: reset, OAuth, API, legacy login. Test each one, not just the primary form.
- Bind "remember device" tokens to the user with a signed, high-entropy value, and require a fresh factor or password to disable MFA.
- Generate backup codes with real entropy and rate-limit them like any other OTP.
Takeaways
- Assume the password is already solved; the second factor is usually the softer of the two because it was hardened later and less thoroughly.
- A 6-digit OTP with no attempt cap is not protection; do the lifetime-versus-rate math and the race condition often does the rest.
- Never let the client's success flag, status code or redirect decide auth state. Forced-browse past the interstitial to prove the session was privileged all along.
- Enumerate every path that issues a session; MFA that is enforced on login and nowhere else is the most common bypass of all.
- The strongest chain disables MFA through a weak, un-re-authenticated endpoint, especially when it doubles as an IDOR.
- Report the whole chain as one impact narrative that ends with you logged in as the victim; that is what moves it from Medium to Critical.