Token-0x

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

Transfer Overflow Leading to Receiver Balance Corruption

Author Revealed upon completion

Transfer Overflow Leading to Receiver Balance Corruption

Description

  • The expected behavior of _transfer() is to deduct value tokens from from and add the same value to to, ensuring both balances remain valid and no arithmetic errors occur.

  • In the current implementation, the addition to the receiver’s balance is performed using raw Yul add(toAmount, value) without overflow checks. When the receiver already holds a balance near uint256.max, adding any additional amount causes an overflow and wraps the balance to zero, corrupting the receiver’s state and violating ERC20 invariants.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
...
let toAmount := sload(toSlot)
...
sstore(fromSlot, sub(fromAmount, value))
// @> Overflow here wraps the receiver's balance back to zero
sstore(toSlot, add(toAmount, value))
success := 1
}
}

Risk

Likelihood:

  • Overflow occurs whenever the receiver already holds a very large balance and receives additional tokens via transfer.

  • Because the function performs unchecked Yul arithmetic, overflow is not prevented by Solidity’s built-in safety mechanisms.

Impact:

  • The receiver’s balance becomes corrupted and resets to zero due to overflow.

  • ERC20 invariants break, as total supply and account balances may no longer match expected values.

Impact Level: High

Proof of Concept

The following test sets the receiver's balance to uint256.max, transfers a single token to them, and observes that the balance wraps back to zero. Console logs confirm the overflow behavior.

function test_transferOverflow() public {
address sender = makeAddr("sender");
address receiver = makeAddr("receiver");
token.mint(receiver, type(uint256).max);
uint256 initialReceiverBalance = token.balanceOf(receiver);
console.log("Receiver balance before transfer:", initialReceiverBalance);
token.mint(sender, 1e18);
uint256 initialSenderBalance = token.balanceOf(sender);
console.log("Sender balance before transfer:", initialSenderBalance);
vm.prank(sender);
token.transfer(receiver, 1);
uint256 finalReceiverBalance = token.balanceOf(receiver);
uint256 finalSenderBalance = token.balanceOf(sender);
console.log("Receiver balance after overflow transfer:", finalReceiverBalance);
console.log("Sender balance after overflow transfer:", finalSenderBalance);
assert(finalReceiverBalance < initialReceiverBalance);
}
Console Output
[PASS] test_transferOverflow() (gas: 71966)
Logs:
Receiver balance before transfer:
115792089237316195423570985008687907853269984665640564039457584007913129639935
Sender balance before transfer: 1000000000000000000
Receiver balance after overflow transfer: 0
Sender balance after overflow transfer: 999999999999999999

Recommended Mitigation

Add explicit overflow checks before writing the new receiver balance. Revert early if the addition wraps around. As with other ERC20 internals, consider using high-level Solidity for arithmetic safety unless Yul is strictly required.

- sstore(toSlot, add(toAmount, value))
+ let newToAmount := add(toAmount, value)
+ if lt(newToAmount, toAmount) {
+ revert(0, 0) // replace with a proper custom error
+ }
+ sstore(toSlot, newToAmount)

This ensures the transfer reverts instead of silently corrupting the receiver’s balance.

Support

FAQs

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

Give us feedback!