Token-0x

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

Critical ERC20 Invariant Breakage Due to Unsafe Yul Assembly

Author Revealed upon completion

Root + Impact

Description:

  • Normal Behavior: An ERC20 token should correctly maintain user balances, total supply, and allowances. Operations like transfer, mint, burn, and approve should revert on invalid actions (e.g., insufficient balance or allowance) and never corrupt state.

  • Issue: In ERC20Internals.sol, critical functions such as _transfer, _burn, _mint, and _spendAllowance are implemented entirely in Yul without proper overflow/underflow checks or state validation. This allows balances, allowances, and total supply to silently underflow or overflow, breaking ERC20 invariants and potentially giving users unintended token amounts.

function _burn(address account, uint256 value) internal {
assembly {
// ❌ No check whether `value <= balance`
let supply := sload(_totalSupply.slot)
sstore(_totalSupply.slot, sub(supply, value)) //@> underflow possible
let ptr := mload(0x40)
mstore(ptr, account)
let balanceSlot := keccak256(ptr, 0x40)
let bal := sload(balanceSlot)
sstore(balanceSlot, sub(bal, value)) //@> silent underflow
}
}

Risk

Likelihood:

  • Any user or contract interacting with the ERC20 functions (transfer, transferFrom, mint, burn) triggers the vulnerability during normal token operations.

  • Malicious actors can exploit the Yul implementation to create unexpected balances, bypass restrictions, or drain tokens, especially when large amounts are involved.

Impact:

  • Token balances or total supply can underflow or overflow, allowing attackers to mint or burn tokens in unintended ways.


DeFi protocols relying on this token may suffer financial losses, broken accounting, or contract failure.

Proof of Concept

The PoC demonstrates how the low-level Yul implementations in ERC20Internals can be exploited. By attempting operations that exceed balances or allowances, an attacker can trigger underflows or incorrect state changes. This could allow them to mint, burn, or transfer tokens in unintended ways, potentially draining a user’s tokens or breaking DeFi protocol accounting.

// Example illustrating potential underflow/overflows in ERC20Internals
ERC20 token = new ERC20("TestToken", "TT");
// Mint 100 tokens to attacker
token._mint(attacker, 100);
// Attempt to burn 200 tokens (more than balance)
token._burn(attacker, 200); // Underflow occurs, may set balance to huge value
// TransferFrom exploits: spender can reduce allowance below zero silently
token.approve(spender, 50);
token._spendAllowance(attacker, spender, 100); // allowance underflows

Recommended Mitigation

Description

The Token‑0x ERC20 implementation heavily relies on Yul for balance and allowance management, which introduces risks of underflows, overflows, zero-address transfers, and incorrect event emissions. To mitigate these risks, all state-changing operations (mint, burn, transfer, approve, and allowance updates) should include strict input validation, use Solidity 0.8+ built-in arithmetic safety, enforce correct allowance logic, ensure proper ERC20 event emission, and restrict mint/burn operations to authorized addresses. Minimizing Yul in critical operations and keeping it only for non-state-changing, view-heavy functions can significantly reduce vulnerabilities.

function _transfer(address from, address to, uint256 amount) internal {
require(from != address(0) && to != address(0), "ERC20: invalid address");
uint256 fromBal = _balances[from];
require(fromBal >= amount, "ERC20: insufficient balance");
unchecked {
_balances[from] = fromBal - amount;
_balances[to] += amount;
}
emit Transfer(from, to, amount);
}
function _spendAllowance(address owner, address spender, uint256 amount) internal {
uint256 currentAllowance = _allowances[owner][spender];
require(currentAllowance >= amount, "ERC20: insufficient allowance");
unchecked { _allowances[owner][spender] = currentAllowance - amount; }
}
modifier onlyOwner() {
require(msg.sender == owner, "ERC20: not authorized");
_;
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function _mint(address account, uint256 amount) internal {
require(account != address(0), "ERC20: mint to zero address");
_totalSupply += amount;
_balances[account] += amount;
emit Transfer(address(0), account, amount);
}

Support

FAQs

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

Give us feedback!