Token-0x

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

unchecked underflow in _burn lets anyone with public burn corrupt supply/balances

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences:

    A correct ERC20 burn implementation reduces an account's balance and totalSupply only when the account has at least value tokens and totalSupply is at least value. If those conditions are not met, the function reverts and leaves state unchanged.

  • Explain the specific issue or problem in one or more sentences:

    In this implementation, _burn uses unchecked subtraction on both totalSupply and the account balance. There is no check that the account has enough balance and no underflow guard. When value exceeds the account's balance or the current totalSupply, both values underflow and wrap to very large numbers instead of reverting, corrupting core ERC20 accounting.

// Root cause in the codebase with @> marks to highlight the relevant section
// src/helpers/ERC20Internals.sol
function _burn(address from, uint256 value) internal {
assembly {
// load totalSupply and balance
// @> let oldSupply := sload(totalSupplySlot)
// @> let newSupply := sub(oldSupply, value) // unchecked, can underflow
// @> sstore(totalSupplySlot, newSupply)
// @> let oldBalance := sload(balanceSlot)
// @> let newBalance := sub(oldBalance, value) // unchecked, can underflow
// @> sstore(balanceSlot, newBalance)
// no require/assert checks before or after subtraction
}
}

Risk

Likelihood:

  • Reason 1 // In common real deployments, burn is exposed to holders or to roles that are not perfectly locked down. Whenever a caller with access to burn passes an amount larger than their balance, the unchecked subtraction path is reached and underflow occurs.

  • Reason 2 // Even where burn is intended for internal use only, role misconfiguration and access leaks are frequent in real systems. Once any such caller can trigger burn with an excessive amount, the underflow always happens, because there is no defensive check in the implementation.

Impact:

  • Impact 1 : An attacker can underflow their balance, causing it to wrap to a huge value (near 2^256 - 1), effectively granting themselves an enormous number of tokens. This enables draining of DEX liquidity pools, manipulation of governance, and other forms of value theft.

  • Impact 2 : totalSupply also underflows and becomes incorrect. All downstream integrations that depend on sane supply and balances, such as lending markets, accounting tools, and analytics, will operate on corrupted data and may break or misbehave permanently.

Proof of Concept

The PoC shows that burning more tokens than owned leads to a wrapped balance and totalSupply instead of reverting, allowing the attacker to inflate their balance and corrupt core ERC20 accounting.

function test_burnUnderflow() public {
address attacker = address(0xBEEF);
// Attacker first obtains a small balance
token.mint(attacker, 1);
// Attacker then calls burn with an amount greater than their balance
vm.prank(attacker);
token.burn(attacker, 2); // Expected behavior: revert. Actual behavior: underflow and wrap.
// After underflow, both balance and totalSupply are corrupted.
// These assertions will pass on the vulnerable implementation.
assertGt(token.balanceOf(attacker), 1); // effectively a huge wrapped value
assertGt(token.totalSupply(), 1); // also wrapped
}

Recommended Mitigation

If you must keep the Yul implementation, add equivalent require style checks before performing subtraction and revert on underflow instead of allowing wrapped values to be written back to storage.

function _burn(address from, uint256 value) internal {
- assembly {
- // load totalSupply and balance
- let oldSupply := sload(totalSupplySlot)
- let newSupply := sub(oldSupply, value) // unchecked, can underflow
- sstore(totalSupplySlot, newSupply)
-
- let oldBalance := sload(balanceSlot)
- let newBalance := sub(oldBalance, value) // unchecked, can underflow
- sstore(balanceSlot, newBalance)
- }
+ // High level example: enforce bounds before updating storage
+ require(from != address(0), "ERC20: burn from the zero address");
+
+ uint256 fromBalance = _balances[from];
+ require(fromBalance >= value, "ERC20: burn amount exceeds balance");
+ _balances[from] = fromBalance - value;
+
+ require(_totalSupply >= value, "ERC20: burn exceeds totalSupply");
+ _totalSupply -= value;
+
+ emit Transfer(from, address(0), value);
}

Support

FAQs

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

Give us feedback!