Skip to main content
Luna Tong

Hosting an Ethereum CTF Challenge, the Easy Way

Painless smart contract CTF challenges using Paradigm’s CTF framework
Article heading

Background

Zellic recently sponsored CSAW 2022. CSAW is an annual cybersecurity event hosted by NYU in Brooklyn, New York. Over the years, it’s become a centerpiece of the US/Canada CTF scene. Each year, CSAW CTF attracts the top high school and college CTF players. In fact, my cofounder, Jazzy, and I actually met at CSAW 2017—where we subsequently formed the team which would eventually go on to become perfect blue. Thus, we saw CSAW’22 as an opportunity to give back to the CTF community.

As a sponsor, we had the chance to contribute a challenge to the CTF. I decided to create a simple Ethereum smart contract challenge. The challenge would revolve around exploiting a vulnerable smart contract. However, the CTF players would need to be able to interact with the smart contract, without leaking their solution or exploit to other players. This is a common problem when creating blockchain CTF challenges.

Luckily, samczsun and the rest of the team at Paradigm have developed an excellent framework for deploying and hosting this kind of CTF challenge. It solves the problem by deploying a private, forked blockchain instance (with Anvil) for each participant on-demand. Here’s what that looks like in practice:

$ nc ctf.example.xyz 31337
1 - launch new instance
2 - kill instance
3 - get flag
action? 1
ticket please: ticket

your private blockchain has been deployed
it will automatically terminate in 30 minutes
here's some useful information
uuid: 12341234-12341234-12341234
rpc endpoint: http://ctf.example.xyz:8545/12341234-12341234-12341234")
private key: 0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd
setup contract: 0x12341234123412341234

The framework is easy to use, albeit not thoroughly documented. Hopefully, this post will provide a practical example on setting up the framework for your own CTF.

The smart contract

Before we dive into using the CTF framework, let’s also quickly discuss the smart contracts themselves. This will help provide useful context later.

The smart contract is a straightforward vault which allows users to deposit and withdraw both USDC and DAI. However, when withdrawing, if the vault’s balance of the desired token is insufficient, it performs a swap on Uniswap to get more of it.

contract Chal {
// ...
function withdrawDAI(uint amountOut) public {
require(balanceOf[msg.sender] >= amountOut);
balanceOf[msg.sender] -= amountOut;

if (dai.balanceOf(address(this)) < amountOut*10e12) {
address[] memory path;
path = new address[](2);
path[0] = USDC;
path[1] = DAI;

uint[] memory amounts = router.swapExactTokensForTokens(
usdc.balanceOf(address(this)), 0, path, address(this), block.timestamp
);
}

dai.transfer(msg.sender, amountOut*10e12);
}
// ...
}

The problem here is the lack of slippage checks. An attacker can easily drain the contract by causing the contract to repeatedly make bad trades at unfavorable prices. For instance, this could be done through price manipulation. The attacker then profits off of the resulting price impact.

To exploit this contract, the attacker needs USDC and DAI. Thus, the testnet we host for CTF players should be a mainnet fork so the usual DeFi facilities (Uniswap, WETH, etc.) will be available to them.

Our challenge contract (Chal.sol) is deployed and set up by a setup contract. The following code gives an idea of how our on-chain contracts plug into the CTF framework, so please don’t gloss over it:

pragma solidity ^0.8.13;

import "./Chal.sol";

contract Setup {
Chal public immutable TARGET; // Contract the player will hack

// Hardcoded constants (same as mainnet)
address private constant UNISWAP_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER);
IERC20 private dai = IERC20(DAI);
IERC20 private usdc = IERC20(USDC);
IWETH9 private weth = IWETH9(WETH);

// Used for tracking if the player has solved the challenge or not
uint private initialBalance;

constructor() payable {
// Deploy the victim contract
TARGET = new Chal();

// Our setup contract will be called with 100 ether by our setup script in the CTF framework.
// Double-check that it's provided 100 ether.
//
// This also lets the CTF player know what the Setup contract is
// called with, thereby eliminating guessing from the challenge.
//
// We don't share the setup script with the player, but we do give them
// this setup contract as part of the challenge handout.
require(msg.value == 100 ether);

// We want some DAI and USDC in the vault initially for the player to steal.
// Turn the 100 ether into USDC and DAI using various Defi primitives.
weth.deposit{ value: 100 ether }();
weth.approve(address(router), type(uint256).max);
dai.approve(address(router), type(uint256).max);
usdc.approve(address(router), type(uint256).max);

address[] memory path;
path = new address[](2);

// swap half our initial eth to usdc on uniswap
path[0] = WETH; path[1] = USDC;
amounts = router.swapExactTokensForTokens(
50 ether, 0, path, address(TARGET), block.timestamp
);

// swap half our initial eth to dai on uniswap
path[0] = WETH; path[1] = DAI;
amounts = router.swapExactTokensForTokens(
50 ether, 0, path, address(TARGET), block.timestamp
);

initialBalance = curTargetBalance();
}

// Helper function
function curTargetBalance() public view returns (uint) {
return usdc.balanceOf(address(TARGET)) + dai.balanceOf(address(TARGET))/10e12;
}

// Our challenge in the CTF framework will call this function to
// check whether the player has solved the challenge or not.
function isSolved() public view returns (bool) {
return curTargetBalance() < (initialBalance / 10);
}
}

While developing the challenge’s smart contracts, I used Foundry to test locally. It’s easy to test my own solution to the challenge by just making a Foundry test.

pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Exploit.sol";

contract CSAWTest is Test {
Setup chal;

function setUp() public {
chal = new Setup{value: 100 ether}();
}

function testExploit() public {
Exploit exploit = new Exploit{value: 100 ether}(chal);
require(chal.isSolved());
}
}

Setting up the CTF framework

First, I just made a new virtual server (for me, a new droplet on DigitalOcean). I overprovisioned the server a lot since it’s cheap (will only last for 48 hours) and means I don’t have to worry as much about players overloading it.

I also made a new project in Quicknode so I’d have a RPC endpoint to use. Alchemy/Infura/etc is fine too.

For all of this, I just did everything as root. I don’t care because it’s a single-purpose virtual machine and will be discarded after the CTF is over.

As per the instructions in the Paradigm CTF 2022 repo, install dependencies:

  • Installed Docker (copy pasted commands from here)
  • mpwn: git clone https://github.com/lunixbochs/mpwn
  • python3: apt install -y python3 python3-dev python3-pip
  • libgmp: apt install -y libgmp-dev build-essential
  • other dependencies: pip install yaml ecdsa pysha3 web3

Each challenge is based on this Docker image. Clone this repo and copy that directory out.

$ git clone https://github.com/paradigmxyz/paradigm-ctf-infrastructure
$ mv paradigm-ctf-infrastructure/images/eth-challenge-base my-chal
$ cd my-chal

Add this to the end of the Dockerfile (taken from one of the challenges from Paradigm CTF):

COPY deploy/ /home/ctf/

COPY contracts /tmp/contracts

RUN true \
&& cd /tmp \
&& /root/.foundry/bin/forge build --out /home/ctf/compiled \
&& rm -rf /tmp/contracts \
&& true

Make a directory called contracts. This is where your contracts will live.

$ mkdir contracts

Also make a directory called deploy and put this in deploy/chal.py. This is your setup script that deploys your Setup contract.

import json
from pathlib import Path

import eth_sandbox
from web3 import Web3


def deploy(web3: Web3, deployer_address: str, player_address: str) -> str:
rcpt = eth_sandbox.sendTransaction(web3, {
"from": deployer_address,
"value": Web3.toWei(100, 'ether'), # our Setup contract expects 100 ether. So let's give it 100 ether.
"data": json.loads(Path("compiled/Setup.sol/Setup.json").read_text())["bytecode"]["object"],
})

return rcpt.contractAddress

eth_sandbox.run_launcher([
eth_sandbox.new_launch_instance_action(deploy),
eth_sandbox.new_kill_instance_action(),
eth_sandbox.new_get_flag_action() # the implementation of this calls isSolved() on Setup contract
])

Now you can build the Docker image like this:

$ docker buildx build --platform linux/amd64 -t mytag .

Note that mytag can be whatever you want. And ’.’ just refers to the current directory where the Dockerfile is living.

Now you can run the Docker image for our challenge with this command:

# adjust these as you see fit
IMAGE=mytag
PORT=31337
HTTP_PORT=8545

exec docker run \
-e "PORT=$PORT" \
-e "HTTP_PORT=$HTTP_PORT" \
-e "ETH_RPC_URL=$ETH_RPC_URL" \
-e "FLAG=$FLAG" \
-e "PUBLIC_IP=$PUBLIC_IP" \
-p "$PORT:$PORT" \
-p "$HTTP_PORT:$HTTP_PORT" \
"$IMAGE"

Now you should have a working CTF challenge. You can netcat to the port the challenge is listening on (in this example, port 31337) and you should be greeted by the CTF framework.

Setting the secret key (important)

It is very important to set the environment variable SECRET_KEY to a random, secret value. Otherwise, participants can directly make RPC calls to the Anvil instance, cheesing the challenge. For more info, you’d want to look the implementations of server.py and auth.py.

Thanks to rkm0959 of Super Guesser for pointing this out!

Testing against the remote

All good CTFs must have health checks using real solve scripts on its challenges. This is extremely important for quality control and liveness during the CTF.

We can test our live, remote environment using an exploit contract with Forge:

RPC_URL="http://1.3.3.7:8545/f2e36d63-78fa-4e55-9319-ac072868497d" \
PRIVATE_KEY="0xf42b8f8e5cbb128b54327182c5399c01dc90a0349239a86963aa7c94a2e1c4db" \
SETUP_CONTRACT="0xa56B24969e7f742e4EF721d5FD647896F0758A48" \
forge create Exploit.sol:Exploit --rpc-url $RPC_URL --private-key $PRIVATE_KEY --constructor-args $SETUP_CONTRACT --value 100ether

Note this doesn’t implement any kind of interaction with the launcher netcat server (i.e., the one listening on port 31337). Ideally, you should also have a script to communicate with that, launch new instances, and parse the communications.

Customizing the ticket stuff (Anti-DoS)

In Paradigm CTF, they use a “ticket” system to prevent abuse / DoS. They do this by assigning each account in the CTF website (i.e., ctf.paradigm.xyz, CTFd, whatever) a ticket. Then, each ticket can only have one blockchain instance deployed at a time. This means that you’d have to register multiple accounts for the CTF, which is presumably behind something like a Captcha.

The way this is hooked up to the CTF framework is that they call some web endpoint to check whether a ticket is valid. This code lives in eth_sandbox/launcher.py:

def check_ticket(ticket: str) -> Ticket:
if ENV == "dev":
return Ticket(challenge_id=CHALLENGE_ID, team_id="team")

ticket_info = requests.get(
f"https://us-central1-paradigm-ctf-2022.cloudfunctions.net/checkTicket?ticket={ticket}"
).json()
if ticket_info["status"] != "VALID":
return None

return Ticket(
challenge_id=ticket_info["challengeId"], team_id=ticket_info["teamId"]
)

So you’d need to replace this hardcoded URL with your own endpoint.

For me, I didn’t want to bother with that, so I just completely replaced this functionality with some generic CTF PoW stuff. This is provided purely as an illustrative example:

def check_ticket(ticket: str) -> Ticket:
if len(ticket) > 100 or len(ticket) < 8:
print('invalid ticket length')
return None
if not all(c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for c in ticket):
print('ticket must be alphanumeric')
return None
m = hashlib.sha256()
m.update(ticket.encode('ascii'))
digest1 = m.digest()
m = hashlib.sha256()
m.update(digest1 + ticket.encode('ascii'))
if not m.hexdigest().startswith('0000000'):
print('PoW: sha256(sha256(ticket) + ticket) must start with 0000000')
print('(digest was ' + m.hexdigest() + ')')
return None
print('This ticket is your TEAM SECRET. Do NOT SHARE IT!')
return Ticket(challenge_id=CHALLENGE_ID, team_id=ticket)

Note that this PoW doesn’t have a nonce. This is so that players only need to solve the PoW once, and then can just reuse their secret ticket thereafter, as a quality of life hack. With this basic scheme, there’s nothing stopping players from parallelizing multiple instances of PoW solver and amassing a lot of valid tickets to DoS you.

If you wanted to, you could hack in some additional in-band registration/rate limiting mechanism (by IP address, by captcha, etc.). I didn’t feel the need for this, so this is left as an exercise for the reader.

Conclusion

In this blog post, we looked at how Paradigm’s CTF framework lets you easily host smart contract CTF challenges. We hope this helps anyone making a CTF challenge!

Finally, all of the code used in this blog post is publicly available here.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.