Token-0x

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

No Overflow Check in _mint and _burn

Author Revealed upon completion

Root + Impact

Description

  • The normal behavior of _mint, _burn, and _transfer functions in Solidity 0.8+ is to automatically revert when an arithmetic operation causes an overflow or underflow (exceeding type(uint256).max).

  • These functions use assembly code with the add/sub opcodes which bypass Solidity 0.8+'s built-in overflow/underflow protections. This allows totalSupply and user balances to silently wrap around to zero on overflow or underflow to type(uint256).max

function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0xec442f05))
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, add(supply, value)) // No overflow check
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
@> sstore(accountBalanceSlot, add(accountBalance, value)) // No overflow check
}
}
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)) // No underflow check
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
@> sstore(accountBalanceSlot, sub(accountBalance, value)) // No underflow check
}
}
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... zero address checks ...
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
// revert ERC20InsufficientBalance
}
sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value)) // No overflow check
success := 1
// ... emit Transfer event ...
}
}

Risk

Likelihood:

  • The mint or burn function is public and can be called by anyone in the Token contract

  • A token with high supply (close to type(uint256).max) will trigger overflow on the next mint (same for burn)

  • The transfer function is public and can be called by any token holder. A recipient with a balance close to type(uint256).max or 0 receiving/giving additional tokens will trigger an overflow/underflow

Impact:

  • The totalSupply can be reset to a value close to zero, breaking the invariant sum(balances) == totalSupply

  • Users can lose visibility on their actual tokens since balanceOf returns an incorrect value after overflow

  • DeFi protocols integrating this token may miscalculate ratios and prices

  • In _transfer, the recipient's balance can wrap around to a small value, causing loss of funds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract MintBurnTransferOverflowUnderflowTest is Test {
Token public token;
function setUp() public {
token = new Token();
}
// ===================== MINT OVERFLOW TESTS =====================
function test_totalSupplyOverflow() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
// Mint max uint256 to alice
token.mint(alice, type(uint256).max);
// Mint 1 more to bob - should revert but wraps to 0
token.mint(bob, 1);
// totalSupply wrapped around to 0 instead of reverting
assertEq(token.totalSupply(), 0);
}
function test_balanceOverflow() public {
address alice = makeAddr("alice");
// Mint max uint256 to alice
token.mint(alice, type(uint256).max);
// Mint 1 more to alice - should revert but wraps to 0
token.mint(alice, 1);
// Balance wrapped around to 0 instead of reverting
assertEq(token.balanceOf(alice), 0);
}
// ===================== BURN UNDERFLOW TESTS =====================
function test_totalSupplyUnderflow() public {
address alice = makeAddr("alice");
// Mint 100 tokens
token.mint(alice, 100);
// Burn 101 - should revert but wraps to max
token.burn(alice, 101);
// totalSupply wrapped to type(uint256).max instead of reverting
assertEq(token.totalSupply(), type(uint256).max);
}
function test_balanceUnderflow() public {
address alice = makeAddr("alice");
// Mint 100 tokens
token.mint(alice, 100);
// Burn 101 - should revert but wraps to max
token.burn(alice, 101);
// Balance wrapped to type(uint256).max instead of reverting
assertEq(token.balanceOf(alice), type(uint256).max);
}
// ===================== TRANSFER OVERFLOW TEST =====================
function test_transferRecipientBalanceOverflow() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
// Mint max uint256 to bob (recipient with huge balance)
token.mint(bob, type(uint256).max);
// Mint some tokens to alice
token.mint(alice, 100);
// Alice transfers 1 token to bob - should revert but wraps bob's balance
vm.prank(alice);
token.transfer(bob, 1);
// Bob's balance wrapped around to 0 instead of reverting
// Lost type(uint256).max tokens!
assertEq(token.balanceOf(bob), 0);
// Alice still lost her token
assertEq(token.balanceOf(alice), 99);
}
}

when we run the code we have:

forge test --match-contract MintBurnTransferOverflowUnderflowTest -vvv
Ran 5 tests for test/MintOverflowVulnerability.t.sol:MintBurnTransferOverflowUnderflowTest
[PASS] test_balanceOverflow() (gas: 59501)
[PASS] test_balanceUnderflow() (gas: 59922)
[PASS] test_totalSupplyOverflow() (gas: 63052)
[PASS] test_totalSupplyUnderflow() (gas: 59114)
[PASS] test_transferRecipientBalanceOverflow() (gas: 71234)
Suite result: ok. 5 passed; 0 failed; 0 skipped

Recommended Mitigation

Add overflow/underflow checks before storing the new values. In assembly, arithmetic safety must be handled manually since the add and sub opcodes do not revert on overflow/underflow like Solidity 0.8+ does by default.

For overflow detection (addition): Check if the result is less than one of the original operands. If a + b < a, an overflow occurred.

For underflow detection (subtraction): Check if the value being subtracted is greater than the original value. If value > balance, an underflow would occur.

Apply these checks to all three functions:

  1. _mint: Add overflow checks for both totalSupply and accountBalance additions

  2. _burn: Add underflow checks for both totalSupply and accountBalance subtractions

  3. _transfer: Add overflow check for the recipient's balance addition (the sender's underflow is already checked via lt(fromAmount, value))

function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0xec442f05))
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, add(supply, value))
+ let newSupply := add(supply, value)
+ if lt(newSupply, supply) { revert(0, 0) } // Overflow check
+ sstore(supplySlot, newSupply)
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
- sstore(accountBalanceSlot, add(accountBalance, value))
+ let newBalance := add(accountBalance, value)
+ if lt(newBalance, accountBalance) { revert(0, 0) } // Overflow check
+ sstore(accountBalanceSlot, newBalance)
}
}
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)
+ if lt(supply, value) { revert(0, 0) } // Underflow check
- sstore(supplySlot, sub(supply, value))
+ sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+ if lt(accountBalance, value) { revert(0, 0) } // Underflow check
- sstore(accountBalanceSlot, sub(accountBalance, value))
+ sstore(accountBalanceSlot, sub(accountBalance, value))
}
}
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... zero address checks ...
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
// revert ERC20InsufficientBalance
}
sstore(fromSlot, sub(fromAmount, value))
- sstore(toSlot, add(toAmount, value))
+ let newToAmount := add(toAmount, value)
+ if lt(newToAmount, toAmount) { revert(0, 0) } // Overflow check
+ sstore(toSlot, newToAmount)
success := 1
// ... emit Transfer event ...
}
}

Support

FAQs

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

Give us feedback!