Racing the rate limit: unlimited OTP emails on LeveX
The endpoint that sends a one-time code capped how often you could call it. The cap was not atomic, so firing the same request in parallel slipped a whole burst past it and flooded the mailbox, turning the email provider's bill into the impact.
LeveX is a crypto exchange. In account settings, disconnecting a linked Google account asks you to confirm with a one-time code (OTP) sent to your email. The endpoint that sends that code is rate limited, so a normal user cannot spam it. The limit, though, was not enforced atomically: sending many copies of the request at the same instant let a large burst through at once. Each one sends a real email, so an attacker could make the platform send effectively unlimited OTP mail. Reported to LeveX on HackenProof, rated Low, and resolved.
What a race condition is, in plain terms
A rate limit usually works in two steps: it reads a counter to check how many requests you have made recently, and if you are under the limit it lets the request through and adds one to the counter. That is fine when requests arrive one after another.
The problem is the tiny gap between the check and the update. If many requests arrive at the exact same moment, they can all read the counter before any of them has increased it. Every one of them sees "you are under the limit" and every one is allowed through. This is a race condition, sometimes called a time-of-check to time-of-use bug: the value that was checked is already stale by the time it is used.
Sent one at a time, each request updates the counter before the next is checked, and the limit holds. Sent all at once, they overlap in that gap and slip through together. The attack is not sending more requests, it is sending them at the same time.
The attack
-
Reach the confirmation step
In account settings, start disconnecting the linked Google account. It prompts for a code sent to your email.
The confirmation prompt. Clicking to send the code is the request we are going to race. Emails are redacted. -
Capture the send-code request
Send the request to Burp Suite. It is a single call, shown here with the session token and email removed:
POST /service-user-identity/codes/mail HTTP/2 Host: api100.levex.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: https://levex.com/en/my/settings/account X-Lx-Uuid: 4076083e-8dd0-40a3-2995-286f55cabe08 X-Lx-Device: WEB X-Lx-Request-Tracing: 9c4981ff-20c9-6bf8-a186-dfb17b6117f4 X-Authorization: [redacted] Content-Type: application/json Content-Length: 55 Origin: https://levex.com Sec-Fetch-Dest: empty Sec-Fetch-Mode: cors Sec-Fetch-Site: same-site Priority: u=0 Te: trailers {"email":"[redacted]","type":"ThirdAuthUnbind"}Under normal use the response returns
{"error":null,"result":"OK"}and reports headers likeX-Capacity: 500with anX-Remainingthat counts down, so a limiter clearly exists. It just does not survive concurrency. -
Fire it in parallel
Put many copies of the identical request into a group and use Burp's send group in parallel (single-packet / last-byte sync). They arrive together and land in the gap between the limiter's check and its update.
A group of identical requests sent in parallel. Each returns result: "OK"and a code is sent, far past what the limiter should allow in that window. Token and cookies redacted.
The mailbox fills with verification codes from a single burst, even though the per-user limit is supposedly 500 and should never be reachable by hand.
Why it matters
There is no account takeover or data leak here, which is why it is rated Low. What it is, is a way to make the platform spend money and send spam on demand:
- Email cost. Every code is a real email through a paid provider (SendGrid, SES, and the like). Automated at scale, a bot issuing very large numbers of these turns into a running daily bill for outbound mail that the platform did not choose to spend.
- Mailbox abuse. The target address is buried in verification mail, which can also be pointed at any user's inbox as a nuisance or to bury a real security notice.
- Amplification. Each single request produces an email, and the parallel bypass removes the ceiling that was supposed to keep that in check. Multiple bots multiply it.
The fix
The limit has to be enforced as one atomic operation, so two requests can never both pass on the same allowance:
- Check and increment atomically. Use an atomic counter (for example a single Redis
INCRwith expiry, or an atomic database update) so the "am I under the limit" and "add one" steps cannot be split by concurrent callers. - Serialize per target. Take a short per-user or per-email lock around the send, so overlapping requests queue instead of racing.
- Add a real cooldown. Enforce a minimum interval between codes to the same address, checked atomically, and cap sends per address per hour.
Key takeaways
- A rate limit that is not atomic is not a rate limit. If check and increment are two steps, parallel requests slip between them.
- Seeing
X-Remainingcount down proves a limiter exists, not that it holds under concurrency. Always test the limit with parallel requests, not just fast sequential ones. - Low severity does not mean no impact: an OTP-send race is a direct, repeatable cost and spam vector for the target.
- Fix it where the counter lives, with an atomic increment or a per-target lock, not with client-side or after-the-fact checks.