How to pwn a website and take over any account
How a broken password-reset flow exposed recovery secrets to unauthenticated users and turned account recovery into a complete account-takeover primitive.
Summary
During an authorized security assessment of Vendor B, I identified several weaknesses across the application’s authentication and API surface.
The most severe issue was a critical flaw in the password-recovery process.
The application generated a password-reset token and then returned that token directly to the unauthenticated client that requested the reset.
That meant the same party asking:
“Can I reset this account?”
was also given the secret that was supposed to prove they were allowed to do so.
The token could then be supplied to the password-reset endpoint together with a new password.
The result was a complete account-takeover chain:
Unauthenticated attacker
│
│ requests password reset
▼
Vendor B
│
│ generates reset token
▼
Reset token returned in API response
│
▼
Attacker submits token + new password
│
▼
Password changed
│
▼
Login as target account
No access to the target user’s mailbox was required in the tested flow.
No previously known password was required.
No out-of-band ownership check was required before the new password was accepted.
The same assessment also identified excessive exposure of sensitive user properties, authentication-related data stored in browser storage, a weak CORS configuration, client-side injection sinks, and unnecessary exposure of administrative UI structure.
However, none of those findings were required for the primary account takeover.
The password-reset mechanism was enough on its own.
No unrelated user account was accessed during testing.
Vulnerability Overview
A secure password-reset flow is supposed to create a temporary proof of account ownership.
Conceptually:
User
│
│ requests password reset
▼
Server
│
│ generates secret token
▼
Trusted side channel
│
├── verified e-mail
├── verified phone
└── another previously trusted channel
│
▼
User proves possession of token
│
▼
Password may be changed
The security of the process depends on one important property:
The reset secret must only become available to someone who already controls a trusted recovery channel for that account.
Vendor B’s implementation broke that property.
The flow effectively became:
Requester
│
│ requests reset
▼
Server
│
│ generates secret
▼
Same requester receives secret
│
▼
Requester changes password
The recovery credential was therefore no longer evidence of account ownership.
It was simply a value handed to anyone who initiated the recovery process.
Discovery
The assessment initially focused on the publicly exposed authentication and administration surface.
The application exposed routes for:
authentication
password recovery
user information
team / administration functionality
session handling
The administration interface itself was interesting from a reconnaissance perspective, but tests against protected administrative APIs returned authorization failures when the session lacked the required permissions.
That was a positive sign.
The backend was not relying entirely on hidden frontend buttons for authorization.
The much more important finding appeared in the ordinary password-reset flow.
A request to the reset-request functionality returned a successful response containing the reset credential itself.
A simplified representation looked like this:
POST /api/auth/reset-request
Content-Type: application/json
{
"username": "CONTROLLED_TEST_ACCOUNT"
}
Instead of only returning a generic acknowledgement, the response exposed the newly created recovery secret.
Conceptually:
{
"success": true,
"resetToken": "RESET_SECRET"
}
The exact production response structure has been simplified for this write-up.
At this point the first trust boundary had already failed.
The next question was whether the exposed value was actually usable.
Testing the Reset Token
The application also exposed functionality for completing the password reset.
Conceptually:
POST /api/auth/reset-password
Content-Type: application/json
{
"token": "RESET_SECRET",
"newPassword": "NEW_CONTROLLED_PASSWORD"
}
Using the token returned by the reset-request flow, the server accepted the new password and completed the reset.
The controlled account could then authenticate using the replacement password.
The complete validated sequence was:
Step 1
Request reset for controlled account
↓
Step 2
Server returns reset token to requester
↓
Step 3
Submit reset token with new password
↓
Step 4
Server accepts password change
↓
Step 5
Authenticate with new password
No additional ownership proof was required between Step 1 and Step 4.
That converted the password-reset endpoint from a recovery feature into an authentication bypass.
Why This Was a Full Account Takeover
A password is normally one of the primary credentials establishing control over an account.
A password-reset mechanism intentionally provides an alternate way to replace that credential.
That makes recovery functionality security-critical.
If an attacker can independently obtain the reset secret, the effective security model becomes:
Normal login:
username + password
│
▼
authenticated account
Broken recovery:
username + publicly returned reset token
│
▼
choose new password
│
▼
authenticated account
The attacker does not need to defeat the original password.
They simply replace it.
The confirmed impact was therefore complete takeover of accounts addressable through the vulnerable recovery mechanism.
The potential impact of compromising a privileged account would depend on that account’s server-side role and permissions.
This distinction matters:
Authentication
"Who are you?"
Authorization
"What are you allowed to do?"
The vulnerability broke the authentication boundary.
It did not automatically disable every downstream authorization check.
Proof-of-Concept Model
The public write-up intentionally avoids production identifiers and real account information.
A safe reproduction model using an authorized test account is:
1. Request a reset
curl -i -s -X POST \
"https://vendor-b.example/api/auth/reset-request" \
-H "Content-Type: application/json" \
--data '{"username":"CONTROLLED_TEST_ACCOUNT"}'
Vulnerable behavior:
HTTP 200
Response includes a reset credential
that should have been delivered only
through a trusted recovery channel.
2. Use the returned credential
curl -i -s -X POST \
"https://vendor-b.example/api/auth/reset-password" \
-H "Content-Type: application/json" \
--data '{
"token":"REDACTED_RESET_TOKEN",
"newPassword":"CONTROLLED_TEST_PASSWORD"
}'
Vulnerable behavior:
HTTP 200
Password successfully changed.
3. Authenticate
The controlled account can then authenticate using the new password.
This validates the complete security impact without requiring access to any unrelated user.
The Broken Trust Boundary
The core issue can be reduced to a single question:
Who is allowed to receive the password-reset credential?
A recovery token represents temporary authority to replace a user’s authentication secret.
Conceptually, possession of the token means:
"I am authorized to choose a new password for this account."
It therefore needs to be protected similarly to other bearer credentials such as:
session tokens
magic login links
API keys
access tokens
MFA recovery codes
Vendor B instead allowed this value to cross the wrong trust boundary:
Account-specific recovery secret
│
▼
Unauthenticated API requester
Once that happened, the token stopped proving ownership of anything.
Sensitive User Data Exposure
While investigating the authentication flow, I also observed that a user-related API returned substantially more information than the frontend required.
The returned user representation contained sensitive properties including authentication and recovery-related data.
Examples observed in the assessment included fields representing:
password hash
reset token
reset-token expiry
A client generally has no legitimate reason to receive these values.
A safer response model would look like:
{
"id": "USER_ID",
"username": "example",
"role": "user",
"displayName": "Example"
}
rather than returning an internal database or ORM object directly.
The important design rule is simple:
API responses should be constructed from an explicit allow-list of fields intended for the client.
Internal authentication properties should remain server-side.
Why Password Hash Exposure Still Matters
A password hash is not equivalent to a plaintext password.
But that does not make it safe to expose.
Once a hash is disclosed, an attacker can perform password guessing offline:
Leaked password hash
│
▼
Offline password guessing
│
├── dictionaries
├── reused passwords
├── weak passwords
└── brute force
The attacker no longer needs to send guesses to Vendor B.
Application rate limits and login monitoring no longer protect the guessing process.
The reset-token fields were even more concerning because they related directly to account recovery.
Neither class of value belonged in a normal client response.
Browser Storage and Remember-Me State
The frontend also used persistent browser storage for authentication-related state.
The assessment identified a remember-me mechanism using localStorage.
This is relevant because JavaScript executing in the application’s origin can read values stored there.
The risk relationship is straightforward:
XSS
+
Bearer credential in localStorage
=
Credential theft
This did not create the primary account takeover described in this article.
It was a separate hardening issue.
Long-lived authentication credentials are generally better isolated from browser JavaScript when possible, for example through appropriately configured cookies using protections such as:
HttpOnly
Secure
SameSite
CORS Configuration
Responses also showed a CORS configuration equivalent to:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
This combination does not by itself provide arbitrary credentialed cross-origin reads in modern browsers.
Browsers reject wildcard origins for credentialed CORS requests.
For that reason, I did not treat this header combination as a separate account-takeover exploit.
It was still a security configuration problem.
Authenticated applications should define their intended trust relationships explicitly rather than relying on overly broad CORS configuration.
And importantly:
CORS is not an authentication mechanism and it is not a replacement for CSRF protection.
Potential Client-Side Injection Sinks
The frontend contained locations where dynamic content was inserted using innerHTML.
innerHTML is an injection sink.
It does not automatically mean an exploitable XSS vulnerability exists.
Exploitability depends on whether attacker-controlled data can reach the sink without correct encoding or sanitization.
The distinction is:
innerHTML
│
├── trusted constant
│ └── no demonstrated issue
│
└── attacker-controlled input
└── potential DOM XSS
For plain text, safer DOM APIs such as textContent reduce this attack surface.
Because a full attacker-controlled source-to-sink chain was not established during the assessment, this remained a potential finding rather than a confirmed XSS vulnerability.
The Admin Panel Was Not the Main Bug
The application exposed its team / administration interface as public HTML.
That revealed information about:
administrative functionality
roles
permissions
API structure
moderation features
From a reconnaissance perspective, that is useful information.
However, the tested administrative API functions still performed server-side permission checks.
Requests without the required permissions returned authorization errors.
So:
Admin HTML is visible
≠
Attacker has admin permissions
This is an important distinction.
Client-side hiding is never sufficient authorization, but in this case the backend did enforce permissions on the tested routes.
The real authentication failure was much simpler.
It was sitting in the password-reset process.
Attack Chain
The complete confirmed chain can be represented as:
┌────────────────────────────┐
│ Unauthenticated requester │
└─────────────┬──────────────┘
│
│ requests password reset
▼
┌────────────────────────────┐
│ Vendor B reset endpoint │
└─────────────┬──────────────┘
│
│ generates recovery token
▼
┌────────────────────────────┐
│ Token returned directly │
│ in API response │
└─────────────┬──────────────┘
│
│ submit token + chosen password
▼
┌────────────────────────────┐
│ Password reset endpoint │
└─────────────┬──────────────┘
│
│ accepts password change
▼
┌────────────────────────────┐
│ Target account password │
│ replaced │
└─────────────┬──────────────┘
│
│ authenticate
▼
┌────────────────────────────┐
│ Account takeover │
└────────────────────────────┘
The chain required no sophisticated exploitation.
There was:
no memory corruption
no race condition
no browser sandbox escape
no dependency zero-day
no cryptographic attack
The application simply delivered the recovery credential to the wrong party.
Root Cause
The root cause was a broken password-recovery design.
1. Recovery Secret Returned In-Band
The reset-request endpoint exposed the same secret later accepted as authorization to replace the password.
The requester therefore received the credential that was supposed to verify account ownership.
2. No Out-of-Band Ownership Verification
The tested flow did not require control of an independent trusted channel before the password could be changed.
A secure design would require access to something already associated with the account, for example:
verified e-mail
verified phone
pre-established recovery channel
3. Sensitive Internal Properties Reached the Client
Separate API responses also exposed authentication-related properties that should have remained server-side.
This suggested that response minimization and secret handling needed broader review beyond the reset endpoint itself.
Security Boundary
The intended architecture should have been:
┌─────────────────────┐
│ Unauthenticated user│
└──────────┬──────────┘
│
reset request
│
▼
┌─────────────────────┐
│ Vendor B │
│ recovery service │
└──────────┬──────────┘
│
secret recovery token
│
▼
╔══════════════════════════╗
║ TRUSTED RECOVERY CHANNEL ║
║ verified account owner ║
╚════════════╤═════════════╝
│
▼
┌─────────────────────┐
│ Password may change │
└─────────────────────┘
Instead, the implementation effectively bypassed the trusted recovery channel:
Unauthenticated requester
│
▼
Reset API
│
├── generates secret
│
└── returns secret
│
▼
Same requester
│
▼
Password changed
That collapsed the entire recovery trust model.
Remediation
Several changes were recommended.
1. Never Return Reset Tokens to the Requester
The reset-request endpoint should never return the recovery credential.
The public response should be generic, for example:
{
"message": "If the account exists, recovery instructions have been sent."
}
The response should ideally remain similar regardless of whether the supplied username or e-mail exists.
This also helps reduce account enumeration.
2. Deliver Recovery Through a Trusted Side Channel
The reset secret should be sent only through a previously verified account channel.
Examples include:
verified e-mail
verified phone
another established recovery mechanism
The security property should be:
Knowing an account identifier is not sufficient to obtain the recovery secret.
3. Use Strong, Short-Lived, Single-Use Tokens
Recovery tokens should be:
cryptographically random
high entropy
short lived
single use
bound to the intended account
invalidated immediately after use
A reset token is a credential and should be treated accordingly.
4. Store Recovery Secrets Safely
Where practical, the server should avoid storing raw reset bearer tokens.
A cryptographic representation can reduce the impact of database disclosure.
5. Revoke Existing Sessions After Password Recovery
A successful password reset should normally invalidate existing authenticated sessions for the account.
Otherwise, a previously compromised session may remain active after the user believes control has been recovered.
6. Notify the Account Owner
A successful password change should trigger an out-of-band security notification.
For example:
Your account password was changed.
If you did not perform this action,
contact support immediately.
7. Add Rate Limiting and Abuse Detection
Recovery endpoints should be protected against automated abuse.
Useful controls may include rate limiting by:
source
account
session
time window
Reset spikes and unusual recovery patterns should be visible to security monitoring.
8. Minimize API Response Objects
User API responses should be created from explicit schemas.
Fields such as:
password hashes
reset tokens
reset expiry values
internal authentication metadata
should never be returned to ordinary clients.
9. Harden Authentication Storage
Long-lived authentication state should not be exposed unnecessarily to JavaScript.
Session and remember-me mechanisms should be reviewed with protections such as:
HttpOnly
Secure
appropriate SameSite behavior
rotation
server-side revocation
Responsible Testing
Testing was performed as part of an authorized assessment.
The validation was intentionally controlled.
The account-takeover chain was demonstrated using authorized test data.
The public write-up removes or replaces identifying information including:
company name
production domain
real usernames
user IDs
reset tokens
session identifiers
internal account data
The target is referred to only as Vendor B.
The public examples use:
vendor-b.example
CONTROLLED_TEST_ACCOUNT
REDACTED_RESET_TOKEN
rather than production values.
The purpose of the reproduction was to establish the vulnerability and its impact without accessing unrelated user accounts.
Takeaway
The most interesting part of this vulnerability was not technical complexity.
It was how little complexity was required.
Vendor B had an ordinary web stack with:
sessions
authentication endpoints
password recovery
user APIs
administration functionality
The critical failure was a single trust-boundary mistake.
A password-reset token is useful only if possession of it proves something the attacker could not already do.
If the application gives that secret directly to the unauthenticated requester, the recovery process proves nothing.
The security question therefore is not simply:
Is the login endpoint secure?
A better question is:
Can any alternate authentication or recovery path establish control of the account without proving ownership?
In this case, the answer was yes.
The login system could require a password.
The recovery system could simply replace it.
And once the recovery mechanism became the weakest authentication path, taking over an account no longer required breaking the password at all.