From 1ced14263ffbccc65ee226e54311577618139775 Mon Sep 17 00:00:00 2001 From: minaminao Date: Tue, 7 Nov 2023 04:56:06 +0900 Subject: [PATCH] add writeup and solver for Cosmic Radiation --- .gitattributes | 1 + .../CosmicRadiation/.gitignore | 5 + .../CosmicRadiation/Exploit.s.sol | 32 + src/ParadigmCTF2023/CosmicRadiation/README.md | 261 ++++++++ .../100ether-transfer-to-contract.sql | 7 + .../10ether-transfer-from-given-block.sql | 4 + .../big-query/top20000-contracts.sql | 6 + .../data/100ether-transfer.csv | 3 + .../CosmicRadiation/data/contract-list.csv | 3 + .../CosmicRadiation/setup_server.py | 107 ++++ src/ParadigmCTF2023/CosmicRadiation/solve.py | 580 ++++++++++++++++++ 11 files changed, 1009 insertions(+) create mode 100644 .gitattributes create mode 100644 src/ParadigmCTF2023/CosmicRadiation/.gitignore create mode 100644 src/ParadigmCTF2023/CosmicRadiation/Exploit.s.sol create mode 100644 src/ParadigmCTF2023/CosmicRadiation/README.md create mode 100644 src/ParadigmCTF2023/CosmicRadiation/big-query/100ether-transfer-to-contract.sql create mode 100644 src/ParadigmCTF2023/CosmicRadiation/big-query/10ether-transfer-from-given-block.sql create mode 100644 src/ParadigmCTF2023/CosmicRadiation/big-query/top20000-contracts.sql create mode 100644 src/ParadigmCTF2023/CosmicRadiation/data/100ether-transfer.csv create mode 100644 src/ParadigmCTF2023/CosmicRadiation/data/contract-list.csv create mode 100644 src/ParadigmCTF2023/CosmicRadiation/setup_server.py create mode 100644 src/ParadigmCTF2023/CosmicRadiation/solve.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..87e654b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.csv filter=lfs diff=lfs merge=lfs -text diff --git a/src/ParadigmCTF2023/CosmicRadiation/.gitignore b/src/ParadigmCTF2023/CosmicRadiation/.gitignore new file mode 100644 index 0000000..96bb453 --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/.gitignore @@ -0,0 +1,5 @@ +broadcast.sh +*.pickle +bq-*.csv +10ether_from_addr.csv +1000ether-transfer.csv diff --git a/src/ParadigmCTF2023/CosmicRadiation/Exploit.s.sol b/src/ParadigmCTF2023/CosmicRadiation/Exploit.s.sol new file mode 100644 index 0000000..a48fd00 --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/Exploit.s.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; + +contract ExploitScript is Script { + function deploy() public { + vm.startBroadcast(); + + new SelfDestruct(); + + vm.stopBroadcast(); + } +} + +contract SelfDestruct { + constructor() payable {} + + function exploit(bytes memory data, address[] memory addresses) public { + for (uint256 i = 0; i < addresses.length; i++) { + address(addresses[i]).call(data); + } + } + + function exploit(address[] memory addresses) external { + exploit("", addresses); + } + + function destruct(address addr) public payable { + selfdestruct(payable(addr)); + } +} diff --git a/src/ParadigmCTF2023/CosmicRadiation/README.md b/src/ParadigmCTF2023/CosmicRadiation/README.md new file mode 100644 index 0000000..fcbd2e8 --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/README.md @@ -0,0 +1,261 @@ +# Paradigm CTF 2023 - Cosmic Radiation - From Zero to 45,046,618 ETH + +On this page, I will explain a solution to the "Cosmic Radiation" challenge from Paradigm CTF 2023. + +Our team, KALOS++, achieved a score of 44,901,978 during the CTF for this challenge. +However, there were some ideas for algorithms that we could not fully implement within the time constraints. +**After the CTF ended, when I implemented those algorithms, our score eventually increased to 45,046,618.** +This score was more than 100,000 ETH higher than the highest score of all teams during the CTF. + +In this writeup, I will describe the algorithms I got the score. + +**Table of Contents** +- [Challenge Overview](#challenge-overview) +- [My Solution](#my-solution) + - [\[-\> 31,659,312\] Attacking the Contract with the Highest Balance](#--31659312-attacking-the-contract-with-the-highest-balance) + - [\[-\> 44,956,845\] Using the Contract List of Top Balances from Google BigQuery](#--44956845-using-the-contract-list-of-top-balances-from-google-bigquery) + - [\[-\> 44,971,334\] Optimizing based on On-chain Simulation](#--44971334-optimizing-based-on-on-chain-simulation) + - [\[-\> 44,973,046\] Optimizing with Overwrite Target: `ORIGIN SELFDESTRUCT` or `CALLER SELFDESTRUCT`](#--44973046-optimizing-with-overwrite-target-origin-selfdestruct-or-caller-selfdestruct) + - [\[-\> 44,975,043\] Optimizing with Calldata: `0x` or `0x11223344`](#--44975043-optimizing-with-calldata-0x-or-0x11223344) + - [\[-\> 44,992,197\] Detecting Proxy Contracts and Modifying Implementation Contracts](#--44992197-detecting-proxy-contracts-and-modifying-implementation-contracts) + - [\[-\> 45,046,618\] Performing Replay Attacks](#--45046618-performing-replay-attacks) + +## Challenge Overview + +"Cosmic Radiation" is a King-of-the-Hill style challenge, where players compete to maximize the amount of ETH they can get under given constraints. +The primary constraints are as follows: +- Players can modify any number of bits within the bytecode of any address with a positive balance. +- The more bits a player modifies, the lower the value the address's balance will be overwritten with. + +However, despite being able to modify the bytecode of any number of addresses, due to infrastructure and time limitations during the CTF, we could only modify up to approximately 10,000 contracts. +**Thus, on this page, I will approach this challenge under the constraint of "being able to modify a maximum of 10,000 contracts."** + +In more detail, the instruction to modify the bytecode is named `bitflip` and follows the below format. + +``` +address:bit1:bit2:bit3:...:bitN +``` + +This is parsed into `addr` and `bits` as follows by the challenge server: + +```python +(addr, *bits) = bitflip.split(":") +addr = Web3.to_checksum_address(addr) +bits = [int(v) for v in bits] +``` + +Following that, the bytecode is modified according to bits as below: + +```python +code = bytearray(web3.eth.get_code(addr)) +for bit in bits: + byte_offset = bit // 8 + bit_offset = 7 - bit % 8 + if byte_offset < len(code): + code[byte_offset] ^= 1 << bit_offset +``` + +Based on the length of `bits`, the balance of the address is modified as follows: + +```python +total_bits = len(code) * 8 +corrupted_balance = int(balance * (total_bits - len(bits)) / total_bits) +``` + +## My Solution + +### [-> 31,659,312] Attacking the Contract with the Highest Balance + +First, we will try to acquire the ETH held by the contract address with the highest balance. + +As confirmed on Etherscan's "[Ethereum Top Accounts by ETH Balance](https://etherscan.io/accounts/1?ps=100)", the contract with the highest balance is the Beacon Deposit Contract at [0x00000000219ab540356cBB839Cbe05303d7705Fa](https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa). + +The block number that the challenge server forks from is 18437825, and at that block, this contract holds 31,664,538 ETH. + +``` +$ cast balance 0x00000000219ab540356cBB839Cbe05303d7705Fa -e --block 18437825 +31664538.264999839958004578 +``` + +While there are various ways to think about how to modify the bytecode and obtain the balance, first, we will overwrite the beginning of the bytecode with the simple and versatile `ORIGIN SELFDESTRUCT`. + +The start of this contract's bytecode is `6080`. +So, for instance, if we send a bitflip like the following, we can change it to `ORIGIN SELFDESTRUCT` (`32FF`). + +``` +0x00000000219ab540356cBB839Cbe05303d7705Fa:6:3:1:15:14:13:12:11:10:9 +``` + +Then, by simply calling this contract address, we can obtain the balance. + +Additionally, since the given player account starts with an initial 1000 ETH, sending this to the challenge contract, in the end, will slightly boost a score (as the balance of the challenge contract becomes our score). + +As a result, the score becomes 31,659,312. + +### [-> 44,956,845] Using the Contract List of Top Balances from Google BigQuery + +Now, let's apply the above method to the top 10,000 contract addresses by balance. + +A list of 10,000 contract addresses can be obtained using Google BigQuery. +For example, we can use the following query: + +```sql +SELECT contracts.address, balances.eth_balance +FROM `bigquery-public-data.crypto_ethereum.contracts` AS contracts +JOIN `bigquery-public-data.crypto_ethereum.balances` AS balances +ON balances.address = contracts.address +ORDER BY balances.eth_balance DESC +LIMIT 10000 +``` + +However, since Google BigQuery updates in real-time, this query will not provide data as of block 18437825 that the challenge server forks. +Thus, it is recommended to obtain not just 10,000 but rather 20,000 addresses and use Web3.py or similar to retrieve the balance and contract code for that block to create a precise list. + +In addition to that, obtain a list of contract addresses that sent more than 10 Ether using the following query. +This ensures we capture contract addresses that might currently be outside the top 20,000 list. + +```sql +SELECT DISTINCT traces.from_address +FROM `bigquery-public-data.crypto_ethereum.traces` AS traces +WHERE traces.value > cast('1E19' as NUMERIC) AND traces.block_number >= 18437825 AND traces.block_number <= 18451700 +LIMIT 100000 +``` + +From the above, we will end up with a list like [this](data/contract-list.csv). + +For each of these contract addresses, calculate the score when applying `ORIGIN SELFDESTRUCT`. +Then, If we attack the top 10,000 addresses based on the calculated scores, we will obtain a score of 44,956,845. + +With just these steps, we will surpass the maximum score of 44,947,584 during the CTF. +It means we could have clinched the first place by submitting this simple solution. + +### [-> 44,971,334] Optimizing based on On-chain Simulation + +Currently, our strategy is overwriting the beginning of the bytecode. +However, it is not mandatory to overwrite the start, so we can also overwrite a position somewhere in the middle. + +For instance, the bytecode for the Beacon Deposit Contract starts with `60806040`, which disassembled looks like this: + +``` +PUSH1 80 +PUSH1 40 +``` + +While we previously altered `PUSH1 80`, there is no problem in overwriting `PUSH1 40`. +Modifying the location that yields the highest score would enable us to achieve even better results. + +To determine this optimal location, on-chain simulations are useful. +Various methods are available for on-chain simulations, but for this instance, I utilized a custom reversing tool I developed named [erever](https://github.com/minaminao/erever). +(Note: This tool is optimized for my usage, so it is not strongly recommended for others to use.) + +For the Beacon Deposit Contract, we initially flipped 10 bits for the `PUSH1 80` with `6:3:1:15:14:13:12:11:10:9`. +However, using on-chain simulation, for the strategy of simply overwriting with `ORIGIN SELFDESTRUCT` and then calling, it is found most efficient to modify the following positions: + +``` +0x0042: (0x80) DUP1 +0x0043: (0xfd) REVERT +``` + +The bitflip becomes `0x00000000219ab540356cBB839Cbe05303d7705Fa:534:531:530:528:542`, requiring edits to just 5 bits. + +Noted that this on-chain simulation simplifies several processes. +For example, it stops when it encounters the opcode: `STOP`,`RETURN`,`REVERT`,`INVALID`,`SELFDESTRUCT`, and also for `DELEGATECALL`,`STATICCALL`,`CALLCODE`,`CALL`,`CREATE`,`CREATE2`. +Moreover, since editing a `JUMPDEST` would make it non-jumpable and broken, we avoid modifying `JUMPDEST`. +Furthermore, the trace is stopped after executing 500 instructions. + +Applying the above methods to all contracts results in a score of 44,971,334. + +### [-> 44,973,046] Optimizing with Overwrite Target: `ORIGIN SELFDESTRUCT` or `CALLER SELFDESTRUCT` + +Now, instead of `ORIGIN SELFDESTRUCT`, executing `CALLER SELFDESTRUCT` is also available. +In that case, it would be better to adopt whichever of the two yields a higher score. + +By optimally choosing between these two options, the score marginally increases to 44,973,046. + +### [-> 44,975,043] Optimizing with Calldata: `0x` or `0x11223344` + +Many contracts initially determine whether or not a jump will be executed based on the `JUMPI` instruction resulting from `CALLDATASIZE`. +This is because they check for the function selector. +For instance, the Beacon Deposit Contract looks like this: + +``` +0x0000: (0x60) PUSH1 0x80 +0x0002: (0x60) PUSH1 0x40 +0x0004: (0x52) MSTORE +0x0005: (0x60) PUSH1 0x04 +0x0007: (0x36) CALLDATASIZE +0x0008: (0x10) LT +0x0009: (0x61) PUSH2 0x003f +0x000c: (0x57) JUMPI +``` + +Currently, we send no calldata. +However, if we consider cases where an arbitrary 4-byte calldata is sent, the range of our search to find the optimal solution expands. + +Thus, we perform simulations for both patterns: one without calldata and one with a 4-byte calldata. +Then, we adopt the pattern that gives the highest score. + +With this simple tweak, the score reaches 44,975,043. + +### [-> 44,992,197] Detecting Proxy Contracts and Modifying Implementation Contracts + +Among the top contracts, many are proxy contracts. +If the balance of an implementation contract is positive, modifying the bytecode of the implementation contract allows obtaining the theoretical score without modifying the balance of the proxy contract. + +Thus, in on-chain simulation, when a `DELEGATECALL` or `CALLCODE` execution is detected, we opt to modify the implementation contract. +The address of the implementation contract can be identified by examining the stack before executing `DELEGATECALL` or `CALLCODE`. + +For instance, [0xC61b9BB3A7a0767E3179713f3A5c7a9aeDCE193C](https://etherscan.io/address/0xc61b9bb3a7a0767e3179713f3a5c7a9aedce193c) has a `DELEGATECALL` instruction at position `0x5e`. + +``` +... +0x5c: DUP5 +0x5d: GAS +0x5e: DELEGATECALL +0x5f: RETURNDATASIZE +0x60: PUSH1 0x00 +... +``` + +From the on-chain simulation, we can observe that the content of the stack just before this `DELEGATECALL` instruction is as follows: + +``` +[0x017e19, 0x34cfac646f301356faa8b21e94227e3583fe3f5f, 0x00, 0x00, 0x00, 0x00, 0x34cfac646f301356faa8b21e94227e3583fe3f5f] +``` + +In this case, `0x34cfac646f301356faa8b21e94227e3583fe3f5f` is the address of the implementation contract. + +Moreover, many proxy contracts share the same implementation contracts. +Thus, the number of bitflips required to capture the balance of the top 10,000 contracts, originally set at 10,000, significantly reduces. +Since we have set a constraint to only send 10,000 bitflips, by considering proxy contracts, we can target a broader range of contracts. + +With this optimization, the score reaches 44,992,197. + +### [-> 45,046,618] Performing Replay Attacks + +Lastly, by replaying transactions on the mainnet after block `18437825` on the challenge server's forked network, we can acquire more Ether. + +Among the transactions up to block `18451700`, which is about an hour and a half before the end of the CTF, we list the transactions that are sending more than 100 ETH to contracts. +We get this list by running the following query on Google BigQuery: + +```sql +SELECT transactions.hash, transactions.from_address, transactions.to_address, transactions.value, transactions.nonce +FROM `bigquery-public-data.crypto_ethereum.transactions` AS transactions +JOIN `bigquery-public-data.crypto_ethereum.contracts` AS contracts +ON transactions.to_address = contracts.address +WHERE transactions.value > cast('1E20' as NUMERIC) AND transactions.block_number >= 18437825 AND transactions.block_number <= 18451700 AND transactions.receipt_status = 1 +ORDER BY transactions.block_number, transactions.nonce +LIMIT 1000 +``` + +One important thing when performing replay attacks is the need to consider the nonces. +If the nonce of the target transaction is larger than the nonce at the time of block `18437825`, we will also need to replay transactions with previous nonces. + +To obtain a list of transactions sent by a specific address, we can use the Etherscan API. +Details of the endpoint can be found in "[Get a list of 'Normal' Transactions By Address](https://docs.etherscan.io/api-endpoints/accounts#get-a-list-of-normal-transactions-by-address)". + +Under the constraint of being able to send a maximum of 5 intermediary transactions, we found over a dozen transactions where the replay attack was effective. +We can calculate the score after replaying these transactions and select the optimal 10,000 bitflips. + +By employing this strategy, **the final score amounted to 45,046,618!** +The source code can be found in [solve.py](solve.py). diff --git a/src/ParadigmCTF2023/CosmicRadiation/big-query/100ether-transfer-to-contract.sql b/src/ParadigmCTF2023/CosmicRadiation/big-query/100ether-transfer-to-contract.sql new file mode 100644 index 0000000..ffa111b --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/big-query/100ether-transfer-to-contract.sql @@ -0,0 +1,7 @@ +SELECT transactions.hash, transactions.from_address, transactions.to_address, transactions.value, transactions.nonce +FROM `bigquery-public-data.crypto_ethereum.transactions` AS transactions +JOIN `bigquery-public-data.crypto_ethereum.contracts` AS contracts +ON transactions.to_address = contracts.address +WHERE transactions.value > cast('1E20' as NUMERIC) AND transactions.block_number >= 18437825 AND transactions.block_number <= 18451700 AND transactions.receipt_status = 1 +ORDER BY transactions.block_number, transactions.nonce +LIMIT 1000 diff --git a/src/ParadigmCTF2023/CosmicRadiation/big-query/10ether-transfer-from-given-block.sql b/src/ParadigmCTF2023/CosmicRadiation/big-query/10ether-transfer-from-given-block.sql new file mode 100644 index 0000000..974c528 --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/big-query/10ether-transfer-from-given-block.sql @@ -0,0 +1,4 @@ +SELECT DISTINCT traces.from_address +FROM `bigquery-public-data.crypto_ethereum.traces` AS traces +WHERE traces.value > cast('1E19' as NUMERIC) AND traces.block_number >= 18437825 AND traces.block_number <= 18451700 +LIMIT 100000 diff --git a/src/ParadigmCTF2023/CosmicRadiation/big-query/top20000-contracts.sql b/src/ParadigmCTF2023/CosmicRadiation/big-query/top20000-contracts.sql new file mode 100644 index 0000000..b72e13e --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/big-query/top20000-contracts.sql @@ -0,0 +1,6 @@ +SELECT contracts.address, balances.eth_balance +FROM `bigquery-public-data.crypto_ethereum.contracts` AS contracts +JOIN `bigquery-public-data.crypto_ethereum.balances` AS balances +ON balances.address = contracts.address +ORDER BY balances.eth_balance DESC +LIMIT 20000 diff --git a/src/ParadigmCTF2023/CosmicRadiation/data/100ether-transfer.csv b/src/ParadigmCTF2023/CosmicRadiation/data/100ether-transfer.csv new file mode 100644 index 0000000..50f74b8 --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/data/100ether-transfer.csv @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d3ec2b9acc097904a6d3c79eb3ad1592fb9dc1687e69aa9dd8b14f39064de58 +size 97134 diff --git a/src/ParadigmCTF2023/CosmicRadiation/data/contract-list.csv b/src/ParadigmCTF2023/CosmicRadiation/data/contract-list.csv new file mode 100644 index 0000000..744834e --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/data/contract-list.csv @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f4f2617dffebe42a74680322695951cb8629a9757f33877f896ede65a0c22d1 +size 125509623 diff --git a/src/ParadigmCTF2023/CosmicRadiation/setup_server.py b/src/ParadigmCTF2023/CosmicRadiation/setup_server.py new file mode 100644 index 0000000..38b5cec --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/setup_server.py @@ -0,0 +1,107 @@ +import socket + +import requests +from web3 import Web3 + +CHALLENGE_HOST = "localhost" +CHALLENGE_PORT = 7777 +ANVIL_HOST = "http://localhost" +ANVIL_PORT = 8888 +ANVIL_URL = f"{ANVIL_HOST}:{ANVIL_PORT}" +w3 = Web3(Web3.HTTPProvider(ANVIL_URL)) + + +def request_anvil(method: str, params: list): + headers = {"Content-Type": "application/json"} + data = {"method": method, "params": params, "id": 1, "jsonrpc": "2.0"} + + response = requests.post(ANVIL_URL, json=data, headers=headers) + return response.json() + + +def anvil_setBalance(addr: str, balance: str): + print("anvil_setBalance", addr, balance, request_anvil("anvil_setBalance", [addr, balance])) + + +def anvil_setCode(addr: str, code: str): + print("anvil_setCode", addr, "***", request_anvil("anvil_setCode", [addr, code])) + + +while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((CHALLENGE_HOST, CHALLENGE_PORT)) + s.listen() + + print(f"Waiting for a connection on {CHALLENGE_HOST}:{CHALLENGE_PORT}") + conn, addr = s.accept() + + with conn: + try: + print(f"Connected by {addr}") + conn.sendall(b"ticket? ") + _ticket = conn.recv(1024).strip() + conn.sendall(b"action? ") + action = conn.recv(1024).strip() + + if action == b"2": + conn.close() + continue + + assert action == b"1" + + corrupted_addrs = {} + + while True: + conn.sendall(b"bitflip? ") + + bitflip = conn.recv(1024).strip().decode() + + if bitflip == "": + break + + (addr, *bits) = bitflip.split(":") + addr = Web3.to_checksum_address(addr) + bits = [int(v) for v in bits] + + if addr in corrupted_addrs: + raise Exception("already corrupted this address") + + corrupted_addrs[addr] = True + + balance = w3.eth.get_balance(addr) + if balance == 0: + raise Exception("invalid target") + + code = bytearray(w3.eth.get_code(addr)) + for bit in bits: + byte_offset = bit // 8 + bit_offset = 7 - bit % 8 + if byte_offset < len(code): + code[byte_offset] ^= 1 << bit_offset + + total_bits = len(code) * 8 + corrupted_balance = int(balance * (total_bits - len(bits)) / total_bits) + + anvil_setBalance(addr, hex(corrupted_balance)) + anvil_setCode(addr, "0x" + code.hex()) + + conn.sendall(f" - {ANVIL_URL}\n".encode()) + PLAYER_PRIVATE_KEY = "0x3c7bada9137558d226f16ad476d107bcedd9346d7bf08cc01b9df4b885c1807b" # cast wallet new + PLAYER_ADDR = "0xFe26e3dCAdE6660909C5E6A71D964d70589A2Ae6" + anvil_setBalance(PLAYER_ADDR, hex(1000 * 10**18)) + conn.sendall(f"private key: {PLAYER_PRIVATE_KEY}\n".encode()) + + CHALLENGE_CONTRACT_ADDR = "0x2b7fF125061edbC692dF5CF25528323adb738Eb1" + CHALLENGE_CONTRACT_CODE = "0x6080604052348015600e575f80fd5b50600436106026575f3560e01c8063afd8206714602a575b5f80fd5b60306044565b604051603b91906061565b60405180910390f35b5f47905090565b5f819050919050565b605b81604b565b82525050565b5f60208201905060725f8301846054565b9291505056fea264697066735822122063d06b0aaf3ba599d040b39fb5d1de2423621c87865ab1817f0ef558d6f35acc64736f6c63430008160033" + conn.sendall(f"challenge contract: {CHALLENGE_CONTRACT_ADDR}\n".encode()) + anvil_setCode(CHALLENGE_CONTRACT_ADDR, CHALLENGE_CONTRACT_CODE) + except Exception as e: + print(e) + + try: + conn.close() + except: + pass + + continue diff --git a/src/ParadigmCTF2023/CosmicRadiation/solve.py b/src/ParadigmCTF2023/CosmicRadiation/solve.py new file mode 100644 index 0000000..a95cafb --- /dev/null +++ b/src/ParadigmCTF2023/CosmicRadiation/solve.py @@ -0,0 +1,580 @@ +import os +import pickle +import time +from dataclasses import dataclass +from pathlib import Path + +import requests +from erever.assemble import assemble +from erever.context import Context +from erever.disassemble import DisassembleResult, disassemble +from eth_account._utils.legacy_transactions import encode_transaction, serializable_unsigned_transaction_from_dict +from eth_account._utils.typed_transactions import TypedTransaction +from pwn import remote +from tqdm import tqdm +from web3 import Web3 + +BASE_DIR = Path(__file__).resolve().parent + +STEP = 7 + +MAX_N_BITFLIP = 10000 if STEP >= 2 else 1 +MAX_N_REPLAY = 1000 if STEP >= 7 else 0 +MAX_N_CONTRACTS_TO_ATTACK_ONCE = 500 +MAX_N_REPLAY_INTERMEDIATE_TXS = 5 +MAX_STEPS_IN_SIMULATION = 500 if STEP >= 3 else 1 +USE_PROXY_STRATEGY = True if STEP >= 6 else False +USE_CALLER = True if STEP >= 4 else False +USE_CALLDATA4 = True if STEP >= 5 else False + +# anvil --fork-url $RPC_MAINNET --fork-block-number 18437825 +# This is NOT the RPC endpoint of the challenge server. +RPC_URL = "http://127.0.0.1:8545" +BLOCK_NUMBER = 18437825 + +RPC_MAINNET_URL = os.getenv("RPC_MAINNET") +assert RPC_MAINNET_URL is not None +ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY") +assert ETHERSCAN_API_KEY is not None + +DEBUG_PRINT = False + +LOCAL = True +CHALLENGE_HOST: str = "localhost" if LOCAL else "cosmic-radiation.challenges.paradigm.xyz" +CHALLENGE_PORT: str = "7777" if LOCAL else "1337" + + +@dataclass +class Contract: + addr: str + balance: int + code: bytes + + +@dataclass +class Strategy: + addr: str + bitflip_addr: str + bitflip: str + context_strategy: dict + estimated_score: int + + +class Web3Cache: + """ + Cache for RPC requests: + """ + + CACHE_FILE: Path = BASE_DIR / "web3-cache.pickle" + + w3: Web3 + block_number: int + cache: dict + + def __init__(self, rpc_url=RPC_URL, block_number=BLOCK_NUMBER): + self.w3 = Web3(Web3.HTTPProvider(rpc_url)) + self.block_number = block_number + self.cache = pickle.load(self.CACHE_FILE.open("rb")) if self.CACHE_FILE.exists() else {} + + def get_balance(self, addr): + key = "get_balance:" + addr + if key in self.cache: + return self.cache[key] + balance = self.w3.eth.get_balance(addr) + self.cache[key] = balance + return balance + + def get_code(self, addr): + key = "get_code:" + addr + if key in self.cache: + return self.cache[key] + code = self.w3.eth.get_code(addr) + self.cache[key] = code + return code + + def get_transaction_count(self, addr): + key = "get_transaction_count:" + addr + if key in self.cache: + return self.cache[key] + nonce = self.w3.eth.get_transaction_count(addr) + self.cache[key] = nonce + return nonce + + def get_transaction(self, tx_hash): + key = "get_transaction:" + tx_hash + if key in self.cache: + return self.cache[key] + tx = self.w3.eth.get_transaction(tx_hash) + self.cache[key] = tx + return tx + + def save(self): + pickle.dump(self.cache, self.CACHE_FILE.open("wb")) + + +w3c = Web3Cache() + + +class DisassembleCache: + CACHE_FILE: Path = BASE_DIR / "disassemble-cache.pickle" + + cache: dict + updated: bool = False + + def __init__(self): + print("loading disassemble cache...") + self.cache = pickle.load(self.CACHE_FILE.open("rb")) if self.CACHE_FILE.exists() else {} + print("done") + + def disas(self, context_strategy: dict, silent, trace, max_steps, return_trace_logs) -> DisassembleResult: + key = str(context_strategy) + str(silent) + str(trace) + str(max_steps) + str(return_trace_logs) + if key in self.cache: + return self.cache[key] + result = disassemble(context=Context.from_dict(context_strategy), silent=silent, trace=trace, max_steps=max_steps, return_trace_logs=return_trace_logs) + self.cache[key] = result + self.updated = True + return result + + def save(self): + if not self.updated: + return + pickle.dump(self.cache, self.CACHE_FILE.open("wb")) + + +disassemble_cache = DisassembleCache() + + +def calculate_corrupted_balance(bitflip: str, balance=None, code=None): + """ + NOTE: This is NOT working for a proxy contract. + """ + (addr, *bits) = bitflip.split(":") + bits = [int(v) for v in bits] + + balance = w3c.get_balance(addr) if balance is None else balance + if balance == 0: + raise Exception("invalid target") + + code = bytearray(w3c.get_code(addr)) if code is None else bytearray(code) + for bit in bits: + byte_offset = bit // 8 + bit_offset = 7 - bit % 8 + if byte_offset < len(code): + code[byte_offset] ^= 1 << bit_offset + + total_bits = len(code) * 8 + corrupted_balance = int(balance * (total_bits - len(bits)) / total_bits) + return corrupted_balance + + +def generate_bitflip(payload, addr, code, pc): + assert pc + len(payload) < len(code) + modified_bits = [] + for i in range(0, len(payload)): + d = payload[i] ^ code[pc + i] + for j in range(8): + if d & (1 << j): + modified_bits.append(pc * 8 + i * 8 + (7 - j)) + bitflip = f"{addr}:{':'.join([str(b) for b in modified_bits])}" + return bitflip + + +def construct_raw_tx(tx: dict): + tx["data"] = tx["input"] + del tx["blockHash"], tx["blockNumber"], tx["from"], tx["hash"], tx["input"], tx["transactionIndex"] + + if "type" in tx and tx["type"] != 0: + del tx["gasPrice"] + typed_tx = TypedTransaction.from_dict(tx) + raw_tx = typed_tx.encode() + else: + v, r, s = tx["v"], int(tx["r"].hex(), 16), int(tx["s"].hex(), 16) + del tx["type"], tx["v"], tx["r"], tx["s"] + legacy_tx = serializable_unsigned_transaction_from_dict(tx) + raw_tx = encode_transaction(legacy_tx, vrs=(v, r, s)) + + return raw_tx + + +def fetch_successful_txs_by_address_between_blocks(addr, start_block, end_block): + result = requests.get( + "https://api.etherscan.io/api", + params={ + "module": "account", + "action": "txlist", + "address": addr, + "startblock": start_block, + "endblock": end_block, + "page": 1, + "offset": 10, + "sort": "asc", + "apikey": ETHERSCAN_API_KEY, + }, + ) + result_json = result.json() + assert result_json["status"] == "1" + + txs = result_json["result"] + successful_txs = [] + + for tx in txs: + if tx["txreceipt_status"] != "1": + continue + successful_txs.append(tx) + + return successful_txs + + +def load_contracts(): + contracts: list[Contract] = [] + contract_addrs = set() + with open(BASE_DIR / "data/contract-list.csv", "r") as f: + lines = f.readlines() + for line in lines: + addr, balance, code_hex = line.strip().split(",") + contracts.append(Contract(addr, int(balance), bytes.fromhex(code_hex))) + contract_addrs.add(addr) + contracts = contracts[: MAX_N_BITFLIP * 3 // 2 + 1000] + + replay_target_addrs = set() + replay_earn = {} + replay_tx_hashes = [] + + addr_to_nonce = {} + loss_by_nonce_mismatch = 0 + loss_by_insufficient_fund = 0 + to_addrs_in_replay_intermediate_txs = set() + + with open(BASE_DIR / "data/100ether-transfer.csv", "r") as f: + transfer_lines = f.readlines()[1:] + for line in tqdm(transfer_lines[:MAX_N_REPLAY]): + # deserialize + tx_hash, from_addr, to_addr, value, nonce = line.strip().split(",") + from_addr = Web3.to_checksum_address(from_addr) + to_addr = Web3.to_checksum_address(to_addr) + value = int(value) + nonce = int(nonce) + + from_balance = w3c.get_balance(from_addr) + to_balance = w3c.get_balance(to_addr) + + if from_balance < value: + loss_by_insufficient_fund += from_balance // 10**18 + continue + + if to_balance == 0: + continue + + start_nonce = w3c.get_transaction_count(from_addr) if from_addr not in addr_to_nonce else addr_to_nonce[from_addr] + + if nonce - start_nonce >= MAX_N_REPLAY_INTERMEDIATE_TXS: + loss_by_nonce_mismatch += value // 10**18 + continue + + tmp_replay_intermediate_tx_hashes = [] + tmp_to_addrs_in_replay_intermediate_txs = set() + if nonce - start_nonce >= 1: + END_BLOCK_NUMBER = 18451700 + successful_txs_by_address_between_blocks = fetch_successful_txs_by_address_between_blocks(from_addr, BLOCK_NUMBER, END_BLOCK_NUMBER) + valid = True + for tx in successful_txs_by_address_between_blocks: + if tx_hash == tx["hash"]: + break + to_addr_in_replay_intermediate_tx = Web3.to_checksum_address(tx["to"]) + to_addr_in_replay_intermediate_tx_balance = w3c.get_balance(to_addr_in_replay_intermediate_tx) + if to_addr_in_replay_intermediate_tx_balance == 0: + valid = False + break + if w3c.get_code(to_addr_in_replay_intermediate_tx) != b"": + tmp_to_addrs_in_replay_intermediate_txs.add(to_addr_in_replay_intermediate_tx) + tmp_replay_intermediate_tx_hashes.append(tx["hash"]) + if not valid: + continue + + addr_to_nonce[from_addr] = nonce + 1 + + replay_target_addrs.add(to_addr) + replay_earn[to_addr] = replay_earn.get(to_addr, 0) + value + + if to_addr not in contract_addrs: + contract_addrs.add(to_addr) + to_code = bytes(w3c.get_code(to_addr)) + contracts.append(Contract(to_addr, to_balance, to_code)) + + to_addrs_in_replay_intermediate_txs.update(tmp_to_addrs_in_replay_intermediate_txs) + replay_tx_hashes.extend(tmp_replay_intermediate_tx_hashes) + replay_tx_hashes.append(tx_hash) + + print("replay target addrs:", len(replay_target_addrs)) + print(f"{loss_by_nonce_mismatch=}, {loss_by_insufficient_fund=}") + print(f"total replay earn: {sum(replay_earn.values())//10**18}") + + contracts.sort(key=lambda x: x.balance, reverse=True) + + return contracts, replay_target_addrs, replay_earn, replay_tx_hashes, to_addrs_in_replay_intermediate_txs + + +def find_optimal_strategies(contracts: list[Contract], replay_target_addrs: set, replay_earn: dict, to_addrs_in_replay_intermediate_txs: set): + GENERAL_PAYLOADS = ( + [ + assemble("ORIGIN SELFDESTRUCT"), + assemble("CALLER SELFDESTRUCT"), + ] + if USE_CALLER + else [ + assemble("ORIGIN SELFDESTRUCT"), + ] + ) + REPLAY_ATTACK_PAYLOAD = assemble("CALLVALUE PUSH1 0x06 JUMPI ORIGIN SELFDESTRUCT JUMPDEST STOP") + REPLAY_ATTACK_INTERMEDIATE_PAYLOAD = assemble("STOP") + + strategies: list[Strategy] = [] + + for contract in tqdm(contracts): + (addr, balance, code) = (contract.addr, contract.balance, contract.code) + + CONTEXT_STRATEGIES = ( + [ + {"bytecode": code.hex(), "number": BLOCK_NUMBER, "address": int(addr, 16), "gas": 100000, "rpc_url": RPC_URL, "timestamp": 1698364865}, + {"bytecode": code.hex(), "number": BLOCK_NUMBER, "address": int(addr, 16), "gas": 100000, "rpc_url": RPC_URL, "timestamp": 1698364865, "calldata": "0x11223344"}, + ] + if USE_CALLDATA4 + else [ + {"bytecode": code.hex(), "number": BLOCK_NUMBER, "address": int(addr, 16), "gas": 100000, "rpc_url": RPC_URL, "timestamp": 1698364865}, + ] + ) + + best_bitflip_addr = None + best_bitflip = None + best_context_strategy = None + best_score = 0 + use_replay_attack = True + + if addr in replay_target_addrs: + assert len(code) >= len(REPLAY_ATTACK_PAYLOAD) + + best_bitflip_addr = addr + best_bitflip = generate_bitflip(REPLAY_ATTACK_PAYLOAD, addr, code, 0) + best_score = calculate_corrupted_balance(best_bitflip, balance + replay_earn[addr], code) + best_context_strategy = CONTEXT_STRATEGIES[0] + + for context_strategy in CONTEXT_STRATEGIES: + result_trace: DisassembleResult = disassemble_cache.disas(context_strategy=context_strategy, silent=True, trace=True, max_steps=MAX_STEPS_IN_SIMULATION, return_trace_logs=True) + + END_OPCODES = [ + "STOP", + "RETURN", + "REVERT", + "INVALID", + "SELFDESTRUCT", + "DELEGATECALL", + "STATICCALL", + "CALLCODE", + "CALL", + "CREATE", + "CREATE2", + ] + + use_proxy_strategy = False + if USE_PROXY_STRATEGY: + for trace_log in result_trace.trace_logs: + if DEBUG_PRINT: + print(trace_log.stack_before_execution.to_string()) + print(trace_log.mnemonic_raw) + if trace_log.mnemonic_raw in ["DELEGATECALL", "CALLCODE"]: + impl_addr = Web3.to_checksum_address("0x" + hex(trace_log.stack_before_execution[-2])[2:].zfill(40)) + impl_balance = w3c.get_balance(impl_addr) + impl_code = w3c.get_code(impl_addr) + if impl_balance > 0 and len(impl_code) >= 2: + use_proxy_strategy = True + + if trace_log.mnemonic_raw in END_OPCODES: + break + + if use_proxy_strategy: + use_replay_attack = False + bitflip = generate_bitflip(GENERAL_PAYLOADS[0], impl_addr, impl_code, 0) + best_score = balance + best_bitflip_addr = impl_addr + best_bitflip = bitflip + best_context_strategy = context_strategy + else: + for pc, mnemonic, _push_v in result_trace.disassemble_code: + if mnemonic == "JUMPDEST": + continue + for payload in GENERAL_PAYLOADS: + if pc + len(payload) >= len(code): + continue + bitflip = generate_bitflip(payload, addr, code, pc) + tmp_score = calculate_corrupted_balance(bitflip, balance, code) + if tmp_score > best_score: + best_score = tmp_score + best_bitflip_addr = addr + best_bitflip = bitflip + best_context_strategy = context_strategy + use_replay_attack = False + + if mnemonic in END_OPCODES: + break + if "?" in mnemonic: + break + + if DEBUG_PRINT: + print(best_bitflip.count(":"), (balance - best_score) // 10**18) + + strategies.append(Strategy(addr, best_bitflip_addr, best_bitflip, best_context_strategy, best_score)) + + if addr in replay_target_addrs and not use_replay_attack: + replay_target_addrs.remove(addr) + + if addr in to_addrs_in_replay_intermediate_txs: + to_addrs_in_replay_intermediate_txs.remove(addr) + + strategies.sort(key=lambda x: x.estimated_score, reverse=True) + + replay_intermediate_strategies = [] + for addr in to_addrs_in_replay_intermediate_txs: + bitflip = generate_bitflip(REPLAY_ATTACK_INTERMEDIATE_PAYLOAD, addr, w3c.get_code(addr), 0) + replay_intermediate_strategies.append(Strategy(addr, addr, bitflip, CONTEXT_STRATEGIES[0], 0)) + strategies = replay_intermediate_strategies + strategies + + count = 0 + addr_to_bitflip = {} + new_strategies = [] + for strategy in strategies: + if count >= MAX_N_BITFLIP: + break + new_strategies.append(strategy) + if strategy.bitflip_addr in addr_to_bitflip: + assert addr_to_bitflip[strategy.bitflip_addr] == strategy.bitflip + continue + addr_to_bitflip[strategy.bitflip_addr] = strategy.bitflip + count += 1 + strategies = new_strategies + + total_estimated_score = 0 + for strategy in strategies: + total_estimated_score += strategy.estimated_score + total_estimated_score += 999 * 10**18 + + print(f"{total_estimated_score//10**18=}") + print(f"{len(strategies)=}") + + return strategies, total_estimated_score + + +def setup_challenge_server(strategies: list[Strategy]): + r = remote(CHALLENGE_HOST, CHALLENGE_PORT, level="debug") + r.recvuntil(b"ticket? ") + r.sendline(b"DUMMY TICKET") + r.recvuntil(b"action? ") + r.sendline(b"2") + r.close() + + time.sleep(1) + + r = remote(CHALLENGE_HOST, CHALLENGE_PORT, level="debug") + r.recvuntil(b"ticket? ") + r.sendline(b"DUMMY TICKET") + r.recvuntil(b"action? ") + r.sendline(b"1") + + r.recvuntil(b"bitflip? ") + + # NOTE: the below code can be changed to use batch sending + addr_to_bitflip = {} + for strategy in strategies: + text = strategy.bitflip + "\n" + if strategy.bitflip_addr in addr_to_bitflip: + assert addr_to_bitflip[strategy.bitflip_addr] == strategy.bitflip + continue + addr_to_bitflip[strategy.bitflip_addr] = strategy.bitflip + r.sendline(text.encode()) + r.recv() + + r.sendline(b"") + r.recvuntil(b" - ") + rpc_endpoint = r.recvline().strip().decode() + r.recvuntil(b"private key:") + private_key = r.recvline().strip().decode() + r.recvuntil(b"challenge contract:") + challenge_addr = r.recvline().strip().decode() + r.close() + + return rpc_endpoint, private_key, challenge_addr + + +def generate_broadcast_script(replay_tx_hashes: list, replay_target_addrs, strategies: list[Strategy], total_estimated_score, rpc_endpoint, private_key, challenge_addr): + with open(BASE_DIR / "broadcast.sh", "w") as f: + commands = [ + # "set -ex", + f"export FOUNDRY_ETH_RPC_URL={rpc_endpoint}", + f"export PRIVATE_KEY={private_key}", + "player_address=$(cast wallet address --private-key $PRIVATE_KEY)", + 'exploit_address=$(forge create src/ParadigmCTF2023/CosmicRadiation/Exploit.s.sol:SelfDestruct --private-key $PRIVATE_KEY --legacy --json | jq ".deployedTo" -r)', + # reduce gas price + "cast send $(cast --address-zero) --private-key $PRIVATE_KEY", + ] + + w3 = Web3(Web3.HTTPProvider(RPC_MAINNET_URL)) + for tx_hash in replay_tx_hashes: + tx = dict(w3.eth.get_transaction(tx_hash)) + if tx["to"] not in replay_target_addrs: + continue + raw_tx = construct_raw_tx(tx) + + commands.append(f"cast publish {raw_tx.hex()} --async") + + commands.append('echo "exploit contract: $(cast balance $exploit_address --ether)"') + commands.append('echo " origin: $(cast balance $player_address --ether)"') + + for i in range(0, len(strategies), MAX_N_CONTRACTS_TO_ATTACK_ONCE): + calldata_to_exploiting_addrs = {} + for j in range(i, min(i + MAX_N_CONTRACTS_TO_ATTACK_ONCE, len(strategies))): + context_strategy = strategies[j].context_strategy + calldata = context_strategy.get("calldata", "0x") + calldata_to_exploiting_addrs[calldata] = calldata_to_exploiting_addrs.get(calldata, []) + [strategies[j].addr] + + for arg_calldata, arg_exploiting_addr in calldata_to_exploiting_addrs.items(): + exploiting_addrs_text = f'[{",".join(arg_exploiting_addr)}]' + + if arg_calldata != "0x": + exploit_command = f'cast send $exploit_address "exploit(bytes,address[])" "{arg_calldata}" "{exploiting_addrs_text}" --private-key $PRIVATE_KEY --legacy --gas-limit 30000000' + else: + exploit_command = f'cast send $exploit_address "exploit(address[])" "{exploiting_addrs_text}" --private-key $PRIVATE_KEY --legacy --gas-limit 30000000' + + commands.append(exploit_command) + commands.append("sleep 0.5") + + commands.append('echo "exploit contract: $(cast balance $exploit_address --ether)"') + commands.append('echo " origin: $(cast balance $player_address --ether)"') + commands.append("sleep 0.5") + + commands.extend( + [ + 'value=$(python -c "import sys; print(int(sys.argv[1]) - 10 ** 17)" $(cast balance $player_address))', + f'cast send $exploit_address "destruct(address)" {challenge_addr} --private-key $PRIVATE_KEY --value $value --legacy', + 'echo " SCORE: ' + f'$(cast balance {challenge_addr} --ether)"', + 'echo "ESTIMATED SCORE: ' + f'{total_estimated_score//10**18}"', + ] + ) + + f.write("\n".join(commands)) + + +def main(): + contracts, replay_target_addrs, replay_earn, replay_tx_hashes, to_addrs_in_replay_intermediate_txs = load_contracts() + + strategies, total_estimated_score = find_optimal_strategies(contracts, replay_target_addrs, replay_earn, to_addrs_in_replay_intermediate_txs) + + disassemble_cache.save() + + rpc_endpoint, private_key, challenge_addr = setup_challenge_server(strategies) + + generate_broadcast_script(replay_tx_hashes, replay_target_addrs, strategies, total_estimated_score, rpc_endpoint, private_key, challenge_addr) + + w3c.save() + + +if __name__ == "__main__": + main()