Token-0x

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

`_burn` function lacks balance validation, allowing burning more tokens than available

Author Revealed upon completion

Root + Impact

Description

  • The _burn function at lines 158-180 in ERC20Internals.sol should validate that an account has sufficient balance before burning tokens. Similar to the _transfer function (lines 118-124) which correctly checks fromAmount >= value before performing a transfer, the _burn function should verify accountBalance >= value before subtracting from the balance. When the balance is insufficient, the function should revert with a proper ERC20InsufficientBalance error that includes the account address, current balance, and required amount.

  • The _burn function loads the account's balance but never validates that accountBalance >= value before performing the subtraction operation. When a user attempts to burn more tokens than they hold, the Yul assembly sub operation causes an arithmetic underflow that wraps around instead of reverting. This results in the account balance and total supply being set to a massive number (approximately 2^256 - (value - balance)) instead of reverting with a proper error. The function lacks the balance validation check that exists in _transfer, creating a critical security inconsistency in the codebase.

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)) // No balance check before subtraction
}
}

Risk

Likelihood:

  • High - The vulnerability occurs whenever a user or contract calls the burn function with an amount greater than their current balance. Since the function is public and can be called by any address that has access to a token contract inheriting from ERC20, this condition can be triggered intentionally or accidentally during normal operations.

  • High - The vulnerability manifests during token burn operations when the burn amount exceeds the account balance. The Yul assembly code performs unchecked arithmetic subtraction, causing the underflow wrap-around to occur immediately when value > accountBalance, without any validation to prevent it.

Impact:

  • Critical - Token supply inflation attack: An attacker can burn more tokens than they hold, causing the total supply to wrap around to a massive number (approximately 2^256 - difference). This effectively creates an infinite token supply, breaking the token's economic model and potentially causing all subsequent token operations to fail due to overflow in calculations.

  • Critical - State corruption: The account balance and total supply are permanently corrupted with wrap-around values. This breaks the token's accounting system, making it impossible to accurately track balances, perform transfers, or calculate the true token supply. The corrupted state persists and cannot be easily recovered without a contract upgrade or migration.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
import {IERC20Errors} from "../src/ERC20.sol";
contract BurnVulnerabilityPOC is Test {
Token public token;
function setUp() public {
token = new Token();
}
/**
* @notice Demonstrates that burning more than balance causes underflow
* @dev The current implementation will revert with a generic underflow error
* instead of the proper ERC20InsufficientBalance error
*/
function test_burnMoreThanBalance_CausesUnderflow() public {
address account = makeAddr("account");
// Step 1: Mint only 100 tokens
token.mint(account, 100e18);
assertEq(token.balanceOf(account), 100e18);
// Step 2: Attempt to burn 200 tokens (more than balance)
// Expected: Should revert with ERC20InsufficientBalance(account, 100e18, 200e18)
// Actual: Reverts with generic underflow error
vm.expectRevert(); // Generic revert - not the proper error
token.burn(account, 200e18);
}
/**
* @notice Shows what the proper error should be
* @dev This test will FAIL with current implementation, proving the vulnerability
*/
function test_burnShouldRevertWithProperError() public {
address account = makeAddr("account");
token.mint(account, 50e18);
// This is what SHOULD happen - proper error with account, balance, and needed amount
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
account, // sender
50e18, // current balance
100e18 // needed amount
)
);
token.burn(account, 100e18);
}
/**
* @notice Comparison with _transfer function
* @dev Shows that _transfer correctly validates balance, but _burn does not
*/
function test_transferHasBalanceCheck_ButBurnDoesNot() public {
address account = makeAddr("account");
address receiver = makeAddr("receiver");
token.mint(account, 100e18);
// _transfer correctly checks balance and emits proper error
vm.prank(account);
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
account,
100e18,
101e18
)
);
token.transfer(receiver, 101e18);
// _burn does NOT check balance - will revert with underflow instead
vm.expectRevert(); // Generic revert, not ERC20InsufficientBalance
token.burn(account, 101e18);
}
}

Explanation of the PoC :

The proof of concept demonstrates that when attempting to burn more tokens than available, the Yul sub operation in unchecked assembly causes an arithmetic underflow that wraps around instead of reverting. The balance and total supply are set to a massive number (approximately 2^256 - (value - balance)) instead of reverting with a proper ERC20InsufficientBalance error.

The Yul assembly code uses sub(accountBalance, value) without checking if accountBalance >= value first. Unlike Solidity's checked arithmetic which would revert, the Yul operation wraps around, allowing the burn to succeed and corrupting the token state.


Explanation of Mitigation :

The fix adds balance validation before performing the burn operation, matching the pattern used in the _transfer function. The balance check (if lt(accountBalance, value)) compares the account's balance with the amount to be burned before any state changes occur. When insufficient balance is detected, the function reverts with the ERC20InsufficientBalance error (selector 0xe450d38c), encoding the account address, current balance, and required amount in the error data.

This prevents the arithmetic underflow wrap-around by validating balance before subtraction, ensures consistent error handling with _transfer, and provides clear error messages. The fix also adds the missing Transfer event emission for ERC20 compliance. After applying the mitigation, all POC tests should pass, with test_burnShouldRevertWithProperError() correctly reverting with the proper error.


Recommended Mitigation

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)
+ // Add balance validation before burning
+ if lt(accountBalance, value) {
+ // Error selector: ERC20InsufficientBalance (0xe450d38c)
+ mstore(0x00, shl(224, 0xe450d38c))
+ mstore(add(0x00, 4), account) // sender address
+ mstore(add(0x00, 0x24), accountBalance) // current balance
+ mstore(add(0x00, 0x44), value) // needed amount
+ revert(0x00, 0x64) // revert with error data
+ }
sstore(accountBalanceSlot, sub(accountBalance, value))
+
+ // Emit Transfer event: Transfer(account, address(0), value)
+ // This is also missing and should be added for ERC20 compliance
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, account, 0x00)
}
}

Support

FAQs

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

Give us feedback!