Solidityで作るCapture The Flag

qiita.com

BitcoinでCapture The Flag(CTF)を作るという記事を読んでEthereumでの実現可能性に触れられていたので実際にSolidity + truffleを使って試してみました。Bitcoinでの実装については上記の記事をぜひ読んでみて下さい。

CTFを簡単に説明するとサイバーセキュリティの競技の一つで、リバースエンジニアリングやWebアプリケーションの脆弱性を突いて秘匿されたフラッグと呼ばれる文字列を他の競技者よりも早く獲得した人に賞金がもらえるという競技です*1 。このCTFをスマートコントラクト上で実装する方法としては、まず事前に参加者から公開鍵を募り、次に運営は参加者の公開鍵とフラッグワードを結合し2回ハッシュ化したものをスマートコントラクト上にデプロイしておき、実際の競技では参加者は自分の公開鍵とフラッグワードを結合してハッシュ化したものを提出し、スマートコントラクトはそれをハッシュ化して予め持っていたハッシュ値と突合して正解かどうかを判断するという流れになります。詳しい解説は Bitcoinによる新しいCapture The Flag(CTF) - Qiita を参照して下さい。余談ですがBitcoinの実装を見ているとMASTで効率よく圧縮できそうなトランザクションですね。

出来上がったコードは短いので最初に貼ってしまって一つずつ解説していきます。

contracts/CaptureTheFlag.sol

pragma solidity ^0.4.18;

import "zeppelin-solidity/contracts/math/SafeMath.sol";
import "zeppelin-solidity/contracts/ownership/Ownable.sol";

contract CaptureTheFlag is Ownable {
  using SafeMath for uint256;

  uint256                     public prize;          // 賞金
  mapping(address => bytes32) public answers;        // 正解の辞書
  uint256                     public deadline;       // 終了時間
  bool                        public isOver = false; // 競技が終了しているか

  function CaptureTheFlag(uint256 _prize, address[] _participants, bytes32[] _answers, uint256 _timedelta) public {
    require(_participants.length == _answers.length);

    // 正解の辞書の構築
    for (uint i = 0; i < _participants.length; i++) {
      answers[_participants[i]] = _answers[i];
    }

    prize        = _prize;
    deadline     = block.timestamp.add(_timedelta);
  }

  // 主催者のみが賞金を入金できるようにする
  function () external payable onlyOwner {}

  // 参加者が正解を提出する
  function submit(bytes32 answer) public {
    require(!isOver);                    // 競技がまだ終了していないこと
    require(block.timestamp < deadline); // 終了時間をまだ過ぎていないこと
    require(this.balance >= prize);      // 賞金が既に入金されていること

    // 提出されたハッシュが正解かどうかを検証する
    bytes32 hashedAnswer  = sha256(answer);
    bytes32 correctAnswer = answers[msg.sender];
    require(correctAnswer != 0x0);
    require(correctAnswer == hashedAnswer);

    // 正解であれば賞金を支払い競技を終了する
    isOver = true;
    msg.sender.transfer(prize);
  }

  // 賞金を主催者に返還する
  function refund() public {
    require(block.timestamp >= deadline);  // 終了時間を過ぎていること
    owner.transfer(this.balance);
  }
}

まずコンストラクタでは

  • 賞金の額
  • 参加者のアドレスのリスト
  • 各参加者に対応する正解のハッシュ値のリスト
  • 競技時間(秒)

を受け取ってグローバルな変数にセットしています。

  function CaptureTheFlag(uint256 _prize, address[] _participants, bytes32[] _answers, uint256 _timedelta) public {
    require(_participants.length == _answers.length);

    // 正解の辞書の構築
    for (uint i = 0; i < _participants.length; i++) {
      answers[_participants[i]] = _answers[i];
    }

    prize        = _prize;
    deadline     = block.timestamp.add(_timedelta);
  }

このスマートコントラクトは正解のリストの作り方も重要です。正解のリストは migration のコードで作成しているのでそちらも見てみましょう。

migrations/2_deploy_capture_the_flag.js

const crypto = require('crypto');
const CaptureTheFlag = artifacts.require('./CaptureTheFlag.sol');

const sha256 = (msg) => crypto.createHash('sha256').update(msg).digest();

const prize = 1e18; // 1 ether
const flagWord = Buffer.from("capture the flag", 'utf8');

const participants = [
  "f17f52151ebef6c7334fad080c5704d77216b732",
  ...
  ]
  .map((address) => Buffer.from(address, 'hex'))

const answers = participants.map((address) => {
  const msg = Buffer.concat([address, flagWord]);
  console.log("DEBUG", "0x" + sha256(msg).toString('hex'));
  return sha256(sha256(msg));
});

const timedelta = 60 * 60; // 1 hours

// Buffer から String に変換
const buf2str = (buf) => "0x" + buf.toString('hex');

module.exports = (deployer) => {
  deployer.deploy(
    CaptureTheFlag,
    prize,
    participants.map(buf2str),
    answers.map(buf2str),
    timedelta
  )
};

参加者のアドレスにフラッグワードを結合したものを2回 sha256 でハッシュ化して answers を作ってるのがわかると思います。

次にいちばん重要な submit のコードを見てみましょう。

  // 参加者が正解を提出する
  function submit(bytes32 answer) public {
    require(!isOver);                    // 競技がまだ終了していないこと
    require(block.timestamp < deadline); // 終了時間をまだ過ぎていないこと
    require(this.balance >= prize);      // 賞金が既に入金されていること

    // 提出されたハッシュが正解かどうかを検証する
    bytes32 hashedAnswer  = sha256(answer);
    bytes32 correctAnswer = answers[msg.sender];
    require(correctAnswer != 0x0);
    require(correctAnswer == hashedAnswer);

    // 正解であれば賞金を支払い競技を終了する
    isOver = true;
    msg.sender.transfer(prize);
  }

最初に require を用いて競技が開催中であることを確認しています。余談ですがrequireが並ぶとrevertした時にどのrequireに引っかかったかが分からなくてデバッグが大変なので早くメッセージを付けれるようにして欲しいですね*2。次に提出されたハッシュ値をsha256でハッシュ化して、それが提出者に対応する正解と一致しているかどうかを検証しています。Solidityのmappingは存在しないキーについては0もしくはfalseを返すので correctAnswer0x0 でないことを確認して提出者が参加者であるかどうかも確認しています。すべての検証がうまくいけば提出者に賞金を支払って競技を終了します。

最後にもし誰も競技時間中にフラッグワードを見つけられなかった時に運営に賞金を返還する処理を見てみましょう。

  // 賞金を主催者に返還する
  function refund() public {
    require(block.timestamp >= deadline);  // 終了時間を過ぎていること
    owner.transfer(this.balance);
  }

終了時間を過ぎていることを確認して、運営に持っているEtherを全て返しています。 ownerOpenZeppelinのOwnableで定義されているものです。

実際にtruffleを使ってちゃんと動作するか確認してみましょう。

$ truffle develop
Truffle Develop started at http://localhost:9545/

Accounts:
(0) 0x627306090abab3a6e1400e9345bc60c78a8bef57
(1) 0xf17f52151ebef6c7334fad080c5704d77216b732
...

Private Keys:
...

truffle(develop)> compile
Compiling ./contracts/CaptureTheFlag.sol...
...

truffle(develop)> migrate
Using network 'develop'.
...

Running migration: 2_deploy_capture_the_flag.js
DEBUG 0x4ffb42209f8c12ef32fcf8589cda12513471e494855db2bbf1ae8968ce167a9a
...

truffle(develop)> contract = CaptureTheFlag.at(CaptureTheFlag.address)

コンパイルしてマイグレートしてコントラクトオブジェクトを作って準備完了です。まずは賞金を入金しましょう

truffle(develop)> web3.eth.sendTransaction({from: web3.eth.accounts[0], to: contract.address, value: web3.toWei(1, 'ether')})
'0x315de69b7d6a37c0106e609c63c6b5089ddd92d97f65b13357b302103985bca3'

それでは正解を提出しましょう。まずは提出されたハッシュ値が間違っているパターンを見てみます。 accounts[1] の正解を使って accounts[2] で提出してみましょう。

truffle(develop)> contract.submit('0x4ffb42209f8c12ef32fcf8589cda12513471e494855db2bbf1ae8968ce167a9a', {from: web3.eth.accounts[2]})
Error: VM Exception while processing transaction: revert
...

期待通り提出拒否されましたね!次に正しく正解を提出してみましょう。

truffle(develop)> contract.submit('0x4ffb42209f8c12ef32fcf8589cda12513471e494855db2bbf1ae8968ce167a9a', {from: web3.eth.accounts[1]})
{ tx: '0x77eaed199aeed34e2e8c68045f5d298558dda2618da62f08b48457b7c601ab23',
  receipt: ...

無事アクセプトされました。ちゃんと賞金がもらえてるか見てみましょう。

truffle(develop)> Number(web3.fromWei(web3.eth.getBalance(web3.eth.accounts[1]), 'ether'))
100.9919659

accounts[1] は最初100eth持っているはずなので賞金の1ethからsubmit時の手数料が引かれたと考えると正しく動いてそうですね!

以上がSolidityで実装したCTFの解説となります。CTFをスマートコントラクトで実装することで正解を偽っていないことを保証できる公平性を担保できるのは大きなメリットだと思います。Ethereumのスマートコントラクトを使えば Bitcoinによる新しいCapture The Flag(CTF) - Qiita で言及されているようなポイントの多い順に賞金を与えたり、解答した順番に関わらず一定のポイントを与えるようなCTFの実装もできそうです。

今回作成したコードは以下の GitHub でも公開しています。

github.com