Token-0x

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

_transfer uses unchecked addition on recipient balance, allowing wraparound

Author Revealed upon completion

ERC20Internals._transfer computes the recipient’s new balance with a raw Yul add(toAmount, value) and writes it with sstore without any overflow check. In assembly, add always wraps modulo 2^256.

Description

  • Normal behavior

    In a robust ERC20:

    • transfer(from, to, value) updates balances in a way that preserves:

      • balance[to] monotonically increasing as they receive tokens.

      • No overflow/wrap: if the operation would push balance[to] above type(uint256).max, the transaction reverts instead of corrupting state.


  • Issue

    In this implementation, _transfer checks that fromAmount >= value before subtracting from the sender, but it does not check whether toAmount + value overflows. The addition happens directly in Yul:

Because add(toAmount, value) is executed in assembly, Solidity 0.8.x’s checked arithmetic does not apply. When toAmount is type(uint256).max and value = 1, the new stored value becomes 0 instead of reverting.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
...
let ptr := mload(0x40)
let baseSlot := _balances.slot
// Load sender balance
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
// Load recipient balance
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
...
}
// Sender update guarded by a balance check
sstore(fromSlot, sub(fromAmount, value))
@> // Recipient update uses unchecked addition.
@> // When toAmount is close to type(uint256).max, this wraps modulo 2^256.
//@> sstore(toSlot, add(toAmount, value))
success := 1
...
}
}

Risk

Likelihood:

  • Likelihood:

    • Whenever the recipient’s balance has previously been pushed near type(uint256).max, any subsequent transfer to that address causes the recipient balance to wrap instead of reverting.

Impact:

  • Recipient balances can silently wrap from a huge value to a small one (including zero) as a consequence of incoming transfers, violating user expectations that receiving tokens never decreases their balance.

Proof of Concept

This PoC uses Foundry’s vm.store to simulate a recipient with a near-maximum balance, then performs a transfer that causes the recipient’s balance to wrap from type(uint256).max to 0.

Add the following test to Token.t.sol:

function test_transferRecipientBalanceOverflowWraps() public {
address from = makeAddr("from");
address to = makeAddr("to");
// Give sender a small valid balance via normal mint
token.mint(from, 1);
assertEq(token.balanceOf(from), 1);
// Manually set recipient balance to max uint256 via vm.store
// _balances is at slot 0 in ERC20Internals, so:
// slot = keccak256(abi.encode(to, uint256(0)))
bytes32 toSlot = keccak256(abi.encode(to, uint256(0)));
vm.store(address(token), toSlot, bytes32(type(uint256).max));
uint256 beforeFrom = token.balanceOf(from);
uint256 beforeTo = token.balanceOf(to);
assertEq(beforeFrom, 1);
assertEq(beforeTo, type(uint256).max);
// This transfer should REVERT in a safe implementation because
// toBalance + value overflows. Here it silently wraps to 0.
vm.prank(from);
token.transfer(to, 1);
uint256 afterFrom = token.balanceOf(from);
uint256 afterTo = token.balanceOf(to);
// Sender spent 1 (as expected).
assertEq(afterFrom, 0, "sender balance should decrease by 1");
// Recipient balance wrapped from max to 0 instead of reverting.
assertEq(afterTo, 0, "recipient balance incorrectly wrapped to 0 after overflow");
}

Recommended Mitigation

Enforce checked arithmetic for the recipient balance update in _transfer, either by:

  • Moving the logic to Solidity (benefiting from 0.8.x checks), or

  • Adding explicit overflow detection in Yul before storing.

Conceptual patch (staying in Yul and adding a check):

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
...
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
...
}
sstore(fromSlot, sub(fromAmount, value))
- sstore(toSlot, add(toAmount, value))
+ // Checked addition for recipient balance: newTo = toAmount + value
+ let newTo := add(toAmount, value)
+ // Overflow if newTo < toAmount in unsigned arithmetic
+ if lt(newTo, toAmount) {
+ // revert or use a dedicated error selector
+ revert(0, 0)
+ }
+ sstore(toSlot, newTo)
success := 1
...
}
}

Support

FAQs

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

Give us feedback!