| Field | Detail |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | Authentication — 2FA Brute-Force with CSRF-Bound Session Macro |
| Difficulty | Expert |
| Objective | Brute-force the 2FA code and access Carlos's account page |
| Note | Victim credentials carlos:montoya. The verification code resets periodically during the attack, so it may need repeating if the new code happens to be one already attempted. |
2FA Bypass Using a Brute-Force Attack¶
I logged in as carlos:montoya on /login and got prompted for a 4-digit security code.
Trying a couple of random guesses like 9999 or 6767 bounced me back to /login after two attempts.
Tracking the full flow in Burp revealed two separate CSRF-bound stages:
POST /login HTTP/2
Host: 0a2500db048a4055807e5db7001000cf.web-security-academy.net
csrf=Zg2vIVIJ2fzscMK2JihzkuN0hmE0q5c7&username=carlos&password=montoya
That leads to a GET /login2, then:
POST /login2 HTTP/2
csrf=i2Y1289dGVC8l5H78MfxOhsglWagwAK2&mfa-code=miku
Reusing the same csrf value on a second attempt returned "Invalid CSRF token (session does not contain a CSRF token)."
The csrf token rotates with every GET /login, and a second, separate csrf gets issued when /login2 is reached. Two separate, rotating CSRF tokens make naive brute-forcing of mfa-code impossible without re-deriving fresh tokens for every attempt. The full sequence needed per attempt is:
GET /login— get a freshcsrfPOST /login— submit credentials with thatcsrfGET /login2— get the second-stagecsrfPOST /login2— submit the brute-forcedmfa-codewith the secondcsrf
Step 4 is the only one Intruder needs to vary; steps 1-3 need to run automatically before every single attempt. Burp's session handling rules with a recorded macro handle exactly this.
In Settings -> Sessions -> Session Handling Rules, I added a rule named teto scoped to all URLs, then recorded a macro covering the three setup requests: GET /login, POST /login, GET /login2.
With the rule in place, I sent the POST /login2 request to Intruder. The macro replays steps 1-3 fresh before each request fires, so every mfa-code guess arrives with a valid, matching csrf. Payload set to numbers 0 to 9999, padded to 4 digits:
Resource pool set to 1 concurrent request to keep the macro-driven session state consistent between requests:
One request returned 302. I used "Show response in browser / request in browser, original session" to get a URL representing that successful state:
Pasting the link in the browser failed with an invalid session error.
The 2FA code resets periodically — by the time the link was loaded, the session/code state had already rotated. I reran the attack, got another 302, and this time copied the session cookie value directly from the successful response and set it manually in the browser instead of using the "request in browser" helper.
Lab solved :P
Dead Ends & Rabbit Holes¶
"Show response in browser → original session" produced a link that failed with an invalid session error on first use — the 2FA code and session state had already rotated by the time it was loaded. Taking the session cookie value straight from the successful 302 response and setting it manually in the browser worked reliably instead.