Token-0x

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

Self-Transfer Token Duplication

Author Revealed upon completion

Description

  • The ERC20 standard expects that when a user transfers tokens to themselves, their balance should remain unchanged. The total supply should also remain constant.

  • In Token-0x's _transfer function, when from == to, the code reads both fromAmount and toAmount from the same storage slot (since they're the same address). It then writes sub(fromAmount, value) to the slot, immediately followed by add(toAmount, value). Since toAmount still holds the original value (not the decremented one), the second write overwrites the first, resulting in balance = original + value instead of balance = original.

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 original balance
mstore(ptr, to)
mstore(add(ptr, 0x20), baseSlot)
let toSlot := keccak256(ptr, 0x40) // @> When from==to, toSlot == fromSlot
let toAmount := sload(toSlot) // @> toAmount == fromAmount (same slot!)
// ... balance check ...
sstore(fromSlot, sub(fromAmount, value)) // @> Write: balance = original - value
sstore(toSlot, add(toAmount, value)) // @> OVERWRITES: balance = original + value
success := 1
}
}

Risk

Likelihood:

  • Any user can call transfer(msg.sender, amount) to trigger this vulnerability

  • This is a standard ERC20 operation that users may perform accidentally or maliciously

Impact:

  • Users can mint unlimited tokens by repeatedly transferring to themselves

  • Total supply accounting becomes incorrect (tokens created from nothing)

  • Complete destruction of the token's economic model

  • Any DeFi protocol integrating this token can be drained

Proof of Concept

function test_self_transfer_exploit() public {
// Alice starts with 100 tokens
token.mint(alice, 100e18);
assertEq(token.balanceOf(alice), 100e18);
// Alice transfers 50 tokens to herself
vm.prank(alice);
token.transfer(alice, 50e18);
// BUG: Alice now has 150 tokens instead of 100!
assertEq(token.balanceOf(alice), 150e18);
// Alice can repeat this to mint unlimited tokens
vm.prank(alice);
token.transfer(alice, 150e18);
assertEq(token.balanceOf(alice), 300e18); // Doubled again!
}

Recommended Mitigation

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
+ if eq(from, to) {
+ let ptr := mload(0x40)
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
+ success := 1
+ leave
+ }
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) {
mstore(0x00, shl(224, 0xe450d38c))
mstore(add(0x00, 4), from)
mstore(add(0x00, 0x24), fromAmount)
mstore(add(0x00, 0x44), value)
revert(0x00, 0x64)
}
sstore(fromSlot, sub(fromAmount, value))
sstore(toSlot, add(toAmount, value))
success := 1
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)
}
}

Support

FAQs

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

Give us feedback!