スマートコントラクトのテストにEchidnaを使用する方法
インストール
Echidnaは、Dockerまたはコンパイル済みのバイナリを使用してインストールします。
DockerからEchidnaをインストールする
docker pull trailofbits/eth-security-toolboxdocker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox
最後のコマンドは、現在のディレクトリにアクセスできるdockerでeth-security-toolboxを実行します。 ホストからファイルを変更し、dockerからファイル上のツールを実行できます。
dockerで、以下を実行します:
solc-select 0.5.11cd /home/training
バイナリの入手先
https://github.com/crytic/echidna/releases/tag/v1.4.0.0(opens in a new tab)
プロパティベースのファジングとは
Echidnaは、プロパティベースのファザーです。これについては、以前のブログ投稿(1(opens in a new tab)、2(opens in a new tab)、3(opens in a new tab))を参照してください。
ファジング
ファジング (opens in a new tab)は、セキュリティコミュニティでよく知られているテクニックです。 ファジングでは、プログラム内のバグを見つけるために、大小のランダムな入力を生成することを行います。 通常のソフトウェアを対象とするファザー(AFL(opens in a new tab)やLibFuzzer(opens in a new tab)など)は、効率的にバグを特定できるツールであると評価されています。
入力をまったくランダムに生成するだけでなく、適切な入力を生成するための多くのテクニックや戦略を活用できます:
- 各実行から取得したフィードバックに基づき、入力を生成する。 例えば、新しく生成された入力値が新しいパスの発見を導いている場合、それに近い新しい入力値を生成することは理にかなっています。
- 構造上の制約を考慮した入力を生成する。 例えば、入力にチェックサム付のヘッダーが含まれている場合、ファザーにチェックサムを検証する入力を生成させることも有益です。
- 既知の入力に基づいて新たな入力を生成する。大規模な有効な入力のデータセットにアクセスできる場合、まったくランダムに生成するのではなく、それらに基づいて新たな入力を生成することができます。 この場合、参照するデータをシードと呼びます。
プロパティベースのファジング
Echidnaは、プロパティに基づくファジングを実行するファザーであり、QuickCheck(opens in a new tab)の影響を強く受けたプログラムです。 クラッシュを監視する従来のファザーとは異なり、Echidnaでは、ユーザー定義の不変条件を壊そうとします。
スマートコントラクトにおける不変条件とは、コントラクトにおいて不適切または無効な状態が発生しうるSolidityの関数を意味します。具体的には、以下が挙げられます:
- 不適切なアクセス制御:攻撃者がコントラクトの所有者になる場合。
- 不適切な状態マシン:コントラクトの一次停止中に、トークンを送信できる。
- 不適切な計算: ユーザーは残高をアンダーフローし無制限に無料トークンを取得できる。
Echidnaを使って、プロパティをテストする
それでは、Echidnaを使ってスマートコントラクトをテストする方法を見てみましょう。 対象は、 token.sol
(opens in a new tab)のスマートコントラクトです。
1contract Token{2 mapping(address => uint) public balances;3 function airdrop() public{4 balances[msg.sender] = 1000;5 }6 function consume() public{7 require(balances[msg.sender]>0);8 balances[msg.sender] -= 1;9 }10 function backdoor() public{11 balances[msg.sender] += 1;12 }13}すべて表示コピー
このトークンは、以下のプロパティを持つと想定します:
- ユーザーは、最大1000トークンを所持できる
- このトークン(ERC-20トークンではない)は、送信不可である
プロパティを記述する
Echidnaのプロパティは、Solidityの関数です。 プロパティは、以下の条件を満たす必要があります:
- 引数を持たない
- 実行に成功した場合、
true
を返す echidna
で始まる名前を持つ
Echidnaは、以下を実行します:
- このプロパティをテストするためのランダムなトランザクションを自動で生成する。
- プロパティが
false
またはエラーを返すすべてのトランザクションを報告する。 - プロパティの呼び出しに伴う副作用を無視する(つまり、プロパティが状態変数を変更した場合、テスト後にこの変更を破棄する)
以下のプロパティは、呼び出し元のユーザーが所持するトークンが1000以下であることを確認します。
1function echidna_balance_under_1000() public view returns(bool){2 return balances[msg.sender] <= 1000;3}コピー
継承を使って、コントラクトとプロパティを分離します。
1contract TestToken is Token{2 function echidna_balance_under_1000() public view returns(bool){3 return balances[msg.sender] <= 1000;4 }5 }コピー
token.sol
(opens in a new tab)は、プロパティを実装し、このトークンを継承します。
コントラクトを開始する
Echidnaでは、引数なしのコンストラクタが必要です。 コントラクトにおいて特定の初期化が必要な場合、コンストラクタ上で実行する必要があります。
Echidnaには、いくつかの特定のアドレスが含まれます:
0x00a329c0648769A73afAc7F9381E08FB43dBEA72
:コンストラクタを呼び出すアドレスです。0x10000
、0x20000
、0x00a329C0648769a73afAC7F9381e08fb43DBEA70
:他の関数をランダムに呼び出すアドレスです。
このチュートリアルでは特定の初期化を実行する必要がないため、コンストラクタは空になります。
Echidnaを実行する
以下のコードで、Echidnaを起動します:
echidna-test contract.sol
contract.solに複数のコントラクトが含まれる場合、実行したいコントラクトを指定できます:
echidna-test contract.sol --contract MyContract
プロパティテストのまとめ
以下は、このチュートリアルにおけるEchidnaの実行をまとめたものです。
1contract TestToken is Token{2 constructor() public {}3 function echidna_balance_under_1000() public view returns(bool){4 return balances[msg.sender] <= 1000;5 }6 }コピー
echidna-test testtoken.sol --contract TestToken...echidna_balance_under_1000: failed!💥Call sequence, shrinking (1205/5000):airdrop()backdoor()...すべて表示
Echidnaは、 backdoor
が呼び出された場合、このプロパティが侵害されることを確認しました。
ファジング中に呼び出す関数を絞り込む
ファジングの対象となる関数を絞り込む方法を見ていきましょう。 以下のスマートコントラクトを対象とします:
1contract C {2 bool state1 = false;3 bool state2 = false;4 bool state3 = false;5 bool state4 = false;67 function f(uint x) public {8 require(x == 12);9 state1 = true;10 }1112 function g(uint x) public {13 require(state1);14 require(x == 8);15 state2 = true;16 }1718 function h(uint x) public {19 require(state2);20 require(x == 42);21 state3 = true;22 }2324 function i() public {25 require(state3);26 state4 = true;27 }2829 function reset1() public {30 state1 = false;31 state2 = false;32 state3 = false;33 return;34 }3536 function reset2() public {37 state1 = false;38 state2 = false;39 state3 = false;40 return;41 }4243 function echidna_state4() public returns (bool) {44 return (!state4);45 }46}すべて表示コピー
この簡単な例は、状態変数を変更する特定のトランザクションのシーケンスをEchidnaに見つけさせるものです。 これは、ファザーにとって容易ではありません(Manticore(opens in a new tab)のようなシンボリック実行ツールを使用することをお勧めします)。 Echidnaで、以下のように検証を実行します:
echidna-test multi.sol...echidna_state4: passed! 🎉Seed: -3684648582249875403
対象の関数を絞り込む
2つのリセット関数(reset1
とreset2
)がすべての状態変数をfalse
に設定するため、Echidnaはこのコントラクトをテストするための正しいシーケンスを見つけられません。 しかしEchidnaでは、リセット関数をブラックリストに含めるか、 f
、g
、 h
、および i
の関数のみをホワイトリストに含める特別の機能が利用できます。
関数をブラックリストに登録するには、設定ファイルを以下のように指定します:
1filterBlacklist: true2filterFunctions: ["reset1", "reset2"]
関数を絞り込むもう一つの方法は、ホワイトリストに含まれる関数を列挙することです。 これには、設定ファイルを以下のように指定します:
1filterBlacklist: false2filterFunctions: ["f", "g", "h", "i"]
filterBlacklist
の初期値はtrue
です。- 絞り込みは、名前のみ(パラメータなし)で実行されます。
f()
とf(uint256)
の両方が含まれる場合、"f"
で絞り込むと両方の関数がヒットします。
Echidnaを実行する
設定ファイル blacklist.yaml
に従ってEchidnaを実行するには、以下のようにします:
echidna-test multi.sol --config blacklist.yaml...echidna_state4: failed!💥Call sequence:f(12)g(8)h(42)i()
Echidnaは、プロパティをfalseにするトランザクションのシーケンスを瞬時に特定します。
対象の関数を絞り込む作業のまとめ
Echidnaでは、ファジングで呼び出す機能を絞り込むために、ブラックリストあるいはホワイトリストの関数を使用します:
1filterBlacklist: true2filterFunctions: ["f1", "f2", "f3"]
echidna-test contract.sol --config config.yaml...
Echidnaは、filterBlacklist
のブール値に基づき、f1
、f2
、および f3
の関数をブラックリストに含めるか、これらの関数のみを呼び出してファジングを実行します。
EchidnaでSolidityのアサーションをテストする方法
次の短いチュートリアルでは、Echidnaを使って、コントラクトに含まれるアサーションをテストします。 以下のようなコントラクトを想定します:
1contract Incrementor {2 uint private counter = 2**200;34 function inc(uint val) public returns (uint){5 uint tmp = counter;6 counter += val;7 // tmp <= counter8 return (counter - tmp);9 }10}すべて表示コピー
アサーションを記述する
引き算を実行した後に、tmp
の値がcounter
の値以下であることを確認したいとします。 Echidnaのプロパティで記述することもできますが、tmp
値をどこかに格納する必要があります。 これには、以下のようなアサーションを用いることができます:
1contract Incrementor {2 uint private counter = 2**200;34 function inc(uint val) public returns (uint){5 uint tmp = counter;6 counter += val;7 assert (tmp <= counter);8 return (counter - tmp);9 }10}すべて表示コピー
Echidnaを実行する
アサーションの失敗をテストできるようにするには、Echidnaの設定ファイル(opens in a new tab)として config.yaml
を作成します:
1checkAsserts: true
このコントラクトをEchidnaで実行すると、次のような期待通りの結果が得られます:
echidna-test assert.sol --config config.yamlAnalyzing contract: assert.sol:Incrementorassertion in inc: failed!💥Call sequence, shrinking (2596/5000):inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)Seed: 1806480648350826486すべて表示
このように、Echidnaでは、アサーション違反の一部をinc
関数で報告します。 1つの関数に対し複数のアサーションを含めることは可能ですが、どのアサーションが失敗したのか区別できなくなります。
アサーションをいつ、どのように使用すべきか
アサーションは、特にチェックしたい条件がf
という特定の操作を適切に用いることと直接関係する場合に、プロパティを明示しない代替手段として用いることができます。 コードにアサーションを追加することで、このコードを実行した直後に強制的にチェックが実行されます。
1function f(..) public {2 // some complex code3 ...4 assert (condition);5 ...6}7コピー
反対に、Echidnaのプロパティを明示的に使用する場合、トランザクションがランダムに実行されるため、チェックをどの時点で強制的に実行させるかを決定しにくくなります。 この場合、以下のような回避策を用いることもできます:
1function echidna_assert_after_f() public returns (bool) {2 f(..);3 return(condition);4}コピー
しかし、以下の問題点が残ります:
f
がinternal
あるいはexternal
と宣言されている場合、違反になる。- どの引数を使って
f
を呼び出すべきかが不明確である。 f
が元に戻された場合、このプロパティは違反になる。
全般的なアサーションの使用については、このJohn Regehrの提案(opens in a new tab)に従うことを推奨します:
- アサーションチェック中には、副作用を強制しない。 例:
assert(ChangeStateAndReturn() == 1)
- 明らかなステートメントは、アサートしない。 例:
var
をuint
と宣言している場合、assert(var >= 0)
は必要ない。
最後に、assert
の代わりにrequire
を用いるのは避けてください。Echidnaではrequireを検出できません。(ただしこの場合でも、コントラクトは元に戻されます)。
アサーションチェックのまとめ
以下は、この例におけるEchidnaの実行をまとめたものです:
1contract Incrementor {2 uint private counter = 2**200;34 function inc(uint val) public returns (uint){5 uint tmp = counter;6 counter += val;7 assert (tmp <= counter);8 return (counter - tmp);9 }10}すべて表示コピー
echidna-test assert.sol --config config.yamlAnalyzing contract: assert.sol:Incrementorassertion in inc: failed!💥Call sequence, shrinking (2596/5000):inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)Seed: 1806480648350826486すべて表示
Echidnaは、inc
のアサーションにつき、この関数が大きな引数で複数回呼び出された場合に違反となりうることを発見しました。
Echidnaコーパスを収集、修正する
次に、Echidnaを使ってトランザクションのコーパスを収集し、これを利用する方法について見ていきましょう。 対象は、magic.sol
(opens in a new tab)のスマートコントラクトです。
1contract C {2 bool value_found = false;3 function magic(uint magic_1, uint magic_2, uint magic_3, uint magic_4) public {4 require(magic_1 == 42);5 require(magic_2 == 129);6 require(magic_3 == magic_4+333);7 value_found = true;8 return;9 }1011 function echidna_magic_values() public returns (bool) {12 return !value_found;13 }1415}すべて表示コピー
以下の短いコードは、状態変数を変更する特定の値をEchidnaに発見させます。 これは、ファザーにとって容易ではありません(Manticore(opens in a new tab)のようなシンボリック実行ツールを使用することをお勧めします)。 Echidnaで、以下のように検証を実行します:
echidna-test magic.sol...echidna_magic_values: passed! 🎉Seed: 2221503356319272685
ただしEchidnaでは、ファジングの実行中もコーパスを収集することができます。
コーパスを収集する
コーパスを収集するには、まずコーパスのディレクトリを作成します:
mkdir corpus-magic
さらに、Echidnaの設定ファイル(opens in a new tab)である config.yaml
を作成します:
1coverage: true2corpusDir: "corpus-magic"
これで、ツールを実行しながら収集したコーパスをチェックできるようになりました:
echidna-test magic.sol --config config.yaml
この段階ではEchidnaはまだ適切なmagic値を特定できませんが、収集したコーパスを確認することはできます。 例えば、以下のようなファイルが収集されました:
1[2 {3 "_gas'": "0xffffffff",4 "_delay": ["0x13647", "0xccf6"],5 "_src": "00a329c0648769a73afac7f9381e08fb43dbea70",6 "_dst": "00a329c0648769a73afac7f9381e08fb43dbea72",7 "_value": "0x0",8 "_call": {9 "tag": "SolCall",10 "contents": [11 "magic",12 [13 {14 "contents": [15 256,16 "93723985220345906694500679277863898678726808528711107336895287282192244575836"17 ],18 "tag": "AbiUInt"19 },20 {21 "contents": [256, "334"],22 "tag": "AbiUInt"23 },24 {25 "contents": [26 256,27 "68093943901352437066264791224433559271778087297543421781073458233697135179558"28 ],29 "tag": "AbiUInt"30 },31 {32 "tag": "AbiUInt",33 "contents": [256, "332"]34 }35 ]36 ]37 },38 "_gasprice'": "0xa904461f1"39 }40]すべて表示コピー
言うまでもなく、この入力はプロパティ違反をトリガーしません。 しかし、次のステップでこれを修正することができます。
コーパスをシードする
Echidnaをmagic
関数に対応するように設定する必要があります。 この入力が適切なパラメータを使用できるように、コピーし、変更します。
cp corpus/2712688662897926208.txt corpus/new.txt
magic(42,129,333,0)
を呼び出せるようにnew.txt
を変更します。 その上で、Echidnaを再実行します:
echidna-test magic.sol --config config.yaml...echidna_magic_values: failed!💥Call sequence:magic(42,129,333,0)Unique instructions: 142Unique codehashes: 1Seed: -7293830866560616537すべて表示
今回は、プロパティ違反がただちに検出されました。
ガス消費量が多いトンラザクションを見つける
ガス消費量が多いトランザクションを特定するために、Echidnaを使用する方法について見ていきましょう。 対象は、次のスマートコントラクトです:
1contract C {2 uint state;34 function expensive(uint8 times) internal {5 for(uint8 i=0; i < times; i++)6 state = state + i;7 }89 function f(uint x, uint y, uint8 times) public {10 if (x == 42 && y == 123)11 expensive(times);12 else13 state = 0;14 }1516 function echidna_test() public returns (bool) {17 return true;18 }1920}すべて表示コピー
ここでは、expensive
でガス消費量が高くなる可能性があります。
この時点では、Echidna上で常にテストすべきプロパティを設定する必要があり、echidna_test
は常にtrue
を返します。 Echidnaを実行して、これを確認します:
1echidna-test gas.sol2...3echidna_test: passed! 🎉45Seed: 2320549945714142710
ガス消費量を測定する
Ethidnaでガスの消費量を確認できるようにするには、 config.yaml
を以下のように設定します:
1estimateGas: true
この例では、結果を分かりやすくするために、次のようにトランザクションシーケンスのサイズを減らしています。
1seqLen: 22estimateGas: true
Echidnaを実行する
設定が完了したら、次のようにEchidnaを実行します:
echidna-test gas.sol --config config.yaml...echidna_test: passed! 🎉f used a maximum of 1333608 gasCall sequence:f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2Unique instructions: 157Unique codehashes: 1Seed: -325611019680165325すべて表示
- 表示されたガス量は、HEVM(opens in a new tab)による見積もりです。
ガス量を削減する呼び出しを対象外にする
ファジング実行時に呼び出す関数を絞り込む方法のチュートリアルでは、特定の関数をテストの対象外にする方法を示しました。
この作業は、ガス量を正確に見積もる上で非常に重要です。 以下の例を検討してみましょう:
1contract C {2 address [] addrs;3 function push(address a) public {4 addrs.push(a);5 }6 function pop() public {7 addrs.pop();8 }9 function clear() public{10 addrs.length = 0;11 }12 function check() public{13 for(uint256 i = 0; i < addrs.length; i++)14 for(uint256 j = i+1; j < addrs.length; j++)15 if (addrs[i] == addrs[j])16 addrs[j] = address(0x0);17 }18 function echidna_test() public returns (bool) {19 return true;20 }21}すべて表示コピー
Echidnaがすべての関数を呼び出せる場合、ガス代が高いトランザクションを見つけることは困難になるでしょう。
1echidna-test pushpop.sol --config config.yaml2...3pop used a maximum of 10746 gas4...5check used a maximum of 23730 gas6...7clear used a maximum of 35916 gas8...9push used a maximum of 40839 gasすべて表示
なぜかと言えば、ガス代はaddrs
のサイズに依存しており、ランダムに呼び出した場合は配列がほぼ空になるためです。 このような場合、 pop
とclear
をブラックリストに追加することで、より正確な結果を得ることができます。
1filterBlacklist: true2filterFunctions: ["pop", "clear"]
1echidna-test pushpop.sol --config config.yaml2...3push used a maximum of 40839 gas4...5check used a maximum of 1484472 gas
ガス消費量が高いトランザクションを見つける作業のまとめ
Echidnaでは、estimateGas
の設定オプションを使用してガス消費量の多いトランザクションを特定することができます:
1estimateGas: true
echidna-test contract.sol --config config.yaml...
Echidnaは、ファジングを実行した後、各関数ごとにガス消費量が最大となるシーケンスを報告します。
最終編集者: @pettinarip(opens in a new tab), 2023年12月4日