Yaamlabs
Case study 07

A withdrawal that ran twice under load

Crypto exchange

An exchange let customers withdraw balances to external addresses. We were asked whether the balance check and the payout could be made to disagree, and whether an operator surface was reachable without one.

Web & API penetration test, Digital asset exchange, severity CRITICAL, reported 2024

At a glance

  • Challenge. A withdrawal checked the balance then debited it in two steps, so two concurrent requests could pass the same check before either reduced it.
  • Approach. We issued concurrent withdrawals for one balance and looked for the case where more left the account than the account held.
  • Result. The check and debit were made one atomic operation, withdrawals serialised per account and operator endpoints gated, then retested.
  • Impact. A customer could withdraw more than their balance held by racing requests, and an operator surface was reachable from an ordinary session.
  • Classification. CWE-367 Time-of-check Time-of-use (TOCTOU) Race Condition, OWASP Top 10 (2021): A04 Insecure Design. Component: Withdrawal balance check.

The challenge

A withdrawal is two steps that the system treats as one: confirm the balance covers the amount, then send the amount and reduce the balance. Between those steps there is a moment. If two requests arrive inside that moment, both can pass the same balance check before either reduces the balance.

The account is never overdrawn on paper at the instant of each check, which is exactly why the ledger raises no objection. The gap is not in the arithmetic, it is in the assumption that the two steps happen without interruption.

Separately, an operator endpoint that is protected only by not being linked is protected by nothing, because reaching a URL does not require being shown it. An exposed operator surface turns a timing bug into a standing one.

Our approach

We issued concurrent withdrawal requests for the same balance and looked for the case where more left the account than the account held. We then checked whether administrative endpoints answered to an ordinary session.

Around those results we checked the surface that shared the assumption:

  • Whether the balance check and the debit were performed as one atomic operation
  • Whether withdrawals were serialised per account rather than processed in parallel
  • Whether operator endpoints enforced role, not obscurity
  • Whether the same race reached internal transfers and order settlement

Delivered with the concurrent requests and the resulting balances, so each finding could be reproduced in a test environment without taking our word for it.

From finding to fix

  1. Sent concurrent withdrawals for one balance
  2. Both passed the same balance check
  3. More left the account than it held
  4. Made the debit atomic, retested

The outcome

The balance check and the debit were made a single atomic operation and withdrawals are now serialised per account, so two requests can no longer pass the same check. The operator endpoints were placed behind a server-side role check.

We retested the withdrawal path and the operator surface after the fix. Reported as CRITICAL in 2024. Retest included in the engagement.

Check your own system

  • Perform the balance check and the debit as one atomic operation so they cannot be interrupted between.
  • Serialise withdrawals per account rather than processing requests for the same balance in parallel.
  • Protect operator endpoints with a server-side role check, not by leaving them unlinked.
  • Test money-moving flows with concurrent requests, since a single request will pass every time.

Read more case studies, see Web Application Penetration Testing, or write to hello@yaamlabs.com.