Skip to content
Field Detail
Platform PortSwigger Web Security Academy
Type Authentication — 2FA Broken Logic
Difficulty Practitioner
Objective Access Carlos's account page
Note Own credentials wiener:peter, victim username carlos; access to email server for own 2FA codes

2FA Broken Logic

I started by logging in as wiener:peter to understand the normal 2FA flow.

Screenshot

After the first step, the app redirected to /login2 and prompted for a 4-digit security code. Checking the email client:

Screenshot

Entering the code logged me in normally.

Screenshot

Looking at the POST /login request revealed something worth noting:

Screenshot
POST /login HTTP/2
Host: 0a4800b0044893eb806a21280038004d.web-security-academy.net
Cookie: session=KqS0TWoI1AMwBeFJ809UYsD2L990XJya; verify=wiener

username=wiener&password=peter

The 302 response set two cookies separately:

HTTP/2 302 Found
Location: /login2
Set-Cookie: verify=wiener; HttpOnly
Set-Cookie: session=n59va3vHks57S5uaXLG1QU6lbtcPWiRe; Secure; HttpOnly; SameSite=None

There's a verify cookie tracking which username the pending 2FA challenge belongs to, completely independent of the session cookie. Then the /login2 POST:

POST /login2 HTTP/2
Host: 0a4800b0044893eb806a21280038004d.web-security-academy.net
Referer: https://0a4800b0044893eb806a21280038004d.web-security-academy.net/login2

mfa-code=1070
Screenshot

That returned a 302 — success. The key observation here is that verify determines whose 2FA code is being checked, entirely independent of which account's password was submitted at /login. That decoupling is the whole flaw.

To confirm it, I tried swapping verify to carlos directly on the POST /login2 step while submitting our own code:

POST /login2 HTTP/2
Host: 0a4800b0044893eb806a21280038004d.web-security-academy.net
Cookie: session=846baVoChaN7fKocM8hbmVEHbZIhwtWy; verify=carlos

mfa-code=1070
Screenshot

"Incorrect security code" — expected. 1070 was the code issued to our inbox, not whatever code is pending for carlos. But GET /login2 (which generates and sends the code) and POST /login2 (which checks it) both honor verify independently. That means if I trigger code generation for carlos via GET /login2 with verify=carlos, the server issues a valid code for his account — and since a 4-digit numeric code is only 10,000 possibilities with no rate limiting in play, I don't need to read Carlos's email. I can just brute-force it.

I modified the GET /login2 request, changing its verify value to carlos:

Screenshot

This generated a fresh 2FA code for Carlos's account server-side. Then I logged out, logged back in as wiener, submitted an invalid 2FA code to land back on /login2, and sent that POST /login2 request to Intruder.

Screenshot

I set verify=carlos as a static cookie value and placed a payload position on mfa-code. Generated the full 4-digit range with:

seq 0000 9999 > numbers

Loaded that into a sniper attack and started it.

Screenshot

One value returned a 302 instead of the usual failure response.

Screenshot

Loading that 302 in the browser and navigating to "My account" landed on Carlos's account page.

Lab solved

Resources