アルゴリズムとかオーダーとか

仕事で勉強したことなどをまとめてます

Ethernaut-CTF2024をローカルでやってみる

今年3月ごろに実施していた、EthernautのCTF2024というイベントがあった。
ctf.openzeppelin.com


イベントはすでに終了したが、実はローカルでも同じように回答に正解するとフラグを取得できる本番さながらの環境が構築できるリポジトリが公開されている
github.com
github.com

この記事ではこちらの環境構築手順と、実際に問題に回答するための最初の導入部分について解説する。

事前準備

以下の環境が必要

  • Docker
  • Node.js + hardhat

DockerなのでWindowsユーザでも問題なく環境構築でる。WSL2 + Docker Desktop環境を事前に準備する。

Dockerのインストールはこちらの公式記事を参考に。
docs.docker.jp

Node.js + hardhat環境は以前の記事を参考に。
y-nakajo.hatenablog.com

なお、Dockerで提供されるのは攻撃対象の環境及び回答提出用のサーバなので、hardhatはdocker内で構築する必要はない。ホスト側に構築で問題ない。

環境構築手順

リポジトリに手順の記載があるのでそれに従って環境を構築する。

最初に以下のリポジトリをcloneする。
github.com

git clone https://github.com/OpenZeppelin/ctf-infra.git

cloneしたディレクトリに移動して、dockerコンテナを起動する。

cd paradigmctf.py
docker compose up -d

実行結果

ctf-infra docker result

この環境はCTFの回答を提出するための環境。各問題の攻撃対象のコントラクトが実行されているnodeを起動したりするためのインフラ環境を提供する。

続いて、CTFのそれぞれの問題の環境を構築する。
まずは以下のリポジトリをローカルにクローンする。場所は先ほどのディレクトリと異なるところで良い。
github.com

git clone https://github.com/OpenZeppelin/ctf-2024.git

続いて、解きたい問題のさらに下にある challenge ディレクトリに移動した後にdockerコンテナを起動する。

question list
challenge dir
cd <challenge_name>/challenge
docker compose up -d

実行結果

challenge docker result


回答したい問題ごとにdockerを実行する必要がある点に注意。

問題の回答方法

CTF インスタンスの基本的な使い方

各問題のdocker composeを実行すると攻撃対象のContractをデプロイしたnodeのインスタンスを起動できるようになる。
それらを管理するサーバが起動するのでまずはそのサーバにアクセスする。サーバはlocalhost:1337で起動しているのでncコマンドでアクセスできる。

nc localhost 1337

ncコマンドでアクセすると以下の3つのコマンドが実行可能。

1 - launch new instance: 攻撃するためのnodeインスタンスを起動する
2 - kill instance: インスタンスを終了させる
3 - get flag: 攻撃に成功したかどうかを判定する

なお、ここで起動するインスタンスは12分で自動的にシャットダウンされるので注意。

1を選択しインスタンスを起動すると以下のようにインスタンスに接続するための情報が表示される。

node instance

これを後述するhardhatの設定ファイルに記述することで攻撃を行うための環境を作成する。

3を選択することで攻撃成功したかどうかがわかる。1で起動したインスタンス上にデプロイされているコントラクトが条件を満たしていた場合はflagが取得できる。
以下はまだ攻撃に成功してない時に表示されるメッセージ。

attack faild

攻撃対象のインスタンスが起動してない場合は以下のメッセージが表示される。

non exists message

ということで基本的な流れは以下のような形で進めることになる。

  • 攻撃の準備をする。(攻撃用のスクリプトを作るなど)
  • nc localhost 1337実行して、1を選択して攻撃対象のインスタンスを起動
  • 攻撃スクリプトやhardhatのconsoleから接続して攻撃を行い、成功させる。
  • nc localhost 1337実行して、3を選択してフラグを取得する

問題によっては攻撃途中でContractが攻撃が不可能な状態になる場合がある(ex Tokenが枯渇したなど)。その場合は2 を選んでインスタンスを終了させて再起動すると良い。

hardhatを用いた攻撃方法

このCTFではおそらくhardhatで攻撃スクリプトや攻撃コントラクトを作成して攻撃することを想定していると思われる。
以下、hardhatで攻撃(問題を解く)のための環境を整える方法の一例を示す。

なお、以降はhardhatの環境は構築済みとして話を進める。またhardhat環境は hardhat init のデフォルト設定を利用している前提とする。
さらに、 @openzeppelin/contracts モジュールもインストール済みとする。

network設定の追加

前述した方法でインスタンスを起動する。攻撃対象のインスタンスが無事に起動するとRPCのエンドポイントとChallengeコントラクトのアドレス、それから攻撃時に利用できるユーザの秘密鍵がそれぞれ表示される。

instance infomation

これらをhardhat.config.jsにnetwork設定として追加する。ここでは "challenge"という名前のネットワークとして設定している。

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.24",
  networks: {
    "challenge": {
      url: "http://127.0.0.1:8545/lAJDnKkJhASiEHskvfeXZeSY/main\n",
      accounts: ["0xc65cc7bc0c3823d011c8f0e8f3d7dbe5a3ab1a77c5763ea164e8c860ea3a15db"]
    }
  }
};

urlとaccountsの秘密鍵は攻撃対象のインスタンスを起動し直す度に変わるのでその都度修正すること。

コントラクトのコピーと修正

続いて、問題で提供されているコントラクトをコピーする。これらのコントラクトにアクセスして攻撃する必要があるのでソースコードをコピーしておく。
ここでは"spacebank"の問題を例に説明する。

spacebank/challenge/project/src/ 以下にあるsolidityコードを全て今作成したhardhatプロジェクトのcontracts/以下にコピーする。

spacebank solidity files

次に、solidityのimportのパスを修正する。

Challenge.solのここを

import "src/SpaceBank.sol";

これに修正する。

import "./SpaceBank.sol";

次に、SpaceBank.solのここを

import "openzeppelin/access/Ownable.sol";
import "openzeppelin/token/ERC20/IERC20.sol";
import "openzeppelin/token/ERC20/ERC20.sol";

これに修正する。

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

他の問題を解く場合も同様に修正すること。

ソースコードの修正も終わったら、 npx hardhat console --network challenge などでコンソールが接続可能かチェックする。

攻撃スクリプトの作成と攻撃

必要な設定が完了したのであとは適当な攻撃スクリプトコントラクトを作成して、対象のコントラクトを攻撃して条件を満たす状態にする。
どの問題でもChallengeコントラクトは必ず用意されており、ここが攻撃の起点となる。

つまり攻撃する時の一通りの流れとして以下の順番で行うことになる。
1. Challenge コントラクトをインスタンス
2. Challenge コントラクトから攻撃対象のコントラクトのアドレスを取得
3. 攻撃対象のコントラクトをインスタンス
4. 攻撃コントラクトをデプロイするなり、スクリプトで条件を満たすように攻撃対象のコントラクトの各メソッド呼ぶなりして条件を満たすようにする。

ここでは、上記の1, 2 の部分のみ実装した攻撃スクリプトの例を示す。

以下のjsをattack.jsとしてhardhatプロジェクトのカレントディレクトリに作成。

const hre = require("hardhat");

async function main() {
    const accounts = await hre.ethers.getSigners();
    const player = accounts[0].address

    const Challenge = await hre.ethers.getContractFactory("Challenge")
    const SpaceBank = await hre.ethers.getContractFactory("SpaceBank")

    const CHALLENGE_ADDR = "0x5bf0ee2725CDe5800545022683eD6403CA90AC63"
    const challenge = await Challenge.attach(CHALLENGE_ADDR)
    const spaceBank_addr = await challenge.SPACEBANK()

    console.log("player: ", player);
    console.log("spacebank: ", spaceBank_addr);
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

実行した結果として以下のようにSpaceBankコントラクトのアドレスが取得できる。

attack.js executed result

あとは、このスクリプトを改良して、Challenge#isSolved()がtrueを返す条件を満たすように攻撃を行う。
Challenge#isSolved()がtrueを返す状態になったら、CTFインスタンスからflagが取得できるようになる。