Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: high

Multi-Step Allowance Double-Spend in approve() Function

Author Revealed upon completion

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 .

// ERC20.sol - Public approve function
function approve(address spender, uint256 value) public virtual returns (bool success) {
address owner = msg.sender;
@> success = _approve(owner, spender, value); // Direct overwrite, no safety check
}
// ERC20Internals.sol - Internal implementation
function _approve(address owner, address spender, uint256 value) internal virtual returns (bool success) {
assembly ("memory-safe") {
// ... validation code ...
let allowanceSlot := keccak256(ptr, 0x40)
@> sstore(allowanceSlot, value) // Unconditional overwrite
}
}

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:

  1. Initial Approval: Owner approves spender for 50 tokens

  2. Partial Spend: Spender uses 30 tokens, 20 tokens remain approved

  3. Reapproval Attack: Owner sets new approval for 40 tokens, overwriting the 20 remaining

  4. 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");
// Mint tokens to owner
baseToken.mint(owner, 100e18);
// Step 1: Approve spender for 50 tokens
vm.prank(owner);
baseToken.approve(spender, 50e18);
// Step 2: Spender spends 30 tokens
vm.prank(spender);
baseToken.transferFrom(owner, recipient1, 30e18);
// Step 3: Owner re-approves for 40 tokens (overwrite while 20 remains)
vm.prank(owner);
baseToken.approve(spender, 40e18);
// Step 4: Spender can now spend 40 more tokens (double-spend vulnerability)
vm.prank(spender);
baseToken.transferFrom(owner, recipient2, 40e18);
// Total spent: 30 + 40 = 70 tokens (more than initial 50 approval)
assertEq(baseToken.balanceOf(recipient1), 30e18);
assertEq(baseToken.balanceOf(recipient2), 40e18);
assertEq(baseToken.balanceOf(owner), 30e18); // 100 - 70 = 30 remaining
}

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;
+ }

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!