Token-0x

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

Unchecked arithmetic in _mint and _burn allows totalSupply and balances to wrap

Author Revealed upon completion

ERC20Internals._mint and ERC20Internals._burn perform additions and subtractions on _totalSupply and account balances directly in Yul using add/sub without any overflow/underflow checks. In assembly, these operations are always wrapping modulo 2^256.

Description

  • Normal behavior

    In a robust ERC20 implementation, minting and burning adjust the total supply and balances without allowing arithmetic to overflow or underflow:

    • Mint:

      • totalSupply increases by value if and only if totalSupply + value does not overflow.

      • balance[account] increases by value with similar checks.

    • Burn:

      • totalSupply decreases by value only when totalSupply >= value.

      • balance[account] decreases by value only when balance[account] >= value.

    If these conditions are not met, the operation naturally reverts to keep the token state consistent.


  • Issue

    In this implementation, both _mint and _burn use raw Yul arithmetic:

    • add(supply, value) and add(accountBalance, value) in _mint.

    • sub(supply, value) and sub(accountBalance, value) in _burn

    These are unchecked and will wrap modulo 2^256 on overflow or underflow. There are no guards that ensure supply + value stays within range or that supply >= value / accountBalance >= value. As a result:


    • A large mint can cause _totalSupply and balances to wrap, potentially resetting to low values or rolling over.

    • A burn with value greater than supply or accountBalance will underflow and set them to huge values (close to type(uint256).max), effectively minting massive amounts of tokens instead of burning.


function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0xec442f05))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
//@> sstore(supplySlot, add(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
//@> sstore(accountBalanceSlot, add(accountBalance, value))
}
}
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
//@> sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
//@> sstore(accountBalanceSlot, sub(accountBalance, value))
}
}

Risk

Likelihood:

  • Whenever a derived contract or caller uses mint(account, value) with very large value (e.g. near type(uint256).max), the total supply and balances wrap around rather than reverting, corrupting the token’s state.

  • Whenever burn(account, value) is called with value greater than the current balance or total supply, the subtraction wraps, causing the burned account and the total supply to become huge instead of reverting.

Impact:

  • Token accounting invariants are broken: _totalSupply no longer meaningfully reflects the sum of balances, and both can jump to extremely large or small values unexpectedly.

  • Attackers can exploit a misconfigured or permissionless burn/mint in derived contracts to create enormous balances or weird supply values, then use these manipulated balances to drain DeFi protocols integrating this token or to disrupt governance and accounting systems.


Proof of Concept

The following tests demonstrate:

  1. Mint overflow: totalSupply and balance wrap when minting above type(uint256).max.

  2. Burn underflow: burning more than the current balance underflows both totalSupply and the account balance to huge values.

You can paste these tests into Token.t.sol.

function test_mintOverflowWrapsSupplyAndBalance() public {
address account = makeAddr("overflowAccount");
// Mint up to max uint256
token.mint(account, type(uint256).max);
assertEq(token.balanceOf(account), type(uint256).max);
assertEq(token.totalSupply(), type(uint256).max);
// Mint 1 more token – this should revert in a safe implementation,
// but here it silently wraps totalSupply and balance to 0.
token.mint(account, 1);
uint256 finalBalance = token.balanceOf(account);
uint256 finalSupply = token.totalSupply();
// In a correct ERC20, we would expect a revert instead of these wrapped values.
assertEq(finalBalance, 0, "balance incorrectly wrapped to 0 after overflow");
assertEq(finalSupply, 0, "totalSupply incorrectly wrapped to 0 after overflow");
}
function test_burnUnderflowCreatesHugeBalance() public {
address account = makeAddr("underflowAccount");
// Give the account a small balance
token.mint(account, 1);
assertEq(token.balanceOf(account), 1);
uint256 initialSupply = token.totalSupply();
assertEq(initialSupply, 1);
// Burn more than the account balance and totalSupply.
// This should revert in a safe implementation, but here it underflows.
token.burn(account, 2);
uint256 finalBalance = token.balanceOf(account);
uint256 finalSupply = token.totalSupply();
// After underflow, both are huge values (mod 2^256 arithmetic).
assertGt(finalBalance, initialSupply, "balance underflow produced a huge value");
assertGt(finalSupply, initialSupply, "totalSupply underflow produced a huge value");
}

Recommended Mitigation

Use checked arithmetic semantics for _totalSupply and balances, either by:

  • Moving these operations out of assembly into Solidity (benefiting from 0.8.x checked arithmetic), or

  • Explicitly guarding for wraparound in Yul before sstore.

Conceptual diff (staying in assembly but adding explicit checks):

function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
...
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, add(supply, value))
+ let supply := sload(supplySlot)
+ let newSupply := add(supply, value)
+ // Check for overflow: newSupply must be >= supply
+ if lt(newSupply, supply) {
+ // revert or use a dedicated error selector
+ revert(0, 0)
+ }
+ sstore(supplySlot, newSupply)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
- let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, add(accountBalance, value))
+ let accountBalance := sload(accountBalanceSlot)
+ let newBalance := add(accountBalance, value)
+ if lt(newBalance, accountBalance) {
+ revert(0, 0)
+ }
+ sstore(accountBalanceSlot, newBalance)
}
}
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
...
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, sub(supply, value))
+ let supply := sload(supplySlot)
+ // Ensure supply >= value before subtraction
+ if lt(supply, value) {
+ revert(0, 0)
+ }
+ sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
- let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ let accountBalance := sload(accountBalanceSlot)
+ if lt(accountBalance, value) {
+ revert(0, 0)
+ }
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}

Support

FAQs

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

Give us feedback!