Token-0x

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

_burn() Underflow Vulnerability

Author Revealed upon completion

Description

The _burn function is responsible for reducing an account’s balance and decreasing the total token supply. In a correct ERC-20 implementation, burning more tokens than the account owns must revert to protect supply invariants. Since Solidity 0.8.x automatically reverts on underflow, this is normally enforced automatically.

However, this contract implements _burn entirely in Yul, and Yul arithmetic does not revert on underflow. The function performs:

sstore(supplySlot, sub(supply, value));
sstore(accountBalanceSlot, sub(accountBalance, value));

without validating that supply >= value or accountBalance >= value.
This causes sub() to wrap around to a very large uint256 value — effectively inflating balances and total supply to near-maximum.

This breaks all ERC-20 invariants and enables indirect infinite mint-like behavior.


Risk

Likelihood:

  • The _burn function is internal, but can be reached by any external function in derived contracts (e.g., a public burn() wrapper, token upgrade, staking/vesting contract, or governance extension using burn logic).

  • The function performs unchecked Yul arithmetic, meaning any call path that forwards an amount larger than the balance will silently underflow rather than revert.

  • Many developers assume Solidity 0.8.x protects against underflow, making the issue easy to overlook during integration.

Impact:

  • Total supply can jump to extremely large values, breaking all accounting and enabling catastrophic inflation.

  • Account balances can wrap to near-2^256-1, giving the attacker a massive spendable balance, effectively minting unlimited tokens.


Proof of Concept

Below is a minimal Foundry test that demonstrates the underflow on your current implementation:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/helpers/ERC20Internals.sol";
contract BurnUnderflowTest is Test {
ERC20Harness token;
address alice = address(0xA11CE);
function setUp() public {
token = new ERC20Harness();
token.exposed_mint(alice, 10);
}
function test_BurnUnderflow() public {
uint256 beforeBalance = token.exposed_balanceOf(alice);
uint256 beforeSupply = token.exposed_totalSupply();
uint256 burnAmount = beforeBalance + 1;
vm.prank(alice);
token.exposed_burn(alice, burnAmount);
uint256 afterBalance = token.exposed_balanceOf(alice);
uint256 afterSupply = token.exposed_totalSupply();
assertGt(afterBalance, beforeBalance);
assertGt(afterSupply, beforeSupply);
}
}
/// @dev Exposes internal-only functions so we can test them
contract ERC20Harness is ERC20Internals {
function exposed_mint(address a, uint256 b) external {
_mint(a, b);
}
function exposed_burn(address a, uint256 b) external {
_burn(a, b);
}
function exposed_balanceOf(address a) external view returns (uint256) {
return _balanceOf(a);
}
function exposed_totalSupply() external view returns (uint256) {
return totalSupply_();
}
}

Running this test shows the underflow exploit clearly:
afterBalance and afterSupply become extremely large values.


Recommended Mitigation

Add explicit balance and supply checks before performing subtraction in Yul, and emit the appropriate Transfer event:

function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
// existing zero-address revert
...
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
- // vulnerable: no checks
- sstore(supplySlot, sub(supply, value))
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ // FIX: ensure both supply and account balance are sufficient
+ if lt(accountBalance, value) {
+ revert(0, 0)
+ }
+ if lt(supply, value) {
+ revert(0, 0)
+ }
+
+ // safe subtraction
+ sstore(supplySlot, sub(supply, value))
+ sstore(accountBalanceSlot, sub(accountBalance, value))
+
+ // emit Transfer(account, address(0), value)
+ mstore(ptr, value)
+ log3(ptr, 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,
+ account, 0
+ )
}
}

Support

FAQs

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

Give us feedback!