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.
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:
- User submits their email to
/api/auth/request-password-reset - Server sends a 6-digit numeric OTP to the email
- User submits the code to
/api/auth/reset-passwordwith their new password - 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
| Parameter | Value |
|---|---|
| Code space | 000000 to 999999 = 1,000,000 combinations |
| Expiry window | 10 minutes (600 seconds) |
| Response time | ~28ms per attempt (direct connection) |
| Rate limiting | None |
| Account lockout | None |
| Attempt tracking | None |
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.