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.
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:
Proof of Concept
Alice approves spender for 100
Alice wants to change it to 50 → calls approve(spender, 50)
3. Spender front-runs and:
contract Spender {
function attack(address token, address victim) external {
IERC20(token).transferFrom(victim, msg.sender, 100 ether);
IERC20(token).transferFrom(victim, msg.sender, 50 ether);
}
}
contract TestRaceCondition {
Token0x token;
Spender attacker;
function testRace() external {
token = new Token0x();
attacker = new Spender();
token.mint(address(this), 200 ether);
token.approve(address(attacker), 100 ether);
attacker.attack(address(token), address(this));
require(token.balanceOf(address(attacker)) == 150 ether, "Attacker stole 150 ether");
}
}
Recommended Mitigation
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;
+ }