Root + Impact
Description
-
The ERC20 standard requires that burn operations include underflow protection to prevent balances from wrapping around when burning more than the available balance. The _burn() function in Token-0x's base implementation performs unchecked subtractions for both totalSupply and user balances, allowing balances to wrap to type(uint256).max when underflow occurs.
-
The _transfer() function correctly checks for insufficient balances before subtraction, demonstrating that underflow protection was intentionally omitted in _burn().
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:
-
Any derived contract that allows burning can trigger this when burning more than available balance
-
Malicious actors can deliberately burn to cause underflow conditions
-
Normal operations can accidentally trigger underflow with incorrect burn amounts
Impact:
-
Balance underflow creates maximum uint256 balance, allowing unlimited token transfers
-
Total supply underflow breaks economic model and token valuation
-
Complete loss of token value and protocol functionality
Proof of Concept
The test demonstrates that burning 1 token from a zero balance causes the balance to wrap to type(uint256).max, bypassing any underflow protection that should exist in a secure ERC20 implementation.
contract VulnerableToken is ERC20 {
constructor() ERC20("Vuln", "V") {}
function burn(address from, uint256 amount) external { _burn(from, amount); }
}
function test_BurnFromZeroCreatesMaxBalance() public {
VulnerableToken baseToken = new VulnerableToken();
address victim = makeAddr("victim");
assertEq(baseToken.balanceOf(victim), 0);
baseToken.burn(victim, 1);
assertEq(baseToken.balanceOf(victim), type(uint256).max);
}
Recommended Mitigation
Add underflow checks before performing subtractions in the _burn() function. The fix should validate that the balance is sufficient before burning, preventing underflow conditions.
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
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+ // FIX: Check balance before burning
+ if lt(accountBalance, value) {
+ mstore(0x00, shl(224, 0xe450d38c)) // InsufficientBalance error
+ mstore(add(0x00, 4), account)
+ mstore(add(0x00, 0x24), accountBalance)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
let supply := sload(supplySlot)
+ // FIX: Check supply underflow (defensive, should never happen if balance check passed)
+ if lt(supply, value) {
+ mstore(0x00, shl(224, 0x35278d12)) // Overflow/underflow error
+ revert(0x00, 0x04)
+ }
+
sstore(supplySlot, sub(supply, value))
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}