Home/Journal/Teardown
TeardownCriticalauthenticationaccount-takeoverotp

The Password Reset That Emailed the Attacker

A property-management SaaS delivered login codes to any address the caller supplied. One request took over any account. Here is how, and the fix.

Rahul Dharan··8 min read·a property-management SaaSAuthentication bypass via caller-supplied destination (CWE-640)

The setup

The target was a property-management SaaS - the software a landlord or building operator uses to run their portfolio: tenants, leases, maintenance requests, payments, and the staff accounts that administer all of it. A platform like this holds a lot of authority in a small number of accounts. The administrator seat can see every tenant and every payment, and there is usually an even higher internal seat above that.

That concentration of authority is exactly why the login flow was worth studying. In a system where one account can see everything, the fastest path to all of it is not a data-layer bug - it is a flaw in how the platform decides who you are.

Recon

Working black-box, from the open internet, we looked at the authentication endpoints the application exposed. The login flow was the usual shape: a user identifies themselves, the platform sends a one-time code, the user enters the code to complete login.

One endpoint stood out. It was a “send OTP to mail” call, and it took an email address as an input parameter. That single design choice - the caller naming the address the code should go to - was the thread worth pulling. A code is only a secret if it reaches only the right inbox.

The hypothesis

A one-time code exists to prove one thing: that whoever is logging in controls the account’s registered contact. The whole security of the flow rests on the code going somewhere only the real owner can read.

So the hypothesis was about who chooses the destination. If the server sends the code to the address it has on file for the account, the flow is sound - the caller has to already control that inbox. But if the server sends the code to an address the caller supplies in the request, the proof collapses. The attacker names the account they want and names their own inbox as the destination, and the platform delivers the key to the wrong door.

If that was how this endpoint worked, it was a one-request account takeover, silent to the victim.

What we tried

We tested it end-to-end against an account we were authorized to target - an administrator account on the platform.

We called the “send OTP to mail” endpoint, naming the administrator account as the target and an inbox we controlled as the destination. The code arrived in our inbox. We entered it and completed login as the administrator. The real owner received nothing - from their side, nothing happened at all.

We confirmed the same shape reached the highest internal seat: the top “godview” account, the one that sits above ordinary administrators, was requestable the same way.

What we deliberately did not do matters. We proved the takeover on an authorized account and stopped. We did not touch tenant accounts, did not read customer data beyond confirming the access, and retained nothing.

The instructive part came after we reported it. On a re-test, the vendor had added a rate-limit header to the endpoint - a sensible-looking response to “someone is abusing our OTP flow.” The exploit still worked. It works with a single request, so a throttle designed to stop repeated requests never engages. Adding a rate limit to a one-request logic flaw changes the response headers and nothing about the vulnerability.

What we found

The endpoint conflated two separate questions - “who is asking?” and “where should the code go?” - and trusted the caller to answer both.

-> POST <send-otp-to-mail endpoint>
   { "account": "<admin account>", "email": "<attacker-controlled inbox>" }

<- 200 OK   (login code delivered to the attacker-controlled inbox)

-> POST <verify endpoint>
   { "account": "<admin account>", "otp": "<code from attacker inbox>" }

<- 200 OK   { "session": "<valid admin session>" }

Two requests, one of them just typing in the code that was handed over. No interaction from the victim, no notification to the victim, no data-layer bug required. The account’s own login flow delivered the credential to the attacker because the request got to name the destination.

The re-test finding was its own lesson. The added rate-limit header:

<- 200 OK
   X-RateLimit-Limit: ...
   X-RateLimit-Remaining: ...
   (login code still delivered to the attacker-controlled inbox)

A control that assumes the attack needs volume, applied to an attack that needs one request.

Why it happens

This is a natural consequence of building the flow around the input the client already has.

When you write a “send a code to the user” endpoint, the email address is right there in the request - the user just typed it, or the client just posted it. Reading the destination straight from the request is the obvious implementation, and it works perfectly in every normal test, because a real user names their own address. The flaw only appears when someone names an account that is not theirs and an inbox that is.

The endpoint ended up trusting the caller for identity and for delivery at the same time. Those are two different trust decisions, and only one of them is safe to delegate to the caller. The rate-limit response afterward came from pattern-matching the symptom - “abuse of the OTP endpoint” - to the wrong class of fix. Throttling is the right tool for brute force. It is the wrong tool for a logic flaw that never needs a second try.

For developers

If you build a login or reset flow that delivers a one-time code, three rules close this:

  1. Send codes only to the address already on file. The account record holds the registered contact. That stored value is the destination - always, without exception. The caller identifies which account they claim to be; the server decides where the proof is sent. Never read the destination from the request.
  2. Separate “who is asking” from “where to send.” These are two trust decisions. Delegating identity to the caller is normal - that is what a username is. Delegating the delivery destination to the caller is the vulnerability. Keep them apart in your code so the second one is never accidentally trusted.
  3. Do not reach for a rate limit to fix a logic bug. A throttle only helps against attacks that need many attempts. A one-request flaw sails straight through it. If an exploit works on the first try, the fix is the logic, not the rate - adding a limit only makes the endpoint look defended.

And one detection step: audit your delivery logs for one-time codes sent to an address that does not match the account’s registered contact. Any mismatch is either the bug firing or an attacker already using it.

The takeaway

An email address in a request is a claim about identity, not permission to receive a secret. The moment a login flow lets the caller choose where the code is delivered, the code stops being a secret and the account is takeable in one silent request. And a rate limit is not a fix for that - it is a fix for a different problem that this endpoint does not have. When an exploit needs only one request, throttling is theater; correct the logic instead.

This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in Email Is Not Identity.

Frequently asked

What is a caller-supplied destination vulnerability?
It is an authentication flaw where a login or reset endpoint sends a one-time code to whatever address the request specifies, instead of the address already on file for the account. An attacker requests a code for a victim's account, points it at their own inbox, and receives a valid credential.
Can a rate limit fix a one-request account takeover?
No. A rate limit slows down attacks that need many attempts, such as guessing a code. A caller-supplied destination flaw needs exactly one request, so throttling never triggers. The fix is to correct the logic, not to limit the rate.
How should password reset and login OTP flows handle the destination?
Send the code only to the address already stored for that account. The caller identifies who they claim to be; the server decides where the code goes. Never let the request specify the delivery destination.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →