Root Cause:
_transfer uses inline assembly and adds value to toAmount without validating that toAmount + value <= type(uint256).max (no overflow guard)
Impact:
Overflow causes recipient balance to wrap around to a small number → recipient loses almost all tokens when receiving a transfer.
Description
-
In _transfer, the recipient balance is updated with a raw add in inline assembly: sstore(toSlot, add(toAmount, value)).
-
If toAmount is close to type(uint256).max, add overflows and wraps to a small value instead of reverting, breaking accounting invariants and enabling loss/manipulation of holder funds.
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))
}
}
Risk
Likelihood:
-
When recipient balance approaches uint256.max
-
During high-inflation token minting periods
-
In whale wallets or system treasury accounts
Impact:
-
Recipient balance wraps → funds effectively lost
-
Total supply invariant breaks
-
Circulating supply becomes inconsistent
-
Governance and reward mechanisms become exploitable
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {ERC20} from "../src/ERC20.sol";
contract ERC20Mock is ERC20 {
constructor() ERC20("Test", "TST") {}
function mint(address to, uint256 amount) public { _mint(to, amount); }
}
contract TransferOverflowTest is Test {
ERC20Mock public token;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
token = new ERC20Mock();
}
function test_transfer_overflow_recipient() public {
uint256 maxUint = type(uint256).max;
uint256 initialBobBalance = maxUint - 100;
token.mint(bob, initialBobBalance);
token.mint(alice, 200);
vm.prank(alice);
token.transfer(bob, 200);
uint256 bobBalance = token.balanceOf(bob);
console.log("Bob before:", initialBobBalance);
console.log("Bob after :", bobBalance);
}
}
Run:
forge test --match-test test_transfer_overflow_recipient -vvvv
Output:
[PASS] test_transfer_overflow_recipient() (gas: 73012)
Logs:
Bob before: 115792089237316195423570985008687907853269984665640564039457584007913129639835
Bob after : 99
Recommended Mitigation
- sstore(fromSlot, sub(fromAmount, value))
+ let newToBalance := add(toAmount, value)
+ // Overflow check: if result is less than original, an overflow occurred
+ if lt(newToBalance, toAmount) {
+ // Revert with standard Panic(0x11) = arithmetic overflow
+ mstore(0x00, shl(224, 0x4e487b71)) // Panic(uint256) selector
+ mstore(add(0x00, 4), 0x11) // panic code 0x11 (overflow)
+ revert(0x00, 0x24)
+ }
+ sstore(toSlot, newToBalance)