Description
In a standard ERC20 implementation, the _burn function should verify that the account has sufficient balance before burning tokens. If the burn amount exceeds the account's balance, the function should revert with an appropriate error.
The _burn function in ERC20Internals.sol does not validate that the account has sufficient balance before performing the subtraction. Due to Solidity 0.8.x's unchecked behavior inside inline assembly blocks, the subtraction underflows silently, resulting in the account receiving an astronomically large balance (close to type(uint256).max).
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
-
Requires a derived contract to expose _burn functionality (as it's internal)
-
When exposed, any authorized caller can burn more than an account's balance
-
Admin/governance functions that call _burn may inadvertently trigger this
Impact: Critical
-
Account balance underflows to type(uint256).max - value + balance
-
Total supply also underflows, completely breaking token accounting
-
Attacker can gain near-infinite token balance
-
Token economics and all integrations are destroyed
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract BurnUnderflowExploit is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_Burn_Underflow() public {
address alice = makeAddr("alice");
token.mint(alice, 100e18);
assertEq(token.balanceOf(alice), 100e18);
token.burn(alice, 150e18);
uint256 balance = token.balanceOf(alice);
console.log("Balance after burning 150e18 from 100e18:", balance);
assertGt(balance, type(uint256).max - 100e18);
console.log("Total supply:", token.totalSupply());
}
}
Test Output:
[PASS] test_Burn_Underflow() (gas: 63260)
Logs:
Balance after burning 150e18 from 100e18: 115792089237316195423570985008687907853269984665640564039407584007913129639936
Recommended Mitigation
Add a balance check before performing the subtraction:
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)
+
+ // Check sufficient balance before burn
+ if lt(accountBalance, value) {
+ // ERC20InsufficientBalance(account, 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))
- mstore(ptr, account)
- mstore(add(ptr, 0x20), balanceSlot)
-
- let accountBalanceSlot := keccak256(ptr, 0x40)
- let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, sub(accountBalance, value))
+
+ // Emit Transfer event to address(0) per ERC20 standard
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, account, 0x00)
}
}