Token-0x

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

Unchecked arithmetic in assembly _transfer causes balance overflow, enabling balance corruption and breaking ERC-20 invariants

Author Revealed upon completion

Root + Impact

Description

  • A transfer should:

    • Change the recceiver's and sender's balance

    • Revert on insufficient balance.

  • Issues

    • The function performs balance updates using the raw EVM opcodes sub and add inside an assembly block.
      These arithmetic operations do not include overflow or underflow checks.

    • Although the insufficient-balance check prevents underflow for the sender, there is no protection against overflow on the receiver’s balance

    Root Cause

// Root cause in the codebase with @> marks to highlight the relevant section
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
if iszero(from) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
if iszero(to) {
mstore(0x00, shl(224, 0xec442f05))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40) // free memory pointer
let baseSlot := _balances.slot // Memory slot for the balances mappign
mstore(ptr, from) // store from address at ptr
mstore(add(ptr, 0x20), baseSlot) // storing baseslot in next 32 bytes
let fromSlot := keccak256(ptr, 0x40) // calculating from slot
let fromAmount := sload(fromSlot) // loading the balance that the sender has
mstore(ptr, to) // store to address at ptr
mstore(add(ptr, 0x20), baseSlot) // storing baseslot in next 32 bytes
let toSlot := keccak256(ptr, 0x40) // calculating to slot locaation
let toAmount := sload(toSlot) // loading the balance that the receiver has
if lt(fromAmount, value) {
mstore(0x00, shl(224, 0xe450d38c))
mstore(add(0x00, 4), from)
mstore(add(0x00, 0x24), fromAmount)
mstore(add(0x00, 0x44), value)
revert(0x00, 0x64)
}
sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value))
success := 1
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
}
}

Risk

Likelihood:

  • Overflow becomes possible whenever _transfer is reachable through any public/external function implementing token transfers.

Many real-world flows involve large cumulative transfers (bridges, batch transfers, staking payouts), increasing exposure.

Impact:

  • The protocol places no upper bound on balances.

_mint functionality (if present) can be used to inflate balances to near-overflow values.

Proof of Concept

  • In the test we can clearly see that the balance of the receiver was type(unit256).max

  • When even 2 tokens were transferred the call was successful and receiver's balance turned to 1, instead the call should be reverted

function test_transfer_overflow() public {
address account = makeAddr("account");
token.mint(account, 100e18);
uint256 balanceSender = token.balanceOf(account);
assertEq(balanceSender, 100e18);
address receiver = makeAddr("receiver");
token.mint(receiver, type(uint256).max);
//vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0)));
vm.prank(account);
token.transfer(receiver, 2);
console.log(token.balanceOf(receiver));
}
// Output
Ran 1 test for test/Token.t.sol:TokenTest
[PASS] test_transfer_overflow() (gas: 92632)
Logs:
1
Traces:
[92632] TokenTest::test_transfer_overflow()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]
├─ [0] VM::label(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], "account")
│ └─ ← [Return]
├─ [45004] Token::mint(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], 100000000000000000000 [1e20])
│ └─ ← [Stop]
├─ [720] Token::balanceOf(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e]
├─ [0] VM::label(receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e], "receiver")
│ └─ ← [Return]
├─ [23104] Token::mint(receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Stop]
├─ [0] VM::prank(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e])
│ └─ ← [Return]
├─ [3393] Token::transfer(receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e], 2)
│ ├─ emit Transfer(from: account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], to: receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e], value: 2)
│ └─ ← [Return] true
├─ [720] Token::balanceOf(receiver: [0xB6D4805bf6943c5875C0C7b67EDa24b2bDACBF6e]) [staticcall]
│ └─ ← [Return] 1
├─ [0] console::log(1) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

Recommended Mitigation

  • Uses Solidity’s built-in checked arithmetic, preventing balance overflow and underflow.

Adds proper zero-address and insufficient-balance validations aligned with ERC-20 standards.

  • Restores safe and predictable state updates without relying on unsafe assembly operations.

function _transfer(address from, address to, uint256 value) internal {
require(from != address(0), "ERC20: transfer from zero");
require(to != address(0), "ERC20: transfer to zero");
uint256 fromBal = _balances[from];
require(fromBal >= value, "ERC20: insufficient balance");
_balances[from] = fromBal - value;
_balances[to] += value;
emit Transfer(from, to, value);
}

Support

FAQs

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

Give us feedback!