ERC20Internals._mint and ERC20Internals._burn perform additions and subtractions on _totalSupply and account balances directly in Yul using add/sub without any overflow/underflow checks. In assembly, these operations are always wrapping modulo 2^256.
Description
-
Normal behavior
In a robust ERC20 implementation, minting and burning adjust the total supply and balances without allowing arithmetic to overflow or underflow:
If these conditions are not met, the operation naturally reverts to keep the token state consistent.
-
Issue
In this implementation, both _mint and _burn use raw Yul arithmetic:
add(supply, value) and add(accountBalance, value) in _mint.
sub(supply, value) and sub(accountBalance, value) in _burn
These are unchecked and will wrap modulo 2^256 on overflow or underflow. There are no guards that ensure supply + value stays within range or that supply >= value / accountBalance >= value. As a result:
A large mint can cause _totalSupply and balances to wrap, potentially resetting to low values or rolling over.
A burn with value greater than supply or accountBalance will underflow and set them to huge values (close to type(uint256).max), effectively minting massive amounts of tokens instead of burning.
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)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
}
}
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)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
}
}
Risk
Likelihood:
Whenever a derived contract or caller uses mint(account, value) with very large value (e.g. near type(uint256).max), the total supply and balances wrap around rather than reverting, corrupting the token’s state.
Whenever burn(account, value) is called with value greater than the current balance or total supply, the subtraction wraps, causing the burned account and the total supply to become huge instead of reverting.
Impact:
Token accounting invariants are broken: _totalSupply no longer meaningfully reflects the sum of balances, and both can jump to extremely large or small values unexpectedly.
Attackers can exploit a misconfigured or permissionless burn/mint in derived contracts to create enormous balances or weird supply values, then use these manipulated balances to drain DeFi protocols integrating this token or to disrupt governance and accounting systems.
Proof of Concept
The following tests demonstrate:
Mint overflow: totalSupply and balance wrap when minting above type(uint256).max.
Burn underflow: burning more than the current balance underflows both totalSupply and the account balance to huge values.
You can paste these tests into Token.t.sol.
function test_mintOverflowWrapsSupplyAndBalance() public {
address account = makeAddr("overflowAccount");
token.mint(account, type(uint256).max);
assertEq(token.balanceOf(account), type(uint256).max);
assertEq(token.totalSupply(), type(uint256).max);
token.mint(account, 1);
uint256 finalBalance = token.balanceOf(account);
uint256 finalSupply = token.totalSupply();
assertEq(finalBalance, 0, "balance incorrectly wrapped to 0 after overflow");
assertEq(finalSupply, 0, "totalSupply incorrectly wrapped to 0 after overflow");
}
function test_burnUnderflowCreatesHugeBalance() public {
address account = makeAddr("underflowAccount");
token.mint(account, 1);
assertEq(token.balanceOf(account), 1);
uint256 initialSupply = token.totalSupply();
assertEq(initialSupply, 1);
token.burn(account, 2);
uint256 finalBalance = token.balanceOf(account);
uint256 finalSupply = token.totalSupply();
assertGt(finalBalance, initialSupply, "balance underflow produced a huge value");
assertGt(finalSupply, initialSupply, "totalSupply underflow produced a huge value");
}
Recommended Mitigation
Use checked arithmetic semantics for _totalSupply and balances, either by:
Moving these operations out of assembly into Solidity (benefiting from 0.8.x checked arithmetic), or
Explicitly guarding for wraparound in Yul before sstore.
Conceptual diff (staying in assembly but adding explicit checks):
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
...
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, add(supply, value))
+ let supply := sload(supplySlot)
+ let newSupply := add(supply, value)
+ // Check for overflow: newSupply must be >= supply
+ if lt(newSupply, supply) {
+ // revert or use a dedicated error selector
+ revert(0, 0)
+ }
+ 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 accountBalance := sload(accountBalanceSlot)
+ let newBalance := add(accountBalance, value)
+ if lt(newBalance, accountBalance) {
+ revert(0, 0)
+ }
+ sstore(accountBalanceSlot, newBalance)
}
}
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
...
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, sub(supply, value))
+ let supply := sload(supplySlot)
+ // Ensure supply >= value before subtraction
+ 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))
+ let accountBalance := sload(accountBalanceSlot)
+ if lt(accountBalance, value) {
+ revert(0, 0)
+ }
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}