Token-0x

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

Missing Balance Validation in _burn() Leads to Underflow and Storage Corruption

Author Revealed upon completion

Root + Impact

Description

The _burn() function performs state updates directly in Yul without applying Solidity’s built-in checked arithmetic. The function substracts value from both _totalSupply and the caller’s balance without verifying that the account holds at least value tokens.

  • Describe the normal behavior in one or more sentences

  • When accountBalance < value, the Yul-level sub(accountBalance, value) underflows and produces a very large 256-bit integer. This silently writes an invalid balance to storage, corrupting the accounting across the entire token system.

// Root cause in the codebase with @> marks to highlight the relevant section
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) //loading free memory pointer
let balanceSlot := _balances.slot //loading base slot of balances mapping
let supplySlot := _totalSupply.slot //loading total supply slot
let supply := sload(supplySlot) // loading current total supply
@> sstore(supplySlot, sub(supply, value)) // decreasing total supply
mstore(ptr, account) //store account at ptr
mstore(add(ptr, 0x20), balanceSlot) //storing balance base slot in next 32 bytes
let accountBalanceSlot := keccak256(ptr, 0x40) //calculating account balance slot
let accountBalance := sload(accountBalanceSlot) //loading current account balance
@> sstore(accountBalanceSlot, sub(accountBalance, value)) //updating account balance
}
}

Risk

Likelihood:

  • Any external or internal caller invoking _burn() with a balance lower than value will trigger the underflow.

This will occur whenever burn operations depend on user-controlled inputs, such as token redemption mechanisms or protocol-driven burns without preceding balance verification.

Impact:

  • Balance corruption: account balances may become extremely large values, breaking all supply-based invariants.

Total supply desynchronization: total supply is reduced even though the account did not have sufficient tokens to burn, further corrupting system-wide accounting.

  • Bypass of economic restrictions: an attacker could manipulate their balance to an arbitrarily high value and subsequently drain governance or reward mechanisms reliant on token holdings.

Proof of Concept

  • This test clearly shows shoows that the balance of the account underflows due to this issue.

function test_underflow() 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 ether); // should revert
balance = token.balanceOf(account);
console.log("balance after burn:", balance);
//assertEq(balance, 0);
//assertEq(token.totalSupply(), 0);
}
// Output for the test is
[PASS] test_underflow() (gas: 65174)
Logs:
balance after burn: 115792089237316195423570985008687907853269984665640564039456584007913129639936
Traces:
[65174] TokenTest::test_underflow()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]
├─ [0] VM::label(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], "account")
│ └─ ← [Return]
├─ [45004] Token::mint(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], 100000000000000000000 [1e20])
│ └─ ← [Stop]
├─ [720] Token::balanceOf(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [1270] Token::burn(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], 101000000000000000000 [1.01e20])
│ └─ ← [Stop]
├─ [720] Token::balanceOf(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039456584007913129639936 [1.157e77]
├─ [0] console::log("balance after burn:", 115792089237316195423570985008687907853269984665640564039456584007913129639936 [1.157e77]) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 21.53ms (9.08ms CPU time)

Recommended Mitigation

  • address(0) check is enforced

  • before burning of the tokens the balance of the user is check against the value parameter and reverts if value exceeds the balance

function _burn(address account, uint256 value) internal {
require(account != address(0), "Invalid burn address");
uint256 accountBalance = balanceOf(account);
require(accountBalance >= value, "Insufficient balance to burn");
assembly ("memory-safe") {
// safe to execute because invariants are enforced
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)
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}

Support

FAQs

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

Give us feedback!