Skip to content
Case study

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.

7 min read
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.

Why it is hard to detect from the inside
Question a defender would askWhat 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

FAQ

Questions this raises

We send a verification email. Are we not covered?

Only if nothing works until it is clicked, and only if the server is the thing that records the click. This application sent one; the account did not wait for it, and the flag saying it had been confirmed was accepted from the request. Sending the mail is the easy half. The half that matters is what the account can do before the mail is answered.

Is this not a low-severity issue, since nothing was actually stolen?

Nothing is stolen at the moment of the attack, which is exactly what makes it worth reporting high. The impact lands later, on a user who did everything correctly, and it lands as full access to their account by somebody who was waiting. Severity follows the outcome that becomes available, not the noise made getting there.

Would a scanner have found any of it?

A scanner can flag a missing rate limit. It cannot know that a field in your signup request is supposed to be a server-side fact, because it does not know what your fields mean. And it cannot test a tier boundary, because that needs two accounts of different kinds and an understanding of which functions belong to which.

How do you test this without affecting real users?

With addresses we control, on an environment agreed in the rules of engagement before testing starts. The pre-hijacking chain is demonstrated end to end against our own addresses, and documented so your developers can reproduce it without doing so against a real account.

Is the same true of yours?

Most of what is above came from ordinary accounts and ordinary requests. A 30-minute scoping call establishes what would be worth checking on your system, what it costs, and when we could start.