Token-0x

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

Unsafe Yul Arithmetic in ERC20Internals.sol

Author Revealed upon completion

Root + Impact

Description

  • Standard ERC20 implementations rely on Solidity 0.8+ checked arithmetic or SafeMath libraries to automatically revert transactions when an arithmetic operation results in an overflow or underflow.

  • The ERC20Internals.sol library implements core functions (_mint, _burn, _transfer) using inline assembly (Yul) with add and sub instructions but fails to perform manual validation to ensure the results do not wrap around.

// src/helpers/ERC20Internals.sol
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
// ...
// @> No check for overflow on the receiver's balance
sstore(toSlot, add(toAmount, value))
// ...
}
function _burn(address account, uint256 value) internal {
// ...
// @> No check for underflow (if balance < value)
sstore(accountBalanceSlot, sub(accountBalance, value))
}

Risk

Likelihood:

  • Attackers can trivially trigger the underflow by calling burn with a value greater than their balance.

  • Malicious actors can force an overflow on a victim's balance by transferring tokens that push the total beyond type(uint256).max.

Impact:

  • Attackers can achieve a near-infinite token balance by causing an underflow in the burn function.

  • Users may permanently lose their funds if an incoming transfer or mint causes their balance to overflow to zero.

Proof of Concept

// Create a new test file, name it TokenDiff.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { Token } from "./Token.sol";
import { Token2 } from "./Token2.sol";
import { Test, console } from "forge-std/Test.sol";
contract TokenDifferential is Test {
Token public customERC20;
Token2 public standardERC20;
function setUp() public {
customERC20 = new Token();
standardERC20 = new Token2();
}
// The following test is to test burn 1 token from 0 balance
// This is to show the difference between the Custom ERC20 and
// OpenZeppelin's ERC20.
function test_Differential_UnderflowHandling() public {
address user = makeAddr("user");
// OpenZeppelin's ERC20
vm.expectRevert();
standardERC20.burn(user, 1);
// CustomERC20
customERC20.burn(user, 1);
uint256 userBal = customERC20.balanceOf(user);
assertEq(userBal, type(uint256).max);
console.log("User's Balance of CustomERC20 Token After Burn:", userBal);
}
function test_Differential_OverflowHandling() public {
address user = makeAddr("user");
uint256 maxAmount = type(uint256).max;
// Setup
standardERC20.mint(user, maxAmount);
customERC20.mint(user, maxAmount);
uint256 userBalStandardToken = standardERC20.balanceOf(user);
uint256 userBalCustomToken = customERC20.balanceOf(user);
// Handling checks
// OpenZeppelin's ERC20
vm.expectRevert();
standardERC20.mint(user, 1);
uint256 userBalStandardTokenAfterRevert = standardERC20.balanceOf(user);
assertEq(userBalStandardTokenAfterRevert, userBalStandardToken);
// CustomERC20
customERC20.mint(user, 1);
uint256 userBalCustomTokenAfterSeconMint = customERC20.balanceOf(user);
assertEq(userBalCustomTokenAfterSeconMint, 0);
// Logs
console.log("User's Standard ERC20 Balance After First Mint:", userBalStandardToken);
console.log("User's Custom ERC20 Balance After First Mint:", userBalCustomToken);
console.log("User's Standard ERC20 Balance After Second Mint:", userBalStandardTokenAfterRevert);
console.log("User's Custom ERC20 Balance After Second Mint:", userBalCustomTokenAfterSeconMint);
}
function test_Differential_TransferOverflowHandling() public {
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
uint256 maxAmount = type(uint256).max;
// Custom ERC20
customERC20.mint(user1, maxAmount);
customERC20.mint(user2, 10);
uint256 user1CustomBalBefore = customERC20.balanceOf(user1);
uint256 user2CustomBalBefore = customERC20.balanceOf(user2);
vm.prank(user2);
customERC20.transfer(user1, 1);
uint256 user1CustomBalAfter = customERC20.balanceOf(user1);
uint256 user2CustomBalAfter = customERC20.balanceOf(user2);
assertEq(user1CustomBalAfter, 0);
console.log("User 1 Custom ERC20 Balance Before Transfer:", user1CustomBalBefore);
console.log("User 2 Custom ERC20 Balance Before Transfer:", user2CustomBalBefore);
console.log("User 1 Custom ERC20 Balance After Transfer:", user1CustomBalAfter);
console.log("User 2 Custom ERC20 Balance After Transfer:", user2CustomBalAfter);
}
}

Recommended Mitigation

function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
// ...
let accountBalance := sload(accountBalanceSlot)
+ if lt(accountBalance, value) { revert(0, 0) }
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}
function _transfer(address from, address to, uint256 value) internal returns (bool success) {
assembly ("memory-safe") {
// ...
let toAmount := sload(toSlot)
+ if gt(toAmount, sub(not(0), value)) { revert(0, 0) }
sstore(toSlot, add(toAmount, value))
// ...
}
}

Support

FAQs

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

Give us feedback!