如何使用 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.0opens in a new tab
基于属性的模糊测试简介
Echidna 是一款基于属性的模糊测试工具,我们在之前的博文(1opens in a new tab、2opens in a new tab、3opens in a new tab)中介绍过它。
模糊测试
模糊测试opens in a new tab是安全领域众所周知的一项技术。 它通过生成或多或少随机的输入来查找程序中的漏洞。 用于传统软件的模糊测试工具(例如 AFLopens in a new tab 或 LibFuzzeropens in a new tab)是公认的高效查漏洞工具。
除了纯粹随机地生成输入外,还有许多技术和策略可用于生成优质输入,其中包括:
- 从每次执行中获取反馈,并用其指导生成。 例如,如果新生成的输入导致发现一条新路径,那么生成与其相近的新输入就是有意义的。
- 根据结构化约束生成输入。 例如,如果你的输入包含一个带校验和的标头,那么让模糊测试工具生成能够验证该校验和的输入就是有意义的。
- 使用已知输入生成新输入:如果你有权访问一个有效输入的大型数据集,你的模糊测试工具可以从中生成新的输入,而不是从头开始生成。 这些通常称为_种子_。
基于属性的模糊测试
Echidna 属于一种特定的模糊测试工具:基于属性的模糊测试,其深受 QuickCheckopens in a new tab 的启发。 与试图查找程序崩溃的传统模糊测试工具不同,Echidna 试图破坏用户定义的不变量。
在智能合约中,不变量是 Solidity 函数,可以表示合约可能达到的任何不正确或无效状态,包括:
- 访问控制不正确:攻击者成为合约的所有者。
- 状态机不正确:合约暂停时仍可转移代币。
- 算法不正确:用户可以下溢其余额并获得无限的免费代币。
用 Echidna 测试属性
我们将了解如何使用 Echidna 测试智能合约。 目标是以下智能合约 token.solopens 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.solopens 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 找到一个特定的交易序列来改变状态变量。 这对模糊测试工具来说很困难(建议使用符号执行工具,例如 Manticoreopens 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 // 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) - 不要断言明显的语句。 例如
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.solopens 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 找到特定值来改变状态变量。 这对模糊测试工具来说很困难 (建议使用符号执行工具,例如 Manticoreopens 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 仍然找不到正确的 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我们将修改 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显示全部这一次,它立即发现违反了该属性。
查找高燃料消耗的交易
我们将了解如何使用 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测量燃料消耗
要使用 Echidna 测量燃料消耗,请创建配置文件 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显示全部- 所示燃料是由 HEVMopens 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 最多使用 10746 燃料4...5check 最多使用 23730 燃料6...7clear 最多使用 35916 燃料8...9push 最多使用 40839 燃料显示全部这是因为成本取决于 addrs 的大小,而随机调用往往会使数组几乎为空。
将 pop 和 clear 加入黑名单却给我们带来了更好的结果:
1filterBlacklist: true2filterFunctions: ["pop", "clear"]1echidna-test pushpop.sol --config config.yaml2...3push 最多使用 40839 燃料4...5check 最多使用 1484472 燃料总结:查找高燃料消耗的交易
Echidna 可以使用 estimateGas 配置选项找到消耗大量燃料的交易:
1estimateGas: trueechidna-test contract.sol --config config.yaml...模糊测试结束后,Echidna 将报告每个函数中消耗燃料最多的交易。
页面最后更新: 2025年10月21日