Root Cause :
The _mintfunction uses inline assembly to update both _totalSupply and the user's _balances using the add opcode without validating that the result does not overflow type(uint256).max.
Impact :
Overflow causes the totalSupply and the user's balance to wrap around to a small number. This completely destroys the token's accounting invariants, allowing the creation of "phantom" tokens or erasing the total supply record while tokens still exist in circulation.
Description
-
In _mint, the state updates are performed using raw assembly:
sstore(supplySlot, add(supply, value))
sstore(accountBalanceSlot, add(accountBalance, value))
-
In Solidity 0.8+ high-level arithmetic, supply + value would revert on overflow, but here the inline assembly add silently wraps modulo 22562256.
If supply or accountBalance are close to type(uint256).max, minting more tokens causes:
totalSupply to wrap to a small value (e.g., 99)
the account’s balance to wrap to the same small value
This breaks ERC‑20 accounting invariants and makes total supply and balances untrustworthy.
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
let supply := sload(supplySlot)
@> sstore(supplySlot, add(supply, value))
let accountBalance := sload(accountBalanceSlot)
@> sstore(accountBalanceSlot, add(accountBalance, value))
}
}
Risk
Likelihood:
-
High in any system where _mint can be called repeatedly (staking rewards, farming, airdrops, admin mint, etc.).
-
No special conditions besides being able to accumulate large balances and supply over time.
Impact:
-
Corrupted totalSupply: Global supply shows a tiny number while many tokens exist in circulation.
-
Corrupted balances: A large holder’s balance can wrap down to a small value, breaking all accounting based on i
-
DeFi integrations at risk: Protocols that use totalSupply in pricing (e.g., pool reserves / supply), lending ratios, or risk models can be manipulated or broken once supply is no longer reliable.
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {ERC20} from "../src/ERC20.sol";
contract ERC20Mock is ERC20 {
constructor() ERC20("Test", "TST") {}
function mint(address to, uint256 amount) public { _mint(to, amount); }
}
contract MintOverflowTest is Test {
ERC20Mock public token;
address public alice = makeAddr("alice");
function setUp() public {
token = new ERC20Mock();
}
function test_mint_overflow() public {
uint256 initialSupply = type(uint256).max - 100;
token.mint(alice, initialSupply);
console.log("Supply Before:", token.totalSupply());
token.mint(alice, 200);
uint256 supplyAfter = token.totalSupply();
uint256 balanceAfter = token.balanceOf(alice);
console.log("Supply After :", supplyAfter);
console.log("Balance After:", balanceAfter);
assertLt(supplyAfter, initialSupply);
assertEq(supplyAfter, 99);
}
}
Run:
forge test --match-test test_mint_overflow -vvvv
Output:
[PASS] test_mint_overflow() (gas: 64297)
Logs:
Supply Before: 115792089237316195423570985008687907853269984665640564039457584007913129639835
Supply After : 99
Balance After: 99
Recommended Mitigation
let supply := sload(supplySlot)
- sstore(supplySlot, add(supply, value))
+ let newSupply := add(supply, value)
+ if lt(newSupply, supply) {
+ mstore(0x00, shl(224, 0x4e487b71)) // Panic(uint256)
+ mstore(add(0x00, 4), 0x11) // Code 0x11 (Overflow)
+ revert(0x00, 0x24)
+ }
+ 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 newBalance := add(accountBalance, value)
+ if lt(newBalance, accountBalance) {
+ mstore(0x00, shl(224, 0x4e487b71)) // Panic(uint256)
+ mstore(add(0x00, 4), 0x11) // Code 0x11 (Overflow)
+ revert(0x00, 0x24)
+ }
+ sstore(accountBalanceSlot, newBalance)