Smart ContractをデプロイするときにEVMで行われていること
この記事は Aizu Advent Calendar 2017 - Adventar 21日目の記事です.
前の人は id:ktr_0731 で,
次は id:slme9364 です.
はじめに
去年に引き続き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とかが参考になります.
まずデータ構造ですが,各命令はデータを保存するために以下の3つの領域を使います.
- stack
- memory
- storage
stack
は普通のスタックだったので省略し, memory
は「無限に拡張可能なバイト配列」です.storage
は stack
や
memory
と違い,長い期間存続する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に拡張したもの)を保存しているということになります.
また,0xdeadbeef
や 0x0
と値だけ書かれているのはpush命令が省略されて書かれていて,実際にEVM bytecodeを見てみるとこの処理は
63deadbeef600055
このようなバイト列になっています.63が push4
にあたるので, 63deadbeef
で push4 0xdeadbeef
, 60が push1
なので 6000
で push1 0x00
になり,最後の55が sstore
です.
これを見るとEVMではビッグエンディアンが使われているということもわかります.
コンストラクタ以外の部分について
コンストラクタの data = 0xdeadbeef
にあたる命令は
0xdeadbeef 0x0 sstore
であり, sstore(0x0, 0xdeadbeef)
であることはわかったものの,他の大部分のコードは未だ謎です.これらは一体なんなのでしょうか.そもそも,EVM bytecodeをデプロイしてそれがEVM上で実行されるためには,デプロイするコード(EVM上へ登録するためのコード)が必要なはずです.コンストラクタが存在しているということ以外は Hello World1
とHello World2
に違いはないので,その他のコードはデプロイするために必要なコードである可能性が高いです.
ということで,上から命令を地道に追っていきます.以下では Hello World1
(なにもしないコントラクト)を使っていきます.
コントラクトがデプロイされる処理を追う
EVM assembly: /* "HelloWorld.sol":25:48 contract HelloWorld {... */ mstore(0x40, 0x60)
まずは mstore(0x40, 0x60)
という命令から始まっています. mstore
は Save word to memory.
であり,この場合 0x40
の場所に 0x60
(正確には0x60を32byteに拡張したもの)を保存するという命令になります.実際にどのような実装になっているのか,go-ethereumを見てみます.
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()
で 0x40
が mStart
に入り2回目の stack.pop()
で 0x60
が val
に入ります.その後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))
jumpi
や iszero
, callvalue
など見慣れない命令が出てきました.
jumpi
は Conditionally 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 }
今回の場合, memOffset
に 0x0
が入り, codeOffset
に 0x1b
, length
に 0x35
が入ります.そして, getDataBig
での結果が codeCopy
に入るわけですが, getDataBig
は go-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
というコマンドがあり,コマンドラインでいろいろできるやつです.
これを使うと任意のEVM bytecodeをコマンドライン上で実行してくれて, --debug
オプションを付けておくとstackやmemory,storageの情報を表示してくれるので,これを使って上記の処理を追うとさらにわかりやすいかと思います.では,今度は Hello World2
のコードを使っていきます.
$ evm --debug --code 60606040523415600e57600080fd5b63deadbeef60005560358060236000396000f3006060604052600080fd00a165627a7a723058201ccb8cf1608dcc548e886094db68d49551c88613cccdcfb5b89884e4028ae32f0029 run
上から順番に見ていきます.
現在のプログラムカウンタやgasの量,それぞれにかかるコストも表示してくれています.
なにをpushしているのか書かれていないのでわかり辛さもありますが, mstore
を実行する時には 0x40
と 0x60
がそれぞれ積まれているのがわかります.また, mstore
によって 0x40
の位置に 0x60
を32byte拡張したものが格納されるので,赤く丸をしたところに60という数字が現れています.jumpする直前まで飛ばします.
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へ移動してからは,まずコンストラクタの処理である「 0xdeadbeef
を data
に代入」があります.
sstore
によって,32byte拡張された0をkeyにvalueである32byte拡張された 0xdeadbeef
がstorageへと保存されているのがわかります.その後, codecopy
まで処理は進んでいき,
codecopy
が実行された直後のmemoryを見てみると, sub_0
のコードがmemoryへ展開されているのがわかります.
いくつかの疑問点
コントラクトをデプロイする過程を追ってきましたが,いくつか疑問が残っています.
mstore(0x40, 0x60)
とは何のためにあるのかsub_0
のラベルが付いているルーチンはなんなのかsub_0
に書かれているauxdata
とは
とりあえずこの3つについて書いていきます.
mstore(0x40, 0x60)について
まず, 0x40
というのは特別な場所らしく free memory pointer
という空きメモリへのポインタを保存しておく場所です.この命令から始まることで, 0x40
の位置に 0x60
という値を入れておき, 0x60
が空いているよと伝えることができます.また, 0x60
に mstore
を使って保存した値をもし保持しておきたい場合は free memory pointer
を他の値で更新し,次はその場所を使うようにしたりと,そんな風に使うっぽいです.
ですが,これって慣習として”そういう風に使う領域”と言われているだけで,書き込みが禁止されていたりとかはないんですよね.ですので,コントラクトの本体コードが大きいものをデプロイした場合,↑で見た処理の通り sub_0
がmemoryの 0x0
に保存されるので, 0x40
にある free memory pointer
はすぐ上書きされてしまうわけで.
結局よくわからなくなったので,もう少し調べてみる必要がありそうです.
sub_0のラベルが付いているルーチンについて
これは恐らく,コンストラクタ以外のコントラクトの関数が含まれる箇所で,例えば get
と set
だけのシンプルなコントラクトにした場合, get
と set
にあたる処理は sub_0
へ入ります.もう少し大きなコントラクトで試した場合も sub_?
というラベルは sub_0
しか出てくることはなかったので,自分はコンストラクタ以外の関数(処理),つまりコントラクトの本体が含まれる箇所だと解釈しています.
sub_0に書かれている auxdata とは
これはググっても本当に情報が出てこなくて,恐らく auxdata
という呼称を使っているのは Solidity なので,Solidity のソースコードを探ってみました.ちなみに auxdata
という言葉自体は auxiliary data
の略称であり,補助データという意味です.
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を紹介しているサイトがあるので,詳しくはこちらをご覧ください.
ということで,このツールを少しだけ使ってみます.足りないパッケージがあったりしたので,インストール方法を載せておきます.
インストール ( 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の使い方!とか基本的なセットアップとかの情報は日本語でも増えてきたものの,もっと深い実装寄りの話においては日本語の記事はほとんど存在していなくて,英語の記事もまとまっているものは少なくて部分的だったり,情報を探すのに本当に苦労しました. でも,情報がここまで多くない時代ってそれが当たり前で,皆さんいろいろ探りながらブログとかへまとめていったんだなぁと思うと本当に先人に感謝だし,自分ももっと情報をアウトプットするべきだなと感じました.
以上ですが,何か間違いなどあればご指摘お願いします.
参照
- https://ethereum.stackexchange.com/questions/2823/what-does-bytecode-of-blank-contract-do/2829
- https://lilymoana.github.io/evm_part5.html
- https://github.com/androlo/solidity-workshop/blob/master/tutorials/2016-03-09-advanced-solidity-I.md
- http://solidity.readthedocs.io/en/develop/miscellaneous.html
- https://ethereum.stackexchange.com/questions/9603/understanding-mload-assembly-function