如何使用 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 個代幣
- 此代幣無法轉移 (它不是 ERC20 代幣)
撰寫屬性
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過濾函數
Echidna 很難找到正確的序列來測試此合約,因為兩個重設函數 (reset1 和 reset2) 會將所有狀態變數設為 false。
然而,我們可以使用一個特殊的 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 將幾乎立即找到可證偽該屬性的交易序列。
摘要:過濾函數
在模糊測試活動中,Echidna 可以使用以下方式將函數列入黑名單或白名單:
1filterBlacklist: true2filterFunctions: ["f1", "f2", "f3"]echidna-test contract.sol --config config.yaml...Echidna 會根據 filterBlacklist 布林值的值,開始一場模糊測試活動,將 f1、f2 和 f3 列入黑名單或只呼叫這些函數。
如何使用 Echidna 測試 Solidity 的 assert
在這個簡短的教學中,我們將展示如何使用 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 函數中回報了一些斷言失敗。 每個函數可以新增多個斷言,但 Echidna 無法判斷是哪一個斷言失敗。
何時以及如何使用斷言
斷言可以用作明確屬性的替代方案,特別是當要檢查的條件與某個操作 f 的正確使用直接相關時。 在一些程式碼之後加入斷言,將強制在它執行後立即進行檢查:
1function f(..) public {2 // 一些複雜的程式碼3 ...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) - 不要斷言顯而易見的陳述。 例如
assert(var >= 0),其中var被宣告為uint。
最後,請不要使用 require 來代替 assert,因為 Echidna 將無法偵測到它 (但合約無論如何都會回復)。
摘要:斷言檢查
以下總結了 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.yamlEchidna 仍然找不到正確的魔術值,但我們可以看看它收集的語料庫。 例如,其中一個檔案是:
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我們將修改 new.txt 來呼叫 magic(42,129,333,0)。 現在,我們可以重新執行 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顯示全部這一次,它立即發現與該屬性發生了衝突。
尋找高 gas 消耗的交易
我們將了解如何使用 Echidna 找到高 gas 消耗的交易。 目標是以下的智能合約:
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 可能會有大量的 gas 消耗。
目前,Echidna 總是需要一個屬性來測試:這裡 echidna_test 總是返回 true。
我們可以執行 Echidna 來驗證這一點:
1echidna-test gas.sol2...3echidna_test: passed! 🎉45Seed: 2320549945714142710測量 Gas 消耗
要透過 Echidna 啟用 gas 消耗測量,請建立一個設定檔 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 gas Call sequence: f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2Unique instructions: 157Unique codehashes: 1Seed: -325611019680165325顯示全部- 顯示的 gas 是由 HEVM (opens in a new tab) 提供的估計值。
過濾掉減少 Gas 的呼叫
上方關於在模糊測試活動中過濾要呼叫的函數的教學展示了如何
從您的測試中移除一些函數。
這對於獲得準確的 gas 估計值至關重要。
請參考以下範例:
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 可以呼叫所有函數,它將不易找到高 gas 成本的交易:
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摘要:尋找高 gas 消耗的交易
Echidna 可以使用 estimateGas 設定選項來尋找高 gas 消耗的交易:
1estimateGas: trueechidna-test contract.sol --config config.yaml...一旦模糊測試活動結束,Echidna 將回報每個函數具有最大 gas 消耗的序列。
頁面最後更新時間: 2025年10月21日