Root + Impact
Description
The normal behavior of _mint, _burn, and _transfer functions in Solidity 0.8+ is to automatically revert when an arithmetic operation causes an overflow or underflow (exceeding type(uint256).max).
These functions use assembly code with the add/sub opcodes which bypass Solidity 0.8+'s built-in overflow/underflow protections. This allows totalSupply and user balances to silently wrap around to zero on overflow or underflow 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))
}
}
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
}
sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value))
success := 1
}
}
Risk
Likelihood:
The mint or burn function is public and can be called by anyone in the Token contract
A token with high supply (close to type(uint256).max) will trigger overflow on the next mint (same for burn)
The transfer function is public and can be called by any token holder. A recipient with a balance close to type(uint256).max or 0 receiving/giving additional tokens will trigger an overflow/underflow
Impact:
The totalSupply can be reset to a value close to zero, breaking the invariant sum(balances) == totalSupply
Users can lose visibility on their actual tokens since balanceOf returns an incorrect value after overflow
DeFi protocols integrating this token may miscalculate ratios and prices
In _transfer, the recipient's balance can wrap around to a small value, causing loss of funds
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract MintBurnTransferOverflowUnderflowTest is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_totalSupplyOverflow() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
token.mint(alice, type(uint256).max);
token.mint(bob, 1);
assertEq(token.totalSupply(), 0);
}
function test_balanceOverflow() public {
address alice = makeAddr("alice");
token.mint(alice, type(uint256).max);
token.mint(alice, 1);
assertEq(token.balanceOf(alice), 0);
}
function test_totalSupplyUnderflow() public {
address alice = makeAddr("alice");
token.mint(alice, 100);
token.burn(alice, 101);
assertEq(token.totalSupply(), type(uint256).max);
}
function test_balanceUnderflow() public {
address alice = makeAddr("alice");
token.mint(alice, 100);
token.burn(alice, 101);
assertEq(token.balanceOf(alice), type(uint256).max);
}
function test_transferRecipientBalanceOverflow() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
token.mint(bob, type(uint256).max);
token.mint(alice, 100);
vm.prank(alice);
token.transfer(bob, 1);
assertEq(token.balanceOf(bob), 0);
assertEq(token.balanceOf(alice), 99);
}
}
when we run the code we have:
forge test --match-contract MintBurnTransferOverflowUnderflowTest -vvv
Ran 5 tests for test/MintOverflowVulnerability.t.sol:MintBurnTransferOverflowUnderflowTest
[PASS] test_balanceOverflow() (gas: 59501)
[PASS] test_balanceUnderflow() (gas: 59922)
[PASS] test_totalSupplyOverflow() (gas: 63052)
[PASS] test_totalSupplyUnderflow() (gas: 59114)
[PASS] test_transferRecipientBalanceOverflow() (gas: 71234)
Suite result: ok. 5 passed; 0 failed; 0 skipped
Recommended Mitigation
Add overflow/underflow checks before storing the new values. In assembly, arithmetic safety must be handled manually since the add and sub opcodes do not revert on overflow/underflow like Solidity 0.8+ does by default.
For overflow detection (addition): Check if the result is less than one of the original operands. If a + b < a, an overflow occurred.
For underflow detection (subtraction): Check if the value being subtracted is greater than the original value. If value > balance, an underflow would occur.
Apply these checks to all three functions:
_mint: Add overflow checks for both totalSupply and accountBalance additions
_burn: Add underflow checks for both totalSupply and accountBalance subtractions
_transfer: Add overflow check for the recipient's balance addition (the sender's underflow is already checked via lt(fromAmount, value))
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))
+ let newSupply := add(supply, value)
+ if lt(newSupply, supply) { revert(0, 0) } // Overflow check
+ 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) { revert(0, 0) } // Overflow check
+ sstore(accountBalanceSlot, newBalance)
}
}
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) } // Underflow check
- sstore(supplySlot, sub(supply, value))
+ sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+ if lt(accountBalance, value) { revert(0, 0) } // Underflow check
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... zero address checks ...
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
// revert ERC20InsufficientBalance
}
sstore(fromSlot, sub(fromAmount, value))
- sstore(toSlot, add(toAmount, value))
+ let newToAmount := add(toAmount, value)
+ if lt(newToAmount, toAmount) { revert(0, 0) } // Overflow check
+ sstore(toSlot, newToAmount)
success := 1
// ... emit Transfer event ...
}
}