Smart-contract CTFs with a Rust practice harness using
Alloy. Write attacks in the existing
attack/ modules. Fallback is the only supplied solution.
From the repository root:
git submodule update --init --recursive
direnv allow
cargo run -p ctf --bin deploy_levelsUse direnv with nix-direnv. Without direnv, enter the environment manually with
nix develop --impure.
The deployment command creates ctfs.json and state.json, then stops its
temporary Anvil. Start your practice chain from that saved deployment in a
separate terminal:
anvil --state state.json --code-size-limit 50000Keep it running. Tests connect to http://127.0.0.1:8545; they do not start
Anvil or redeploy levels. --state loads the saved deployment and writes the
updated state when Anvil exits, preserving completed levels between sessions.
Run the watcher from the repository:
watch-levelsThat is cargo watch -x run (plus the path list that makes saves in hidden Git
worktrees count). Every save reruns the level runner against your chain:
- levels you already solved print
✓ already solvedand skip their attacks; - a level you just solved gets the full
CONGRATULATIONSbanner, in green; - unsolved stubs stay in the end-of-run
Next up:list — the runner never stops at them.
The runner plays every registered level (Ethernaut, Damn Vulnerable DeFi,
Paradigm 2023) and ends with a Progress: X/N solved summary. For a single run
without watching:
cargo runIt connects to your practice Anvil at http://127.0.0.1:8545; set
ATTACK_RPC_URL to point it elsewhere. If it cannot reach the chain it tells
you which command to start.
cargo test -p attack -- --nocapture runs the same levels as one sequential
test that stops at the first unsolved level — a strict progress gate; CI uses
the runner instead.
Open the existing attack/src/ethereum/ethernaut/hackNN_name.rs for the level
you want to solve. Its header gives the goal and direct contract-source URL.
Write your solution in Exploit::attack; it is already wired into the runner
and the test.
target contains the deployed instance address and any helper addresses.
offender is your player account. Use its provider and
.from(offender.address()) when sending transactions. Do not use real keys or
funds.
Need a Solidity contract for your attack (a fake Building, an exploit
contract, anything)? Write it under attack/contracts/<game>/ and build it with
the root Foundry project:
forge buildThe compiled artifact lands at attack/out/<File>.sol/<Contract>.json
(gitignored — your solutions stay yours). Import level contracts in your
Solidity via the root remappings:
import "@ethernaut/Elevator.sol"; // lib/ethernaut/contracts/src/levels/
import "@dvd/puppet/PuppetPool.sol"; // lib/damn-vulnerable-defi/src/Also available: @paradigm-2021/, @paradigm-2022/, @paradigm-2023/, plus
@openzeppelin/contracts/ and the Uniswap/Safe libs from lib/.
In your Rust attack code, sol! paths are relative to the crate directory —
attack/ — so the artifact path drops the attack/ prefix:
use alloy::{sol, sol_types::SolContract};
sol!(
#[sol(rpc)]
Example,
"out/Example.sol/Example.json"
);(forge build first — the artifact must exist when sol! expands.) For the
already-deployed level contracts you usually need no sol! at all: the ctf
level modules re-export their bindings, e.g.
use ctf::ethereum::ethernaut::lvl11_elevator::*; gives you Elevator.
If anything reports connection refused on port 8545, start the saved-state
Anvil shown above. If you deploy a new set of levels, restart your practice
chain with the matching new state.json so its addresses agree with
ctfs.json.
attack/src/ethereum/<game>/: existing attack modules.ctf/src/ethereum/<game>/: Rust target types, Alloy bindings, setup and checks.lib/<game>/: pinned upstream contract sources and original challenge text.ctf/abi/: checked-in Forge artifacts consumed by Alloy'ssol!macro. ABI JSON is not a replacement for reading the contract source.ctf/src/solana/: LiteSVM harnesses.ctf/src/sui/: experimental Sui integration, excluded from the default build.
The checked-in ABI artifacts are enough to build the default Rust workspace. Regenerate them only when changing contract artifacts:
nu scripts/build-abis.nucargo fmt --all --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --locked -- -D warningsThe development shell installs formatter hooks configured directly in
flake.nix: Nix formatting, Rust formatting, Taplo for TOML and deno fmt for
Markdown. Keep pinned upstream sources in lib/ unchanged.