Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: high
Valid

Integer overflow in _mint for total supply and account balance

Description

  • In a safe ERC‑20, minting should increase total supply and the recipient’s balance without risk of overflow. In Solidity ≥0.8.x, arithmetic in standard Solidity code reverts on overflow automatically. When using Yul assembly, add/sub do not have built‑in overflow checks, so explicit guards are required.

  • In this codebase, _mint updates totalSupply and the recipient’s balance using raw Yul add with no overflow checks. If either totalSupply + value or balance[account] + value exceeds type(uint256).max, the value will silently wrap (mod 2^256), corrupting supply and balances.

// src/helpers/ERC20Internals.sol (excerpt)
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)) // @> BUG: unchecked addition of totalSupply
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, add(accountBalance, value)) // @> BUG: unchecked addition of balance
}
}

Risk

Likelihood: Low

  • Mint is a common operation in many tokens (distributions, rewards, bridges).

  • This project’s Token.mint is public (unrestricted), making it trivial for anyone to repeatedly mint until either the total supply or an account balance reaches an overflow boundary.

Impact: High

  • Supply corruption: totalSupply can wrap to a small number, breaking core invariants and all downstream accounting assumptions.

  • Balance corruption: Recipient balances can wrap, enabling misreporting of funds and causing severe inconsistencies for DEXes, bridges, indexers, and protocols relying on accurate balances.

Proof of Concept

  • Create poc7.t.sol under test directory and copy the code below.

  • Run forge test --mp poc7 -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
// @notice PoC: Demonstrates overflow in _mint for both totalSupply and account balance.
// The internal Yul code does unchecked additions:
// sstore(supplySlot, add(supply, value))
// sstore(accountBalanceSlot, add(accountBalance, value))
// When values approach type(uint256).max, these additions wrap modulo 2^256.
contract MintOverflowTest is Test {
Token internal token;
address internal ALICE = address(0xA11CE);
function setUp() public {
token = new Token();
}
// @dev Overflow in totalSupply and recipient balance:
// - First mint sets both totalSupply and ALICE's balance close to MAX_UINT (MAX - 10).
// - Second mint of 20 causes wrap-around for both totalSupply and ALICE's balance.
// Math: (MAX - 10) + 20 = 2^256 + 9 -> 9 after modulo 2^256.
function test_mint_overflows_totalSupply_and_balance() public {
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
uint256 expectedWrap = 9; // (MAX - 10) + 20 wraps to 9
// 1) Mint near-max amount to ALICE
token.mint(ALICE, nearMax);
// Sanity: totals and balances reflect near-max
assertEq(token.totalSupply(), nearMax, "totalSupply should be near MAX_UINT");
assertEq(token.balanceOf(ALICE), nearMax, "ALICE balance should be near MAX_UINT");
// 2) Mint again to trigger overflow
token.mint(ALICE, value);
// 3) Observe wrap-around due to unchecked addition in _mint
assertEq(token.totalSupply(), expectedWrap, "totalSupply wrapped due to unchecked add");
assertEq(token.balanceOf(ALICE), expectedWrap, "ALICE balance wrapped due to unchecked add");
}
// @dev Additional check: repeating small mints from a wrapped state behaves normally,
// but shows the accounting is already corrupted.
function test_followup_mints_after_wrap_change_values_but_state_is_corrupted() public {
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
// Cause the overflow
token.mint(ALICE, nearMax);
token.mint(ALICE, value);
// Now mint a small amount again; it "works" but from a corrupted baseline
token.mint(ALICE, 3);
assertEq(token.totalSupply(), 12, "wrapped supply (9) + 3 => 12");
assertEq(token.balanceOf(ALICE), 12, "wrapped balance (9) + 3 => 12");
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/poc7.t.sol:MintOverflowTest
[PASS] test_followup_mints_after_wrap_change_values_but_state_is_corrupted() (gas: 64532)
Traces:
[64532] MintOverflowTest::test_followup_mints_after_wrap_change_values_but_state_is_corrupted()
├─ [45004] Token::mint(0x00000000000000000000000000000000000A11cE, 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77])
│ └─ ← [Stop]
├─ [1204] Token::mint(0x00000000000000000000000000000000000A11cE, 20)
│ └─ ← [Stop]
├─ [1204] Token::mint(0x00000000000000000000000000000000000A11cE, 3)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 12
├─ [0] VM::assertEq(12, 12, "wrapped supply (9) + 3 => 12") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 12
├─ [0] VM::assertEq(12, 12, "wrapped balance (9) + 3 => 12") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_mint_overflows_totalSupply_and_balance() (gas: 67113)
Traces:
[67113] MintOverflowTest::test_mint_overflows_totalSupply_and_balance()
├─ [45004] Token::mint(0x00000000000000000000000000000000000A11cE, 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77])
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], "totalSupply should be near MAX_UINT") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], "ALICE balance should be near MAX_UINT") [staticcall]
│ └─ ← [Return]
├─ [1204] Token::mint(0x00000000000000000000000000000000000A11cE, 20)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 9
├─ [0] VM::assertEq(9, 9, "totalSupply wrapped due to unchecked add") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 9
├─ [0] VM::assertEq(9, 9, "ALICE balance wrapped due to unchecked add") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.27ms (429.60µs CPU time)
Ran 1 test suite in 13.66ms (1.27ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

  • Guard the additions in Yul.

  • Or move arithmetic to Solidity where overflow is checked automatically.

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)
+ // Check: newSupply = supply + value; overflow if newSupply < supply
+ let newSupply := add(supply, value)
+ if lt(newSupply, supply) {
+ // Use a custom error or revert consistently with your style
+ revert(0, 0)
+ }
+ sstore(supplySlot, newSupply)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+ // Check: newBalance = accountBalance + value; overflow if newBalance < accountBalance
+ let newBalance := add(accountBalance, value)
+ if lt(newBalance, accountBalance) {
+ revert(0, 0)
+ }
+ sstore(accountBalanceSlot, newBalance)
+ // (Optional but recommended) Emit Transfer(0x0, account, value) for mint
+ // mstore(ptr, value)
+ // log3(ptr, 0x20, TRANSFER_TOPIC, 0x0, account)
}
}
Updates

Lead Judging Commences

gaurangbrdv Lead Judge 18 days ago
Submission Judgement Published
Validated
Assigned finding tags:

overflow & underflow

missing checks for overflow and underflow.

Support

FAQs

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

Give us feedback!