Token-0x

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

Integer overflow in _transfer

Author Revealed upon completion

Description

  • In a correct ERC‑20 implementation, transferring tokens should safely decrease the sender’s balance and safely increase the recipient’s balance. Arithmetic must not overflow or underflow. In Solidity ≥0.8.x, normal Solidity arithmetic reverts on overflow; however, when using Yul assembly, add/sub do not include automatic overflow checks, so explicit guards are required.

  • In this codebase, _transfer loads toAmount and then performs sstore(toSlot, add(toAmount, value)) without any overflow check. If toAmount is close to type(uint256).max, adding value silently wraps and corrupts the recipient’s balance (and total accounting), violating ERC‑20 safety expectations.

// src/helpers/ERC20Internals.sol (excerpt)
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... zero-address checks and storage slot derivations ...
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
// @> Underflow is checked on the sender side (lt(fromAmount, value) -> revert),
// @> BUT overflow on the recipient side is NOT checked:
// @> If toAmount + value > 2^256-1, this wraps around to a small number.
sstore(toSlot, add(toAmount, value)) // <-- BUG: unchecked addition in Yul
success := 1
// ... emit Transfer event ...
}

Risk

Likelihood: Low

  • The path is executed on every successful transfer, including high-volume protocol flows (DEX swaps, bridges, reward distributions).

  • Reaching 2^256 - 1 is rare in practice, but assembly arithmetic here lacks safety checks; attackers or misconfigured mints/burns elsewhere (also using unchecked assembly) can push balances near the limit. The mint/burn routines in the same codebase also use unchecked add/sub, increasing the realistic chance of reaching unsafe states.

Impact: High

  • Recipient balances can wrap to a much smaller value after overflow, breaking accounting and enabling potential funds misrepresentation.

  • Downstream systems (bridges, indexers, vaults) reading balances will observe incorrect states, which can cascade into loss of funds, stuck operations, or governance actions based on bad data.

Proof of Concept

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

  • Run forge test --mp poc6 -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 recipient balance overflow due to unchecked addition in _transfer.
/// The internal Yul code does: sstore(toSlot, add(toAmount, value)) without overflow check.
/// If `toAmount` is close to MAX_UINT, adding `value` wraps to a small number.
contract TransferOverflowTest is Test {
Token internal token;
address internal FROM = address(0xF00D);
address internal TO = address(0xCAFE);
function setUp() public {
token = new Token();
}
/// @dev Shows overflow on the recipient side:
/// - Set recipient balance to (MAX_UINT - 10).
/// - Transfer 20 from a funded sender to recipient.
/// - Expected new recipient balance wraps to 9 (since (MAX-10)+20 == 2^256 + 9 -> 9).
function test_recipientBalance_overflows_onTransfer() public {
// Prepare balances
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
// Mint a near-max balance to recipient
token.mint(TO, nearMax);
// Mint enough to the sender so the transfer can succeed
token.mint(FROM, 100);
// Sanity preconditions
assertEq(token.balanceOf(TO), nearMax, "recipient should start near MAX_UINT");
assertEq(token.balanceOf(FROM), 100, "sender pre-fund failed");
// Perform transfer: this will pass the sender's insufficient-balance check,
// but will overflow on recipient addition in `_transfer`.
vm.prank(FROM);
bool ok = token.transfer(TO, value);
assertTrue(ok, "transfer should report success");
// Recipient balance SHOULD be nearMax + value, but due to unchecked addition it wraps:
// (MAX - 10) + 20 = MAX + 10 = 2^256 + 9 => 9
uint256 recipientAfter = token.balanceOf(TO);
assertEq(recipientAfter, 9, "recipient balance wrapped to a small number due to overflow");
// Sender decreased by `value` as usual
uint256 senderAfter = token.balanceOf(FROM);
assertEq(senderAfter, 80, "sender balance should decrease by transfer value");
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/poc6.t.sol:TransferOverflowTest
[PASS] test_recipientBalance_overflows_onTransfer() (gas: 98736)
Traces:
[98736] TransferOverflowTest::test_recipientBalance_overflows_onTransfer()
├─ [45004] Token::mint(0x000000000000000000000000000000000000cafE, 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77])
│ └─ ← [Stop]
├─ [23104] Token::mint(0x000000000000000000000000000000000000F00D, 100)
│ └─ ← [Stop]
├─ [720] Token::balanceOf(0x000000000000000000000000000000000000cafE) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639925 [1.157e77], "recipient should start near MAX_UINT") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x000000000000000000000000000000000000F00D) [staticcall]
│ └─ ← [Return] 100
├─ [0] VM::assertEq(100, 100, "sender pre-fund failed") [staticcall]
│ └─ ← [Return]
├─ [0] VM::prank(0x000000000000000000000000000000000000F00D)
│ └─ ← [Return]
├─ [3393] Token::transfer(0x000000000000000000000000000000000000cafE, 20)
│ ├─ emit Transfer(from: 0x000000000000000000000000000000000000F00D, to: 0x000000000000000000000000000000000000cafE, value: 20)
│ └─ ← [Return] true
├─ [0] VM::assertTrue(true, "transfer should report success") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x000000000000000000000000000000000000cafE) [staticcall]
│ └─ ← [Return] 9
├─ [0] VM::assertEq(9, 9, "recipient balance wrapped to a small number due to overflow") [staticcall]
│ └─ ← [Return]
├─ [720] Token::balanceOf(0x000000000000000000000000000000000000F00D) [staticcall]
│ └─ ← [Return] 80
├─ [0] VM::assertEq(80, 80, "sender balance should decrease by transfer value") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.67ms (2.22ms CPU time)
Ran 1 test suite in 119.23ms (10.67ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Add explicit overflow checks in Yul before performing the add.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... prelude ...
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
+ // Overflow check: if (toAmount + value < toAmount) -> overflow
+ let newTo := add(toAmount, value)
+ if lt(newTo, toAmount) {
+ // Reuse ERC20 error style or custom revert
+ revert(0, 0)
+ }
- sstore(toSlot, add(toAmount, value))
+ sstore(toSlot, newTo)
success := 1
// ... event emission ...
}
}

Support

FAQs

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

Give us feedback!