Description
ERC20 implementations should guard totalSupply against overflow or underflow in when minting and burning.
This is left unguarded both_mint and _burn. Since arithmetic is done in inline assembly, add and sub will wrap on overflow, allowing totalSupply to roll from type(uint256).max to 0 and from 0 to type(uint256).max.
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: Medium
This issue occurs when an inheriting contract relies on the ERC20 implementation to prevent totalSupply overflows and underflows. Inheriting contracts can implement their own checks, but many will assume this is handled internally, as it is in ERC20 implementations from OpenZeppelin, Solady, etc.
Reaching type(uint256).max is unlikely in practice, but underflow is much more possible whenever a caller executes _burn with value > totalSupply and there is no higher-level guard.
Impact: High
DeFi protocols rely on the internal accounting of ERC20s. Any overflow or underflow of totalSupply could severly affect protocols that utilize governance tokens, interest-bearing tokens, or tokenized vaults (ERC4626), as well as any contract or off-chain indexers that read and use the totalSupply.
Proof of Concept
Add the following test to the test suite in test/Token.t.sol.
function testNoOverflowOrUnderflowChecksForTotalSupplyInBurnOrMint() public {
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
token.mint(user1, type(uint256).max);
token.mint(user2, 1);
assert(token.totalSupply() == 0);
token.burn(user2, 1);
assert(token.totalSupply() == type(uint256).max);
}
This test shows that the token's totalSupply will overflow back to 0 after reaching type(uint256).max, and the token's totalSupply will also underflow to type(uint256).max after subtracting past 0.
Recommended Mitigation
Checks should be added to prevent overflows and underflows. Preventing overflows of totalSupply is also important to protect individual balances from overflowing.
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)
+ let newSupply := add(supply, value)
+ if lt(newSupply, supply) {
+ revert(0,0)
+ }
- sstore(supplySlot, add(supply, value))
+ 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))
}
}
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)
+ 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))
}
}
Make sure to replace both instances of revert(0, 0) with a custom error to align with the rest of the error handling in the contract.