Any Account Taken Over in 7 Minutes — OTP Brute Force on a Web3 Game

A critical vulnerability discovered during a Solana play-to-earn game audit: 6-digit OTP with zero rate limiting, allowing full account takeover in under 7 minutes.

2026-03-26otpbrute-forceweb3account-takeoversolana

During a recent security audit of a Solana-based play-to-earn game, I discovered a critical vulnerability that would allow an attacker to take over any user account in under 7 minutes with zero victim interaction — no phishing, no social engineering, no clicks required.

The root cause? A 6-digit OTP with absolutely no rate limiting.


The Context

During an authorized web security audit, the application's authentication was reviewed. Session tokens were cryptographically strong — 256-bit random hex, not predictable or forgeable. No issues there.

The problem was in the password reset flow.

The Discovery

The password reset worked like this:

  1. User submits their email to /api/auth/request-password-reset
  2. Server sends a 6-digit numeric OTP to the email
  3. User submits the code to /api/auth/reset-password with their new password
  4. Code expires after 10 minutes

Simple, standard, and — as the testing was about to reveal — fatally flawed.

During routine testing, I sent a few wrong OTP codes to the verification endpoint. No lockout. No delay. No increasing wait times. Every response came back in ~30ms with a clean 400: invalid_or_expired_code.

20 more attempts. Same thing. Zero friction.

That's when the math became interesting.

The Math

ParameterValue
Code space000000 to 999999 = 1,000,000 combinations
Expiry window10 minutes (600 seconds)
Response time~28ms per attempt (direct connection)
Rate limitingNone
Account lockoutNone
Attempt trackingNone

With 80 concurrent connections, the sustained rate reached 623 requests per second. That means roughly 374,000 attempts per 10-minute window — a 37% probability of finding the code in a single window.

With random sampling (shuffled codes instead of sequential), each attempt has an independent probability of hitting the right code.

The Proof of Concept

The PoC script used three key design choices:

1. Random sampling over sequential scanning:

// Pre-shuffle all 1M codes — Fisher-Yates
const codes = new Uint32Array(1000000);
for (let i = 0; i < 1000000; i++) codes[i] = i;
for (let i = codes.length - 1; i > 0; i--) {
  const j = Math.floor(Math.random() * (i + 1));
  [codes[i], codes[j]] = [codes[j], codes[i]];
}

Sequential scanning (000000, 000001, 000002...) wastes coverage if the code happens to be at 928749. Random sampling gives uniform probability across the entire code space.

2. High concurrency with connection pooling:

const CONCURRENCY = 80;
 
while (idx < codes.length && !found) {
  const batch = [];
  for (let i = 0; i < CONCURRENCY && idx < codes.length; i++, idx++) {
    batch.push(tryCode(codes[idx]));
  }
  await Promise.all(batch);
}

3. Time-boxed execution — stop before the 10-minute expiry:

const MAX_TIME_MS = 9.5 * 60 * 1000; // 9.5 min, 30s buffer
if (Date.now() - startTime > MAX_TIME_MS) break;

The Result

On the authorized test account, the PoC confirmed the vulnerability:

============================================================
OTP BRUTE FORCE SUCCESSFUL
Code: 928749
Attempts: 261,120
Time: 418.8s
Rate: 623 /sec
429 errors: 0
Password changed successfully
============================================================

Test account password changed in 6 minutes and 58 seconds.

Zero rate limiting. Zero lockout. Zero detection.

CDN Does Not Protect Against This

The vulnerability was confirmed on the staging environment with direct access. But the production endpoint behind Cloudflare was also tested:

80 OTP verification attempts via rotating residential proxies
  80/80 passed (zero Cloudflare blocking)
  All returned 400: invalid_or_expired_code
  Application-level rate limiting: ABSENT

Cloudflare doesn't rate-limit individual API endpoints by default — it handles DDoS at the network layer, not business logic at the application layer. The application must enforce its own OTP rate limits. Relying on a CDN for this protection is a common and dangerous misconception.

With a modest proxy pool (~500 IPs), an attacker could sustain ~300 req/sec against production, covering 180,000 codes per window — an 18% success probability per attempt. Run it a few times and success becomes near certain.

The Fix

The remediation is straightforward:

// Pseudocode — what the endpoint SHOULD do
async function resetPassword(req) {
  const { email, code, newPassword } = req.body;
 
  // 1. Check attempt count for this code
  const attempts = await getAttemptCount(email);
  if (attempts >= 5) {
    await invalidateCode(email); // Kill the code entirely
    return { error: 'too_many_attempts' };
  }
 
  // 2. Increment attempt counter BEFORE checking
  await incrementAttempts(email);
 
  // 3. Verify
  const valid = await verifyCode(email, code);
  if (!valid) return { error: 'invalid_code' };
 
  // 4. Reset password
  await updatePassword(email, newPassword);
  await invalidateCode(email);
}

Key principles:

  • 5 attempts max per code — then the code is invalidated and must be re-requested
  • Increment counter BEFORE verification — prevents TOCTOU race conditions
  • Account lockout after 10 failed resets — 30-minute cooldown
  • Longer codes — 8+ digits or alphanumeric tokens exponentially increase the search space
  • CAPTCHA after 3 failures — adds friction for automated attacks

A 6-digit code with 5 attempts gives an attacker a 0.0005% chance per code request. That's the difference between "7 minutes" and "statistically impossible."

The Broader Lesson

OTP brute force is one of the most underestimated vulnerabilities in web applications. Developers implement OTPs correctly — random generation, short expiry, secure delivery — but forget the most important control: limiting how many times someone can guess.

What made this finding particularly notable is that the application had excellent security in many other areas:

  • Parameterized SQL queries (zero injection)
  • Strict input validation on file uploads (no SSRF, no RCE)
  • Cryptographically strong session tokens
  • Proper HTML escaping on all frontend rendering

But one missing rate limit on one endpoint was enough to compromise any account on the platform.

Check your OTP endpoints. Count the attempts. Lock after failures. It takes 10 lines of code to prevent a 7-minute account takeover.


Finding discovered during an authorized security audit. The vulnerability was reported to the development team with a detailed remediation plan. Testing was performed exclusively on dedicated test accounts — no real user accounts were affected.