Verified by Whoever Asked: Account Takeover Before the Victim Ever Signed Up
We registered an account on an address we did not own. The application asked the browser whether that address had been verified, believed the answer, and let us in. Nothing had to be intercepted, guessed or brute-forced - the victim had not signed up yet, and by the time they did, the account was already somebody else’s.
- The system
- A web application anybody can register for. Accounts are keyed on an email address, verified by a link, and different tiers of account see different functions.
- In scope
- The web application, its registration and recovery flows, and the boundaries between account tiers.
- What was at risk
- Every address that had not registered yet, and the boundary between account tiers for the ones that had. An attacker could hold a verified account on a stranger’s email address, and from inside a low tier reach functions belonging to a higher one.
Anonymised. An assessment carried out by our testers, with client-identifying detail withheld under the terms of the engagement. Full statement
What we were given
A web application with open registration. Anybody can create an account, accounts are keyed on an email address, and the address is meant to be confirmed by clicking a link sent to it. Different tiers of account see different functions.
Scope was the application, its registration and recovery flows, and the authorisation boundaries between those tiers. Open registration is worth saying out loud, because it means the first account an attacker needs is one the product hands out for free - there is no access to obtain before the assessment begins.
What we found
You could sign up as somebody else
Registration accepted an email address the registrant did not own. No ownership check ran before the account existed, and nothing reached the owner of the address to tell them it had been used.
On its own that is common and usually survivable, because the account is supposed to be inert until the address is confirmed. The next finding is what removed that.
The application asked the browser whether the address was verified
The request that completed registration carried a field stating whether the address had been confirmed. The server read that field and believed it.
A field in a request is written by whoever sends the request. So the answer to "has this person proved they own this address" was being supplied by the person who needed to prove it. Set it, and the account came out the other side verified - on an address that had never received anything.
The general rule this breaks
Verification is a fact the server establishes and the server records. The moment its value arrives in a request, it is no longer a verification - it is a request to be treated as verified, and the only correct response to that request is to ignore it. This is the same class as a price, a role or a user id arriving in a form: if the client can name it, the client can choose it.
And the tier boundary did not hold everywhere
With an ordinary low-tier account, we replayed its session against functions belonging to a higher tier. Some were refused. Some returned data and acted on it. That mixture is worse than a boundary that fails completely, because it is what makes the gap survive a code review: the developer tests one screen, sees the refusal, and reasonably concludes the check is in place.
Nothing limited the mail
Verification mail, password reset mail and account setup mail could each be sent to a chosen address as many times as we liked. That is a cost, a way to make somebody’s inbox unusable, and - alongside the finding above - a way to make an unexpected message from the platform look routine at exactly the wrong moment. There is more on where to key a limit in the rate limiting guide.
How far it went
The interesting part of this one is not what an attacker reaches today. It is what they reach later.
Register a verified account on somebody else’s address and then do nothing. The owner has no way to know: nothing arrived in their inbox, because nothing was ever sent. Weeks later they come to the product, sign up or are invited, and one of two things happens. Either the address is taken and they raise a support ticket, or the platform reconciles the two and they are placed inside an account somebody else set up and still has the password for.
This is account pre-hijacking, and it inverts the usual assumption about takeover. Nothing is stolen. There is no phishing, no credential reuse, no interception. The attacker simply gets there first, and every ordinary thing the real user then does - accepting an invitation, setting a password, adding their details - happens inside a container that was prepared for them.
| Question a defender would ask | What the logs say |
|---|---|
| Was there a suspicious login? | No. The attacker signed in to their own account, with a password they chose. |
| Was a password reset abused? | No. No reset was needed at any point. |
| Did the victim report anything? | Nothing to report. They never received a message. |
| Did anything fail? | No. Every request succeeded, because every request was legitimate in form. |
And the tier finding compounds it. The account an attacker holds is not merely an account on somebody else’s address; it is one that can reach some of what a more privileged account can.
The fix
- Verification recorded server-side, from the act of following a link the server issued and can recognise. Nothing in a request may set it, and any request that tries should be treated as an attempt rather than as input.
- An unverified account able to do nothing that matters. Registration may proceed, but the account holds no privileges, no data and no claim on the address until the owner has demonstrably acted.
- The address released, not merged, when it turns out to be contested. If somebody later proves ownership of an address held by an unverified account, the unverified account loses it - that decision needs to be made deliberately, because the alternative is what completes the attack.
- Every function checked against the caller’s tier at the endpoint, not on the screen that offers it. The mixed result we saw is the signature of checks added per screen rather than per action.
- Limits on verification, reset and setup mail, keyed to the sender rather than to the address being sent to.
The retest is where this one earned its place
When we came back, several items were fixed and several were partially fixed - a header set but not the others, a cookie flag applied but not the one that mattered most. That is the normal outcome of remediation, not a failure of it: fixes are made by different people at different times against a list, and a partial fix looks identical to a complete one from outside. A retest is what turns "we have addressed it" into something you can say to an auditor, which is why one is included in the engagement rather than sold afterwards.
The findings, in one place
- Registration accepted an email address the registrant did not own, with nothing sent to the owner before the account worked.
- The request carried a field stating whether the address had been confirmed, and the server took the client’s word for it.
- That combination is account pre-hijacking: take the address first, and the real owner arrives to find it already held.
- Inside the application, a lower-tier session reached functions belonging to a higher tier - some refused, some returned data.
- Verification, recovery and setup mail could all be sent to a chosen address without limit.
What to check on your own system
Take the request your own signup flow sends and read every field in it. Any field that answers a question the server is supposed to answer for itself - is this address confirmed, is this account active, what tier is this, has payment cleared - is a field the client is choosing the value of. There should be none.
Then ask the second question, which is the one most teams have never asked: what does your product do when somebody signs up for an address that already has an unverified account against it? If the answer is that the two are joined together, you have the last step of this attack already built, and the only thing standing in front of it is the check the first finding removed.
Confidentiality
Anonymised. This describes an assessment carried out by our testers. The client, the dates, the sector detail and any figure that could identify the organisation are withheld or generalised under the confidentiality terms of the original engagement, and no endpoint, parameter or payload from the assessment appears here.
Keep reading
- One Missing Check, Total Exposure: a single API flaw across 3 appsThe same root cause on the authorisation side: the server trusting something the client supplied.
- Rate limiting that locks out usersWhere to key a limit, and how a defence keyed on the wrong thing becomes an attack.
- Step-up authentication for sensitive actionsWhat a code proves, what it does not, and which actions need one.
- How we run an engagementScoping, testing, reporting, and the retest that confirms the fix actually landed.