| 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.
After the first step, the app redirected to /login2 and prompted for a 4-digit security code. Checking the email client:
Entering the code logged me in normally.
Looking at the POST /login request revealed something worth noting:
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
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
"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:
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.
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.
One value returned a 302 instead of the usual failure response.
Loading that 302 in the browser and navigating to "My account" landed on Carlos's account page.
Lab solved