yyy

CTFつよくなりたい

Smart ContractをデプロイするときにEVMで行われていること

この記事は Aizu Advent Calendar 2017 - Adventar 21日目の記事です.

前の人は id:ktr_0731 で,

syfm.hatenablog.com

次は id:slme9364 です.

slme9364.hatenablog.com

はじめに

去年に引き続き2回目の参加ですが,今回は最近某所でEthereumを触ることがあり,その際疑問に思ったことを掘り下げていこうと思います.レイヤーが低めの話になります.

今回やること

Ethereumの内部でSmart Contractというものが使われていますが,これをブロックチェーン上へデプロイする時って基本的にはsolcでコンパイルしてからgethで

> eth.contract(abi).new({ from: ..... , data: ...... , gas: ..... })

とかでトランザクションを発行します.この時にdataに指定しているものってEVM bytecodeと呼ばれる16進数の列なわけで,大体こんな感じ

606060405260006001600061010...................................

になっていると思います.

また,他のユーザーに自身の作成したコントラクトを利用してもらうために、「コントラクトのアドレス」と「ABI」の2つ情報を伝えて初めて他のユーザーがデプロイされたコントラクトを使うことができるわけですが,実は利用者はコントラクトのコードに対して以下のようにアクセスすることができます.

> eth.getCode(コントラクトのアドレス)
"0x606060405260006001600061010...................................

そこでふと思ったのが,デプロイされたEVM bytecodeをリバースエンジニアリングし,コンパイルされる前のSolidityで記述されたファイルまで戻すことはどこまで可能なのか?ということです.

と言っても,EVMで使われているアセンブリも知らない初心者がリバースエンジニアリングをしようとしても詰むだけなので,今回はコントラクトをデプロイするときの処理を中心に,EVMの中でなにが行われているのか,EVM assemblyを読むことで明らかにしていきたいと思います.コントラクトを書く言語は他にもあるらしいですが,今回はとりあえずSolidityでやります.

EVMで使われるデータ構造や命令について

まず,EVM内ではどのようなデータ構造が使われているのか,どのような命令(オペコード)が使われているのか,などを簡単に調べていきます.

Ethereumの仕様書でYellow Paperと呼ばれるものや,あとはEthereumのwikiとかが参考になります.

github.com

github.com

まずデータ構造ですが,各命令はデータを保存するために以下の3つの領域を使います.

  • stack
  • memory
  • storage

stack は普通のスタックだったので省略し, memory は「無限に拡張可能なバイト配列」です.storagestackmemory と違い,長い期間存続するkey/valueストアです.

また,内部で使われている命令ですが,これは Yellow Paperの23ページに全て載っているので割愛します.

やってみる

まずは以下のようなSmart Contractから始めていきます.

pragma solidity ^0.4.0;

contract HelloWorld {
}

なにもしない空のコントラクトです.これを,以下のようにアセンブリを出力するようにして,読んでいきます.

$ solc --optimize --asm HelloWorld.sol > HelloWorld.sol.dis
======= HelloWorld.sol:HelloWorld =======
EVM assembly:
    /* "HelloWorld.sol":25:48  contract HelloWorld {... */
  mstore(0x40, 0x60)
  jumpi(tag_1, iszero(callvalue))
  0x0
  dup1
  revert
tag_1:
  dataSize(sub_0)
  dup1
  dataOffset(sub_0)
  0x0
  codecopy
  0x0
  return
stop

sub_0: assembly {
        /* "HelloWorld.sol":25:48  contract HelloWorld {... */
      mstore(0x40, 0x60)
      0x0
      dup1
      revert

    auxdata: 0xa165627a7a72305820a6b4f23e09acc57a4a971849c21344d45e014c2944a13821c5804492f1c50c720029
}

また,比較のために以下のようなコントラクトも用意します.

pragma solidity ^0.4.0;

contract HelloWorld {
  uint data;

  function HelloWorld() {
    data = 0xdeadbeef;
  }
}

これもアセンブリを出力してみます.

======= HelloWorld2.sol:HelloWorld =======
EVM assembly:
    /* "HelloWorld2.sol":25:115  contract HelloWorld {... */
  mstore(0x40, 0x60)
    /* "HelloWorld2.sol":63:113  function HelloWorld() {... */
  jumpi(tag_1, iszero(callvalue))
  0x0
  dup1
  revert
tag_1:
    /* "HelloWorld2.sol":98:108  0xdeadbeef */
  0xdeadbeef
    /* "HelloWorld2.sol":91:95  data */
  0x0
    /* "HelloWorld2.sol":91:108  data = 0xdeadbeef */
  sstore
    /* "HelloWorld2.sol":25:115  contract HelloWorld {... */
  dataSize(sub_0)
  dup1
  dataOffset(sub_0)
  0x0
  codecopy
  0x0
  return
stop

sub_0: assembly {
        /* "HelloWorld2.sol":25:115  contract HelloWorld {... */
      mstore(0x40, 0x60)
      0x0
      dup1
      revert

    auxdata: 0xa165627a7a7230582008092ae2364624e84432682e083b5c6bad044762c66a05fae7e553e4ce9258460029
}

一番最初のコントラクトを Hello World1 ,コンストラクタで0xdeadbeef を代入するほうを Hello World2 とすると,2では

/* "HelloWorld2.sol":98:108  0xdeadbeef */
0xdeadbeef
/* "HelloWorld2.sol":91:95  data */
0x0
/* "HelloWorld2.sol":91:108  data = 0xdeadbeef */
sstore

このような命令が追加されていて,これが

function HelloWorld() {
  data = 0xdeadbeef;
}

この部分にあたります. sstore という見慣れない命令がありますが,これがどういう命令なのかをYellow Paperで調べてみると Save word to storage. と書かれていました.つまり,この処理ではkey/valueストアであるstorageに 0x0 をkeyとして 0xdeadbeef (正しくは0xdeadbeefを32byteに拡張したもの)を保存しているということになります.

また,0xdeadbeef0x0 と値だけ書かれているのはpush命令が省略されて書かれていて,実際にEVM bytecodeを見てみるとこの処理は

63deadbeef600055

このようなバイト列になっています.63が push4 にあたるので, 63deadbeefpush4 0xdeadbeef , 60が push1 なので 6000push1 0x00 になり,最後の55が sstore です.

これを見るとEVMではビッグエンディアンが使われているということもわかります.

コンストラクタ以外の部分について

コンストラクタの data = 0xdeadbeef にあたる命令は

0xdeadbeef
0x0
sstore

であり, sstore(0x0, 0xdeadbeef) であることはわかったものの,他の大部分のコードは未だ謎です.これらは一体なんなのでしょうか.そもそも,EVM bytecodeをデプロイしてそれがEVM上で実行されるためには,デプロイするコード(EVM上へ登録するためのコード)が必要なはずです.コンストラクタが存在しているということ以外は Hello World1Hello World2 に違いはないので,その他のコードはデプロイするために必要なコードである可能性が高いです.

ということで,上から命令を地道に追っていきます.以下では Hello World1 (なにもしないコントラクト)を使っていきます.

コントラクトがデプロイされる処理を追う

EVM assembly:
    /* "HelloWorld.sol":25:48  contract HelloWorld {... */
  mstore(0x40, 0x60)

まずは mstore(0x40, 0x60) という命令から始まっています. mstoreSave word to memory. であり,この場合 0x40 の場所に 0x60 (正確には0x60を32byteに拡張したもの)を保存するという命令になります.実際にどのような実装になっているのか,go-ethereumを見てみます.

github.com

go-ethereum/core/vm/instructions.go の493行目にあります.

func opMstore(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
    // pop value of the stack
    mStart, val := stack.pop(), stack.pop()
    memory.Set(mStart.Uint64(), 32, math.PaddedBigBytes(val, 32))

    evm.interpreter.intPool.put(mStart, val)
    return nil, nil
}

mstore を呼ぶときのstackの状態は

 0x40
+-+-+
 0x60
+-+-+

このようになっていて,1回目の stack.pop()0x40mStart に入り2回目の stack.pop()0x60val に入ります.その後memoryに保存するのですが,その際 math.PaddedBigBytes を使って val を32byte拡張しています.そして, go-ethereum/core/vm/memory.go の32行目のSet

func (m *Memory) Set(offset, size uint64, value []byte) {
    // length of store may never be less than offset + size.
    // The store should be resized PRIOR to setting the memory
    if size > uint64(len(m.store)) {
        panic("INVALID memory: store empty")
    }

    // It's possible the offset is greater than 0 and size equals 0. This is because
    // the calcMemSize (common.go) could potentially return 0 when size is zero (NO-OP)
    if size > 0 {
        copy(m.store[offset:offset+size], value)
    }
}

これが呼ばれ,最終的に copy(m.store[offset:offset+size], value) でstoreされるという処理になっています.

次ですが,

  jumpi(tag_1, iszero(callvalue))

jumpiiszero , callvalue など見慣れない命令が出てきました.

jumpiConditionally alter the program counter と書かれていて要するに条件分岐命令であり,その後の説明を見る限り第2引数が0でなければ第1引数へ飛ぶ命令っぽいです.また, iszero はスタックから1つpopして0であれば1,そうでないなら0をスタックへpushする命令です.では callvalue がなんなのかですが,正直よくわかってません.実際のコードは以下のようになっているのですが,

func opCallValue(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
    stack.push(evm.interpreter.intPool.get().Set(contract.value))
    return nil, nil
}

contract.value がなにを指すのかがわからず...とにかくこの値が0になっていないと iszero により1がセットされないので,0であるべき値なのは確かなのですが...

では次に,tag_1へ飛んだ先の処理を追っていきます.

tag_1:
  dataSize(sub_0)
  dup1
  dataOffset(sub_0)
  0x0
  codecopy
  0x0
  return
stop

まず, dataSize(sub_0) ですがこれば実際のバイト列を見てみると 6035 のようになっていて,今回の場合は push1 0x35 だということがわかります.この 0x35 って何かというと sub_0 でラベル付けされたルーチンのバイト数であり,つまりここでは sub_0 のサイズがstackへpushされているということになります.次の dup1 ですが,これは Duplicate 1st stack item. と説明がある通りstackの1番目の要素を複製します.ここでは,現在stackへ積まれている 0x35 が複製されるということです.

次に, dataOffset(sub_0) ですがこれも実際のバイト列を見てみると 601b のようになっていて, push1 0x1b だということがわかります.この 0x1b は先頭から sub_0 までのオフセットを指していて,このオフセットをstackへ積んでいます.その後 0x0 をpushして codecopy を実行するわけですが,この時のstackの状態を整理してみると以下のようになっています.

 0x0
+-+-+
 0x1b
+-+-+
 0x35
+-+-+
 0x35
+-+-+

この状態で codecopy を呼ぶとどのように実行されていくのか, go-ethereum/core/vm/instructions.go の408行目を見ていきます.

func opCodeCopy(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
    var (
        memOffset  = stack.pop()
        codeOffset = stack.pop()
        length     = stack.pop()
    )
    codeCopy := getDataBig(contract.Code, codeOffset, length)
    memory.Set(memOffset.Uint64(), length.Uint64(), codeCopy)

    evm.interpreter.intPool.put(memOffset, codeOffset, length)
    return nil, nil
}

今回の場合, memOffset0x0 が入り, codeOffset0x1blength0x35 が入ります.そして, getDataBig での結果が codeCopy に入るわけですが, getDataBiggo-ethereum/core/vm/common.go で宣言されていて

// getDataBig returns a slice from the data based on the start and size and pads
// up to size with zero's. This function is overflow safe.
func getDataBig(data []byte, start *big.Int, size *big.Int) []byte {
    dlen := big.NewInt(int64(len(data)))

    s := math.BigMin(start, dlen)
    e := math.BigMin(new(big.Int).Add(s, size), dlen)
    return common.RightPadBytes(data[s.Uint64():e.Uint64()], int(size.Uint64()))
}

スライスを返す関数だとわかります.つまり,コントラクト全体のコードから sub_0 にあたるコードだけを取り出してそれを返します.その後, memOffset の位置 ( 0x0 )に sub_0 のコードを格納しています.

codecopy の後は 0x0 をpushして return ですが, これはメモリオフセットとサイズの2つの値をstackからpopし,memory.GetPtr での返り値を返すような処理になっています.

func opReturn(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
    offset, size := stack.pop(), stack.pop()
    ret := memory.GetPtr(offset.Int64(), size.Int64())

    evm.interpreter.intPool.put(offset, size)
    return ret, nil
}

memory.GetPtr は以下です.

// GetPtr returns the offset + size
func (self *Memory) GetPtr(offset, size int64) []byte {
    if size == 0 {
        return nil
    }

    if len(self.store) > int(offset) {
        return self.store[offset : offset+size]
    }

    return nil
}

呼び出し元へ戻ってからも後は revert しているぐらいなのですが, revert の説明が Yellow Paper に無くて,このサイト で見つけたので,後から追加された命令なんでしょうか.

ここまで説明した処理がコントラクトをデプロイする処理にあたります.

evm --debugでコントラクトがデプロイされる処理を見てみる

恐らくethereumをインストールした際に一緒に入ってくるコマンドだと思うんですが, evm というコマンドがあり,コマンドラインでいろいろできるやつです.

f:id:ywkw1717:20171221002742p:plain

これを使うと任意のEVM bytecodeをコマンドライン上で実行してくれて, --debug オプションを付けておくとstackやmemory,storageの情報を表示してくれるので,これを使って上記の処理を追うとさらにわかりやすいかと思います.では,今度は Hello World2 のコードを使っていきます.

$ evm --debug --code 60606040523415600e57600080fd5b63deadbeef60005560358060236000396000f3006060604052600080fd00a165627a7a723058201ccb8cf1608dcc548e886094db68d49551c88613cccdcfb5b89884e4028ae32f0029 run

上から順番に見ていきます.

f:id:ywkw1717:20171221004759j:plain

現在のプログラムカウンタやgasの量,それぞれにかかるコストも表示してくれています.

なにをpushしているのか書かれていないのでわかり辛さもありますが, mstore を実行する時には 0x400x60 がそれぞれ積まれているのがわかります.また, mstore によって 0x40 の位置に 0x60 を32byte拡張したものが格納されるので,赤く丸をしたところに60という数字が現れています.jumpする直前まで飛ばします.

f:id:ywkw1717:20171221005255p:plain

jumpi が実行されるときには jump先のオフセットと iszero(callvalue) の返り値である1がstackへ積まれています. jumpi の次は jumpdest となっていますが,このようにjumpする直前では実は Mark a valid destination for jumps. This operation has no effect on machine state during execution. と説明が書かれている jumpdest 命令が実行されています.

tag_1へ移動してからは,まずコンストラクタの処理である「 0xdeadbeefdata に代入」があります.

f:id:ywkw1717:20171221010315p:plain

sstore によって,32byte拡張された0をkeyにvalueである32byte拡張された 0xdeadbeef がstorageへと保存されているのがわかります.その後, codecopy まで処理は進んでいき,

f:id:ywkw1717:20171221010824j:plain

codecopy が実行された直後のmemoryを見てみると, sub_0 のコードがmemoryへ展開されているのがわかります.

いくつかの疑問点

コントラクトをデプロイする過程を追ってきましたが,いくつか疑問が残っています.

  1. mstore(0x40, 0x60) とは何のためにあるのか
  2. sub_0 のラベルが付いているルーチンはなんなのか
  3. sub_0 に書かれている auxdata とは

とりあえずこの3つについて書いていきます.

mstore(0x40, 0x60)について

まず, 0x40 というのは特別な場所らしく free memory pointer という空きメモリへのポインタを保存しておく場所です.この命令から始まることで, 0x40 の位置に 0x60 という値を入れておき, 0x60 が空いているよと伝えることができます.また, 0x60mstore を使って保存した値をもし保持しておきたい場合は free memory pointer を他の値で更新し,次はその場所を使うようにしたりと,そんな風に使うっぽいです.

ですが,これって慣習として”そういう風に使う領域”と言われているだけで,書き込みが禁止されていたりとかはないんですよね.ですので,コントラクトの本体コードが大きいものをデプロイした場合,↑で見た処理の通り sub_0 がmemoryの 0x0 に保存されるので, 0x40 にある free memory pointer はすぐ上書きされてしまうわけで.

結局よくわからなくなったので,もう少し調べてみる必要がありそうです.

sub_0のラベルが付いているルーチンについて

これは恐らく,コンストラクタ以外のコントラクトの関数が含まれる箇所で,例えば getset だけのシンプルなコントラクトにした場合, getset にあたる処理は sub_0 へ入ります.もう少し大きなコントラクトで試した場合も sub_? というラベルは sub_0 しか出てくることはなかったので,自分はコンストラクタ以外の関数(処理),つまりコントラクトの本体が含まれる箇所だと解釈しています.

sub_0に書かれている auxdata とは

これはググっても本当に情報が出てこなくて,恐らく auxdata という呼称を使っているのは Solidity なので,Solidity のソースコードを探ってみました.ちなみに auxdata という言葉自体は auxiliary data の略称であり,補助データという意味です.

github.com

solidity/libevmasm/Assembly.h の158行目にこうありました.

/// Data that is appended to the very end of the contract.
bytes m_auxiliaryData;

説明は書かれておらず,コントラクトの最後に追加されるデータってそんなことわかっとんねんと思いながら他を探しました.

solidity/libsolidity/codegen/Compiler.cpp の39行目.

m_runtimeContext.appendAuxiliaryData(_metadata);

とあり,この _metadata

void Compiler::compileContract(
    ContractDefinition const& _contract,
    std::map<const ContractDefinition*, eth::Assembly const*> const& _contracts,
    bytes const& _metadata
)
{
..........................

この関数の引数として渡ってきます.結局 metadata でしかないのはわかるんですが,これが何のためにあって,どういう使われ方をするのかはよくわかりませんでした.

auxdata: 0xa165627a7a7230582") == 0

コードのテストにはこのような1行もあり,決まったフォーマットであることもわかるのですが,正体が謎です.改ざんされていないか検証するための値とかでしょうか.

Porosityについて

今年のDEFCONでPorosityというEthereum Smart Contractsのデコンパイラが発表されました.なぜこんなリバースエンジニアリングツールが出てきたのかというと,そもそもSmart Contract自体に脆弱性が存在することがあるらしく,それを悪用して不正送金されたケースがあったとか.

で,セキュリティ対策としてコンパイルされる前のコードを静的解析するツールはあるものの,これは開発者がコードを提供することで初めて成り立つものです.セキュリティを意識しない開発者がそのままコンパイルしてブロックチェーン上へデプロイした場合はコードが安全であることを保証する方法が無く,もし新たに脆弱性が見つかった場合に開発者自信が元々のコードを保持・または共有していない限り,脆弱なコントラクトを特定するのは非常に難しくなります.

というような内容が書かれているPorosityを紹介しているサイトがあるので,詳しくはこちらをご覧ください.

blog.comae.io

ということで,このツールを少しだけ使ってみます.足りないパッケージがあったりしたので,インストール方法を載せておきます.

インストール ( Ubuntu16.04 )

$ git clone https://github.com/comaeio/porosity.git
$ cd porosity
$ sudo apt-get install libboost-all-dev
$ cd porosity/porosity
$ wget https://raw.githubusercontent.com/chriseth/solidity/0a2a2cf38b7794826a17efe44aea5dc96de98dc7/libdevcore/boost_multiprecision_number_compare_bug_workaround.hpp
$ porosity.hの中の `#include "Common.h"``#include <boost/dynamic_bitset.hpp>` の上に移動
$ make

以下のようなコントラクトで試してみます.

pragma solidity ^0.4.0;

contract Aizu {
  uint data;

  function set(uint input) public {
    data = input;
  }

  function get() public constant returns (uint) {
    return data;
  }
}

実行.

$ abi=`cat output/Aizu.abi`
$ code=`cat output/Aizu.bin`
$ porosity --abi $abi --code $code --decompile --verbose 0
Porosity v0.1 (https://www.comae.io)
Matt Suiche, Comae Technologies <support@comae.io>
The Ethereum bytecode commandline decompiler.
Decompiles the given Ethereum input bytecode and outputs the Solidity code.

Attempting to parse ABI definition...
Success.
Hash: 0x60FE47B1
executeInstruction: NOT_IMPLEMENTED: REVERT
function set(uint256) {
      if (!msg.value) {
      }
      store[var_LK0e1] = arg_4;
      return;
}


LOC: 6
Hash: 0x6D4CE63C
executeInstruction: NOT_IMPLEMENTED: REVERT
function get() {
      if (!msg.value) {
      }
      return;
      return;
}


LOC: 6

んー...どうなんですかね. set は大体戻せていますが, get は最後 return が2つ出ていたりして怪しいです.GitHubを見てみるとサンプルでは自明な脆弱性があるところは教えてくれたりするらしいですが,まだまだ発展途上というところでしょうか.

まとめ

gethの使い方!とか基本的なセットアップとかの情報は日本語でも増えてきたものの,もっと深い実装寄りの話においては日本語の記事はほとんど存在していなくて,英語の記事もまとまっているものは少なくて部分的だったり,情報を探すのに本当に苦労しました. でも,情報がここまで多くない時代ってそれが当たり前で,皆さんいろいろ探りながらブログとかへまとめていったんだなぁと思うと本当に先人に感謝だし,自分ももっと情報をアウトプットするべきだなと感じました.

以上ですが,何か間違いなどあればご指摘お願いします.

参照