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.
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
sstore(toSlot, add(toAmount, value))
success := 1
}
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
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract TransferOverflowTest is Test {
Token internal token;
address internal FROM = address(0xF00D);
address internal TO = address(0xCAFE);
function setUp() public {
token = new Token();
}
function test_recipientBalance_overflows_onTransfer() public {
uint256 nearMax = type(uint256).max - 10;
uint256 value = 20;
token.mint(TO, nearMax);
token.mint(FROM, 100);
assertEq(token.balanceOf(TO), nearMax, "recipient should start near MAX_UINT");
assertEq(token.balanceOf(FROM), 100, "sender pre-fund failed");
vm.prank(FROM);
bool ok = token.transfer(TO, value);
assertTrue(ok, "transfer should report success");
uint256 recipientAfter = token.balanceOf(TO);
assertEq(recipientAfter, 9, "recipient balance wrapped to a small number due to overflow");
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
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 ...
}
}