Introduction
Account-takeover vulnerabilities are often described as authentication failures: weak passwords, reusable reset tokens, broken multi-factor authentication, or predictable OTPs. This case began somewhere less obvious—the authenticated “change email address” feature.
The application accepted a clientId in the body of its email-change requests. Instead of deriving the target account from the authenticated session, the backend trusted this body value to decide which customer should be updated. The same workflow sent its OTP to the proposed new email address, which was also supplied by the requester.
Those two decisions created a dangerous identity mix-up:
- The bearer token answered, “Who is making the request?”
- The body-supplied
clientIdanswered, “Whose account should be modified?” - The body-supplied email answered, “Who should receive the authorization code?”
Because the server did not require all three identities to match, one authenticated customer could target another customer while receiving the victim’s email-change OTP in the attacker’s own mailbox.
The only missing input was the victim’s clientId. A second flaw in the unauthenticated login flow supplied it from nothing more than the victim’s email address. Chained together, the issues produced a zero-click account takeover: no victim approval, no victim OTP, no brute force, and no interaction beyond the attacker knowing the victim’s email.
Step 1 — Discovering the vulnerable email-change endpoints
The investigation started by mapping the legitimate email-change workflow from the account settings page. I captured the requests generated by the application and reviewed the client-side code responsible for serializing them.
The flow used two requests, represented here with normalized paths:
POST /api/account/email/request-otpPOST /api/account/email/verify-otp
The first request generated an OTP:
POST /api/account/email/request-otp HTTP/2
Host: api.target.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{
"flow": "emailUpdate",
"emailId": "new-address@example.com",
"clientId": "ACCOUNT_A_CLIENT_ID"
}
The second request submitted the OTP and committed the new address:
POST /api/account/email/verify-otp HTTP/2
Host: api.target.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{
"flow": "emailUpdate",
"emailId": "new-address@example.com",
"otpCode": "<OTP>",
"clientId": "ACCOUNT_A_CLIENT_ID"
}
The request shape immediately raised two questions:
- Why did an authenticated request need a body-supplied
clientIdwhen the server already had a bearer token? - Was the OTP sent to the current trusted address or directly to the proposed new address?
Neither behavior is automatically vulnerable. A clientId may be ignored or checked against the token, and sending a code to a new address can be safe if the user has already reauthenticated through a trusted factor. The next step was therefore to test the authorization boundary with two controlled accounts.
Establishing a clean two-account baseline
I used two accounts that I owned:
- Account A: the attacker account and source of the bearer token.
- Account B: the victim account whose email would be changed.
Before attempting the cross-account case, I replayed the legitimate flow with Account A’s token and Account A’s clientId. This established that the request format, token, headers, and OTP delivery were correct.
I then changed only one identity-bearing field: clientId.
| Test | Bearer token | Body clientId | Expected secure behavior | Observed behavior |
|---|---|---|---|---|
| Owner baseline | Account A | Account A | Request accepted | Request accepted |
| Cross-account test | Account A | Account B | Reject as unauthorized | Request accepted |
That differential was the first strong indication of broken object-level authorization. The server was not consistently binding the operation to the authenticated subject.
Step 2 — Building the complete takeover chain
Accepting a foreign clientId was only a primitive. To demonstrate an impactful vulnerability, I needed to prove where the OTP went, whether it authorized an actual cross-account change, and whether the changed address led to account control.
2.1 Requesting the victim’s email change with the attacker’s session
Using Account A’s valid token, I supplied Account B’s clientId and an unused mailbox controlled by me:
POST /api/account/email/request-otp HTTP/2
Host: api.target.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{
"flow": "emailUpdate",
"emailId": "attacker-new-mailbox@example.com",
"clientId": "ACCOUNT_B_CLIENT_ID"
}
The server accepted the request.
2.2 Confirming the OTP destination
The OTP arrived in attacker-new-mailbox@example.com—the new address chosen by the attacker. It did not arrive at Account B’s existing email address.
This detail turned the authorization issue into a takeover path. The supposedly second factor was not proof that the requester controlled the victim’s existing identity. It only proved control of an address the requester had just supplied.
In effect, the application asked the attacker to authorize the attacker’s own change.
2.3 Committing the change across accounts
I submitted the OTP using the same Account A session while keeping Account B’s clientId in the body:
POST /api/account/email/verify-otp HTTP/2
Host: api.target.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{
"flow": "emailUpdate",
"emailId": "attacker-new-mailbox@example.com",
"otpCode": "<OTP_RECEIVED_BY_ATTACKER>",
"clientId": "ACCOUNT_B_CLIENT_ID"
}
The verification succeeded, and Account B’s login email was replaced with the attacker-controlled address.
I verified the state change using only my two accounts. The old Account B address was no longer bound to the account, while the new mailbox was recognized as registered.
2.4 Converting the email swap into full account takeover
Once the victim account’s login email belonged to the attacker, the remaining step used the application’s normal password-recovery process:
- Start “forgot password” for the newly attached email address.
- Receive the reset message in the attacker-controlled mailbox.
- Set a new password.
- Sign in as Account B.
This demonstrated complete account takeover rather than a theoretical email-change issue. In the affected financial application, a compromised account exposed the customer profile, identity-verification data, trading functionality, and money-related features. No funds were moved during testing.
Why this was zero-click
“Zero-click” describes the victim interaction requirement, not the number of requests made by the attacker. The chain required no action from the victim:
- No link had to be opened.
- No OTP had to be shared.
- No prompt had to be approved.
- No malicious page had to be visited.
- No password or session token had to be stolen.
The attacker needed a normal account, a controlled mailbox, and one public piece of victim information: the victim’s email address.
At this point, however, the exploit still appeared to require a non-public clientId. That constraint led to the final discovery.
Step 3 — Finding the clientId leak in the login error
Now this is getting interesting, The attack works but the severity isnt critical yet. While i was mapping the requests and the reponses i found that the login function leaks the clientId if the attacker knows the email only. if the account exists in the database it errors out the clientId if not it won’t leak the ClientId
3.1 Existing account with an incorrect password
Using the email of Account B, an incorrect password, and a present-but-invalid CAPTCHA value produced a login error shaped like this:
POST /api/auth/login HTTP/2
Host: api.target.com
Content-Type: application/json
{
"emailId": "account-b@example.com",
"password": "DefinitelyWrongPassword!",
"captchaToken": "invalid-value"
}
{
"error": {
"message": "authentication failed",
"metaData": {
"ClientId": "ACCOUNT_B_CLIENT_ID"
}
}
}
The disclosed value matched the known clientId of my controlled Account B.
3.2 Negative controls
I repeated the request with a nonexistent email address. The response returned a generic credential error and did not contain ClientId. Omitting the CAPTCHA value also followed a different validation path and did not disclose the identifier.
| Input | Result |
|---|---|
| Existing email + wrong password + invalid non-empty CAPTCHA | Error includes ClientId |
| Nonexistent email + wrong password + invalid non-empty CAPTCHA | Generic error, no ClientId |
| Existing email + missing CAPTCHA | CAPTCHA validation error, no ClientId |
These controls showed that the identifier was disclosed specifically for an existing account before authentication succeeded.
3.3 Closing the exploit chain
The login error transformed a limited IDOR into a broadly targetable takeover:
Victim email address
↓
Unauthenticated login error leaks victim clientId
↓
Attacker session requests email-change OTP for victim clientId
↓
OTP is delivered to attacker-chosen new email
↓
Attacker verifies OTP and replaces victim login email
↓
Normal password reset gives attacker full account access
The identifier leak would have been low impact in isolation. The email-change flaw was critical even when tested with a known identifier, but the leak removed its main practical constraint. Together they created a reliable attack requiring only the victim’s email address.
Root cause analysis
The chain resulted from several trust failures that reinforced one another.
1. Authorization relied on a client-controlled object reference
The email-change handlers trusted the body’s clientId instead of deriving the target account from the bearer token. Authentication proved the caller owned Account A, but the server allowed the caller to nominate Account B as the object being modified.
2. The OTP was bound to an untrusted destination
The code was delivered to the proposed new email before the requester had proven control of the account’s existing trusted identity. Possession of that OTP therefore proved only that the attacker controlled the attacker-supplied address.
3. The workflow lacked strong reauthentication
A sensitive identity change did not require confirmation through the current email, the existing password, a valid MFA factor, or a purpose-bound step-up token.
4. Authentication errors leaked an internal account identifier
The login endpoint returned ClientId in a pre-authentication error path. This made the cross-account object reference discoverable from an email address.
5. Security controls were implemented independently
The bearer token, OTP, CAPTCHA, and password-reset flow each existed, but none guaranteed that the actor, target account, trusted destination, and recovery identity were the same principal. Multiple controls do not create security when they protect different identities.
Remediation
The primary fix is to make the authenticated identity authoritative throughout the workflow.
- Derive the affected customer exclusively from the verified token subject. Do not accept
clientIdfrom the body of self-service email-change requests. - Bind every OTP to the authenticated subject, exact proposed email, purpose, transaction identifier, expiry time, and attempt counter.
- Require reauthentication or a purpose-bound step-up factor before changing a login identifier.
- Confirm the request through an existing trusted channel before activating the new email address.
- Send immediate notifications to the old email when a change is requested and completed, with a safe recovery path.
- Remove internal identifiers and account-existence differences from unauthenticated errors. Login responses should be uniform in body, status, and meaningful timing behavior.
- Invalidate active sessions and recovery tokens after a sensitive identity change, or require explicit session review.
- Add automated cross-identity tests: Account A’s token combined with Account B’s identifier must fail at every stage.
Fixing only the identifier leak would make targeting harder but would leave the authorization vulnerability intact. Fixing only OTP rate limits would not affect the attack because the attacker receives the correct code. The decisive controls are token-bound authorization and proof of control over the account’s existing trusted identity.
Lessons for security researchers
Treat identity fields as authorization hypotheses
Whenever an authenticated request contains userId, customerId, clientId, accountId, or a similar owner reference, ask why it is needed. Compare it with the token subject using two owned accounts.
Test where a security code is delivered
A 200 OK from an OTP endpoint is not enough. Determine which identity the OTP represents, where it is sent, and which fields it is cryptographically or server-side bound to.
Validate primitives as complete impact chains
The foreign clientId acceptance was a primitive. The finding became a demonstrated account takeover only after proving OTP delivery, cross-account email replacement, and password recovery.
Revisit “low-impact” leaks after finding a powerful sink
An internal identifier in a login error may look like ordinary enumeration. Its severity changes when another endpoint trusts that identifier as an authorization boundary. Evaluate information leaks in the context of reachable state-changing operations.
Use controls that isolate one variable at a time
The clearest evidence came from changing only the clientId while keeping the token, request format, destination mailbox, and headers constant. Owner, cross-account, nonexistent-object, and unauthenticated controls make authorization findings reproducible and difficult to misinterpret.
Conclusion
This takeover did not depend on breaking cryptography or guessing an OTP. It came from an identity-binding failure across otherwise ordinary features.
The application authenticated one customer, modified another customer, and sent the authorization code to an address selected by the first customer. A login error then disclosed the identifier needed to choose the victim. Each component appeared to perform its local job, but the end-to-end workflow never established that the caller, target account, OTP recipient, and recovery identity belonged to the same person.
That is the central lesson of the research: account security must be evaluated as a chain of identity transitions. If a user can choose either the account being modified or the channel that authorizes the modification, the entire recovery boundary should be treated as attacker-controlled until proven otherwise.
