Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: high

Missing Balance Check in _burn() Allows Unlimited Token Creation via Underflow

Author Revealed upon completion

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)) // @> Unchecked subtraction causes totalSupply underflow
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, sub(accountBalance, value)) // @> Underflow here creates huge fake balance
}
}

Risk

Likelihood

  1. The function is callable by any internal or external wrapper .

  2. The vulnerability activates whenever the burn amount exceeds the actual balance.

  3. No internal invariant prevents underflow.

Impact:

  1. The attacker can give themselves a massive number of tokens nearly equal to 2^256

  2. Total supply becomes corrupted or wrapped.

  3. 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 {
// Do not mint anything → balance = 0
vm.recordLogs();
// Burn more than balance → underflow
token.burnPublic(address(this), 200);
uint256 newBalance = token.balanceOf(address(this));
// Expect gigantic underflowed value
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))
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!