Async Withdraw Race Condition
id: LS08H
title: Async Withdraw Race Condition
baseSeverity: H
category: race-condition
language: solidity
blockchain: [ethereum]
impact: Multiple withdrawals from the same balance before state update
status: draft
complexity: medium
attack_vector: external
mitigation_difficulty: medium
versions: [">=0.4.0", "<0.8.21"]
cwe: CWE-362
swc: SWC-107
π Description
- Double Spend via Async Withdraw refers to a race condition vulnerability where a user (or attacker) is able to call a
withdraw()function multiple times before their balance is updated, especially when external calls (e.g.,transfer(),call()) are made before state updates. - This results in multiple successful withdrawals from the same balance or claim allocation.
- This often occurs in poorly ordered code in
withdraw()orclaim()functions, particularly when gas forwarding or reentrancy opportunities exist.
π¨ Vulnerable Code
contract AsyncVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// β External call happens before balance update
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
balances[msg.sender] = 0; // β State update after call
}
}
π§ͺ Exploit Scenario
Step-by-step exploit process:
- Attacker deposits 1 ETH into AsyncVault.
- They call withdraw(), which sends funds via call() before setting balance to 0.
- The fallback function in attackerβs contract re-enters withdraw(), repeating the process.
- Attacker drains funds multiple times, exceeding their original balance.
Assumptions:
- Contract uses external calls before updating internal state.
- No reentrancy guard or state-locking mechanism is in place.
β Fixed Code
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // β
State updated before external call
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
}
}
π§ Contextual Severity
- context: "Async withdrawal logic exposed to reentrancy"
severity: H
reasoning: "Leads to total fund loss if attacker loops reentry."
- context: "Claim function is rate-limited or uses delayed execution"
severity: M
reasoning: "Timing mitigates mass exploitation, but race still exists."
- context: "State locked before external calls"
severity: I
reasoning: "Pattern correctly mitigated, not exploitable."
π‘οΈ Prevention
Primary Defenses
- Always follow the Checks-Effects-Interactions pattern.
- Set balances or state before performing external calls.
- Use ReentrancyGuard (mutex lock) for functions that move ETH or tokens.
Additional Safeguards
- Emit withdrawal events after success for off-chain monitoring.
- Consider using pull payment models (withdraw() pattern) rather than send() or push payments.
- Harden fallback functions and avoid sending gas-forwarding call() unless necessary.
Detection Methods
- Slither: reentrancy-eth, dos, and external-call-before-state-change detectors.
- Manual inspection of withdraw(), claim(), or transfer() logic.
- Fuzz testing with reentrant contracts to simulate race conditions.
π°οΈ Historical Exploits
- Name: Lendf.Me Exploit
- Date: 2020-04-19
- Loss: Approximately $25 million
- Post-mortem: Link to post-mortem
π Further Reading
β Vulnerability Report
id: LS08H
title: Async Withdraw Race Condition
severity: H
score:
impact: 5
exploitability: 4
reachability: 4
complexity: 2
detectability: 4
finalScore: 4.2
π Justifications & Analysis
- Impact: Enables attackers to extract more than their rightful balance.
- Exploitability: Reentrancy through fallback or low-level call is common and easy to deploy.
- Reachability: Very common in financial contracts with external withdrawals.
- Complexity: Simple logic errorβattacker can act with basic tooling.
- Detectability: Readily flagged by tools like Slither and through code review.