Root + Impact
Description
Description: The _burn function does not verify that the account has sufficient balance before burning tokens. This allows burning more tokens than the account holds, which can lead to:
-
Underflow in balance calculation - While Solidity 0.8+ will revert on underflow, the explicit check should be present for clarity and to emit proper errors
-
Invariant violation - Breaks the critical ERC20 invariant: sum(userBalances) == totalSupply
-
State corruption - If balance underflows, the totalSupply will be reduced but the balance becomes corrupted
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))
}
}
Risk
Likelihood:
-
Direct call to the function with an incorrect amount.
-
Any access restrictions would not prevent this situation, since the internal function does not mitigate such an error.
Impact:
-
Users can burn more tokens than they own, corrupting the token's accounting
-
Protocols relying on the invariant sum(balances) == totalSupply will break
-
Total supply can become less than the sum of all balances
-
In Yul assembly, sub() will wrap around on underflow (not revert), potentially causing silent corruption
Proof of Concept
This test demonstrates the overflow in the `_burn` function.
function test_burnMoreThanBalance() public {
uint256 amount = 100e18;
address account = makeAddr("account");
token.mint(account, amount);
uint256 balance = token.balanceOf(account);
assertEq(balance, amount);
assertEq(token.totalSupply(), amount);
token.burn(account, amount + 1);
balance = token.balanceOf(account);
assertEq(balance, type(uint256).max);
}
Recommended Mitigation
Add a balance check in the _burn function before burning tokens to prevent underflow.
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)
+
+ // Load account balance first
+ mstore(ptr, account)
+ mstore(add(ptr, 0x20), balanceSlot)
+ let accountBalanceSlot := keccak256(ptr, 0x40)
+ let accountBalance := sload(accountBalanceSlot)
+
+ // Check balance is sufficient
+ if lt(accountBalance, value) {
+ mstore(0x00, shl(224, 0xe450d38c))
+ mstore(add(0x00, 4), account)
+ mstore(add(0x00, 0x24), accountBalance)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
+
+ let supply := sload(supplySlot)
+ sstore(supplySlot, sub(supply, value))
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}