Root + Impact
Description
-
Normal ERC20 allowance patterns should prevent spenders from extracting more tokens than the owner intends to approve. The approve() function in Token-0x's public interface directly overwrites existing allowances without checking for partial spends, enabling a multi-step attack where spenders can extract additional tokens beyond any single approval limit.
-
The vulnerability occurs because approve() calls _approve() which unconditionally overwrites the storage slot without validating the current allowance state .
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)
}
}
Risk
Likelihood:
-
Any token holder who approves a spender and later modifies that approval is vulnerable
-
DeFi protocols using allowance patterns (DEXes, lending markets, vaults) will encounter this in normal operation
-
The attack requires no special privileges beyond a valid initial approval
Impact:
-
Token holders can lose more tokens than they ever approved in a single transaction
-
Economic attacks on DeFi protocols that rely on allowance mechanisms for security
-
Undermines trust in allowance-based authorization systems
Proof of Concept
The test demonstrates how a spender can extract 70 tokens despite never receiving more than 50 tokens in any single approval:
Initial Approval: Owner approves spender for 50 tokens
Partial Spend: Spender uses 30 tokens, 20 tokens remain approved
Reapproval Attack: Owner sets new approval for 40 tokens, overwriting the 20 remaining
Double-Spend: Spender extracts 40 more tokens, totaling 70 tokens extracted
function test_AllowanceDoubleSpend() public {
VulnerableToken baseToken = new VulnerableToken();
address owner = makeAddr("owner");
address spender = makeAddr("spender");
address recipient1 = makeAddr("recipient1");
address recipient2 = makeAddr("recipient2");
baseToken.mint(owner, 100e18);
vm.prank(owner);
baseToken.approve(spender, 50e18);
vm.prank(spender);
baseToken.transferFrom(owner, recipient1, 30e18);
vm.prank(owner);
baseToken.approve(spender, 40e18);
vm.prank(spender);
baseToken.transferFrom(owner, recipient2, 40e18);
assertEq(baseToken.balanceOf(recipient1), 30e18);
assertEq(baseToken.balanceOf(recipient2), 40e18);
assertEq(baseToken.balanceOf(owner), 30e18);
}
Recommended Mitigation
Implement the safe approval pattern that requires resetting allowances to zero before setting new values, preventing the reapproval attack vector.
function approve(address spender, uint256 value) public virtual returns (bool success) {
address owner = msg.sender;
+
+ // Safe approval pattern: check existing allowance
+ uint256 currentAllowance = allowance(owner, spender);
+ if (currentAllowance > 0 && value > 0) {
+ // Revert if trying to increase non-zero allowance
+ revert("SafeERC20: approve from non-zero to non-zero allowance");
+ }
+
success = _approve(owner, spender, value);
}
Alternatively, implement the decrease/increase approval pattern used by OpenZeppelin:
+ 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");
+ unchecked {
+ _approve(owner, spender, currentAllowance - subtractedValue);
+ }
+ return true;
+ }
+
+ function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
+ address owner = msg.sender;
+ _approve(owner, spender, allowance(owner, spender) + addedValue);
+ return true;
+ }