Token-0x

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

Integer underflow in _burn for total supply and account balance

Author Revealed upon completion

Description

  • In a safe ERC‑20, burning tokens should decrease total supply and the account’s balance without risk of underflow. In Solidity ≥0.8.x, standard arithmetic reverts on underflow automatically. When using Yul assembly, sub does not include automatic underflow checks, so explicit guards are required before subtracting.

  • In this codebase, _burn updates totalSupply and the account’s balance using raw Yul sub with no underflow checks. If value is greater than totalSupply (for supply) or greater than balance[account] (for the account), the subtraction wraps modulo 2^256, corrupting supply and balances.

// src/helpers/ERC20Internals.sol (excerpt)
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)) // @> BUG: unchecked subtraction (totalSupply)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, sub(accountBalance, value)) // @> BUG: unchecked subtraction (balance)
}
}

Risk

Likelihood: Medium

  • The test's Token.burn is public (no access control, no allowance checks), so any address can invoke it for any account with any value, making underflow trivial: call burn(account, value) where value > balanceOf(account) or value > totalSupply().

  • In real scenario, developers might assume that _burn function has the same protection against integer overflows as the modern Solidity.

Impact: High

  • Supply corruption: If value > totalSupply, the calculation wraps and may set total supply to a huge number (near 2^256-1), destroying invariants and breaking all downstream accounting.

  • Balance corruption / Funds misreporting: If value > balance[account], the account’s balance wraps to a very large value, enabling misrepresentation of funds and breaking DEXes, bridges, indexers, and protocols relying on accurate balances.

Proof of Concept

  • Create poc8.t.sol under test directory and copy the code below.

  • Run forge test --mp poc8 -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
// @notice PoC: Demonstrates underflow in _burn for totalSupply and account balance.
// The internal Yul code does unchecked subtractions:
// sstore(supplySlot, sub(supply, value))
// sstore(accountBalanceSlot, sub(accountBalance, value))
// When `value` exceeds current totals/balances, these subtractions wrap modulo 2^256.
contract BurnUnderflowTest is Test {
Token internal token;
address internal ALICE = address(0xA11CE);
address internal BOB = address(0xB0B);
function setUp() public {
token = new Token();
}
// @dev Case 1: Only the account balance underflows.
// - Mint 5 tokens to BOB (so totalSupply = 5, ALICE balance = 0).
// - Burn 1 from ALICE, who has 0 balance.
// * totalSupply: 5 - 1 = 4 (no underflow)
// * ALICE balance: 0 - 1 wraps to MAX_UINT
function test_burn_balance_underflow_only() public {
token.mint(BOB, 5);
assertEq(token.totalSupply(), 5, "pre: totalSupply should be 5");
assertEq(token.balanceOf(ALICE), 0, "pre: ALICE starts at 0");
// Burn causes ALICE's balance to underflow
token.burn(ALICE, 1);
// Supply decreases normally
assertEq(token.totalSupply(), 4, "post: totalSupply decreased by 1");
// Balance underflows: 0 - 1 == 2^256 - 1
assertEq(token.balanceOf(ALICE), type(uint256).max, "post: ALICE balance underflowed to MAX_UINT");
}
// @dev Case 2: Both totalSupply AND account balance underflow.
// - Mint 3 tokens to BOB (so totalSupply = 3, ALICE balance = 0).
// - Burn 5 from ALICE (value > totalSupply and > ALICE balance).
// * totalSupply: 3 - 5 wraps to 2^256 - (5 - 3) = MAX_UINT - 1
// * ALICE balance: 0 - 5 wraps to 2^256 - 5 = MAX_UINT - 4
function test_burn_supply_and_balance_underflow() public {
token.mint(BOB, 3);
assertEq(token.totalSupply(), 3, "pre: totalSupply should be 3");
assertEq(token.balanceOf(ALICE), 0, "pre: ALICE starts at 0");
// Burn causes BOTH supply and ALICE balance to underflow
token.burn(ALICE, 5);
uint256 expectedSupply = type(uint256).max - (5 - 3) + 1; // MAX - 1
uint256 expectedBalance = type(uint256).max - 5 + 1; // MAX - 4
assertEq(token.totalSupply(), expectedSupply, "post: totalSupply underflowed to MAX - 1");
assertEq(token.balanceOf(ALICE), expectedBalance, "post: ALICE balance underflowed to MAX - 4");
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/poc8.t.sol:BurnUnderflowTest
[PASS] test_burn_balance_underflow_only() (gas: 91049)
Traces:
[91049] BurnUnderflowTest::test_burn_balance_underflow_only()
├─ [45004] Token::mint(0x0000000000000000000000000000000000000B0b, 5)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 5
├─ [0] VM::assertEq(5, 5, "pre: totalSupply should be 5") [staticcall]
│ └─ ← [Return]
├─ [2720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "pre: ALICE starts at 0") [staticcall]
│ └─ ← [Return]
├─ [21170] Token::burn(0x00000000000000000000000000000000000A11cE, 1)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 4
├─ [0] VM::assertEq(4, 4, "post: totalSupply decreased by 1") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], "post: ALICE balance underflowed to MAX_UINT") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_burn_supply_and_balance_underflow() (gas: 91827)
Traces:
[91827] BurnUnderflowTest::test_burn_supply_and_balance_underflow()
├─ [45004] Token::mint(0x0000000000000000000000000000000000000B0b, 3)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 3
├─ [0] VM::assertEq(3, 3, "pre: totalSupply should be 3") [staticcall]
│ └─ ← [Return]
├─ [2720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "pre: ALICE starts at 0") [staticcall]
│ └─ ← [Return]
├─ [21170] Token::burn(0x00000000000000000000000000000000000A11cE, 5)
│ └─ ← [Stop]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77], "post: totalSupply underflowed to MAX - 1") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639931 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639931 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639931 [1.157e77], "post: ALICE balance underflowed to MAX - 4") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.47ms (734.60µs CPU time)
Ran 1 test suite in 18.84ms (1.47ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

  • Add explicit underflow checks in Yul before performing sub.

  • Or move arithmetic to Solidity where it’s auto‑checked.

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)
+ // Underflow check for totalSupply: require(supply >= value)
+ if lt(supply, value) { revert(0, 0) }
+ let newSupply := sub(supply, value)
+ sstore(supplySlot, newSupply)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+ // Underflow check for balance: require(accountBalance >= value)
+ if lt(accountBalance, value) { revert(0, 0) }
+ let newBalance := sub(accountBalance, value)
+ sstore(accountBalanceSlot, newBalance)
+ // (Recommended) Emit Transfer(account, address(0), value) for burn
+ // mstore(ptr, value)
+ // log3(ptr, 0x20, TRANSFER_TOPIC, account, 0x0)
}
}

Support

FAQs

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

Give us feedback!