本文深入探讨了使用Echidna进行智能合约模糊测试的实际应用,涵盖了安装、配置和两种不同的测试模式。作者通过示例详细展示了如何通过调整参数来优化模糊测试,并提供了与真实DeFi协议(如Uniswap V3)互动的案例,强调了在模糊测试中仔细准备和优化测试的重要性。
作者:Sergey Boogerwooger , MixBytes 的安全研究员
在本文中,我们将回顾智能合约的实际模糊测试,并旨在执行两种不同类型的分析。我们的目标是说明现实世界协议的模糊测试所涉及的内容。我们的计划包括提到 Echidna 配置中的重要选项,讨论加速模糊测试的调整并解决相应的挑战。
关于模糊测试的综合文章面临的挑战在于可能的测试数量庞大,以及每个测试所需的计算资源。这包括配置、不变性和测试结构等方面,可以采用多种方法对其进行处理。寻找涵盖多个案例的最有效方法可能非常复杂。
我们不会深入讨论模糊测试的基础知识,因为这已经在各种文章中有了广泛的介绍。因此,不再赘述,让我们继续。
使用 Echidna 的最简单方法是从最近的 Echidna 发行版 中获取预编译的二进制文件(我们使用当前发行版:2.2.2,echidna 可执行文件的直接下载链接为 这里)。另外,你可以从源代码构建它。此外,我们建议使用具有多个 CPU 核心的强大配置,因为模糊测试过程可以从并行化中获益匪浅。
solc-select
你很可能会使用不同的合约,每个合约需要不同版本的 Solidity 编译器 solc。为简化在不同 solc 版本之间的切换,非常方便使用 solc-select 工具。安装它,以便通过简单的命令如 solc-select use 0.8.0 轻松切换到所需的 solc 版本。运行 solc --version 检查你当前的版本并切换到正确的版本。
HEVM
Echidna 使用 HEVM - 一种 EVM 的符号执行。其某些功能在测试中非常有用。它使你能够在特定区块上测试你的合约,模拟时间戳并修改存储值。虽然 HEVM 功能不多,但每个功能在测试中都非常强大。
链上数据
Echidna 不仅允许通过编译本地合约进行分析,还可以通过连接到任何现有网络,直接从区块链获取合约数据和存储。因此,它对我们非常方便,因为我们计划与不同的现有 ERC20 代币和 DeFi 协议进行交互,使用实际的链状态,跳过预初始化和部署。
RPC 节点
要访问链上数据,Echidna 需要访问以太坊 RPC 节点。此访问特别密集,尤其是如果你计划执行大量的模糊测试。因此,请小心,你可能会遇到像以下这样的错误日志:
ERROR: Failed to fetch contract: <EVM.Query: fetch contract 0x...>
但仍继续执行测试且结果为zero。这些错误可能仅在一段时间后出现,当你达到某些公共 RPC 节点的速率限制时。因此,为了执行尽可能多的模糊测试,你应该有一个好的 RPC 端点(最好是你自己的节点),能够处理大量请求。
Echidna 配置
与 Echidna 一起工作的重要部分是配置测试。许多测试非常繁重,需要调整测试序列的数量、分析深度、过滤被模糊测试的函数等。此外,测试需要许多前期调整,如起始余额、发送者地址、Gas 和 Gas 价格等。
在实践中,你将花费大量时间为你的测试配置 Echidna,因此在开始之前查看所有选项及其描述是有益的。首先要阅读的是 default.yaml(链接至当前版本),以了解所有可用的选项。我们将描述其中的一些,因为许多参数在文档中没有明确的说明。
测试模式
Echidna 提供了多种测试模式,在测试真实协议时,选择正确的模式和测试配置至关重要,许多案例完全无法达到,因为复杂场景需要数小时进行测试,且没有结果的保证。你必须记住,其中一个函数调用或与合约的交互(特别是那些改变状态的调用)可能会使分析变得非常繁重。
在本文中,我们仅使用两种不同的测试模式:属性模式和优化模式。第一种模式使我们能够找到“破坏”不变函数的输入参数组合。第二种模式使我们能够找到导致我们“不变”函数最大值的输入参数。还有其他模式,例如,一个非常有用的断言,使得避免编写任何特殊函数成为可能。只需将 assert() 添加到你的代码中,并尝试使用 Echidna 达到该断言。
测试结构
为了演示不同的模糊测试场景,我们创建了一个教育性的 repo,其中已包含 echidna 二进制文件(版本2.2.2)在 bin 文件夹中,以及在 fuzzing-test-1 和 fuzzing-test-2 目录下的两个独立测试用例。出于教育目的、简单性以及能够复制、修改和实验示例的原则,我们优先考虑了功能而非代码美观,因此请原谅我们的代码缺乏美感。 :)
每个测试用例(首先在 fuzzing-test-1 文件夹中)由两个主要文件组成:
要运行此测试用例,你可以使用我们的 echidna 二进制文件或你自己的。来自教育存储库的第一个测试的运行命令如下:
git clone https://github.com/mixbytes/echidna-farm.git
cd echidna-farm
cd fuzzing-test-1
../bin/echidna fuzzing-test-1.sol --config fuzzing-test-1.yaml --contract FuzzingTest1 --format text
[注意] 最后一个选项 --format text 用于避免终端中的颜色问题(如果你在没有颜色支持的终端中运行 Echidna)。
第二个测试用例使用类似的命令运行,但使用了另一配置文件和不同的 Solidity 源。
让我们检查第一个测试用例,在该用例中,我们与 Sepolia 测试网络中的现有 WETH 合约进行交互。然而,我们仅执行简单操作,展示 Echidna 能够识别导致不变破裂的“特殊”值,声明为 fuzzing-test-1.sol 中的 echidna_secret_fee_not_taken() 函数,我们还展示了它处理现有合约(如 WETH)的能力。
测试结构非常简单:
Echidna 的目标是找出破坏不变的秘密值 这里,该不变式函数仅在费用为零时返回 true。在这里,秘密的搜索被简化,但在现实场景中,这个过程可以包括费用和奖励的计算。当算法错误地计算费用/奖励,可能影响用户或协议的回报时,Echidna 可以帮助发现边缘情况。
请查看一些重要选项:
让我们通过取消注释 CASE 1 行 来尝试运行我们的第一个测试用例。这是一个简单的任务,Echidna 找到了我们破坏不变式的秘密值:
你可以尝试不同的案例来实验秘密值的计算。现在,让我们关注 CASE 3,其中有条件 if (secret > 100000000 && secret**9 % 10 == 0)。当 secret 的幂小(在我们的例子中等于“9”)时,一切正常。Echidna 找到了值。但是,如果你增加这个幂,Echidna 将无法在没有额外的模糊测试迭代的情况下找到正确的秘密值。你需要增加测试迭代次数,但即便如此,也没有保证你会找到正确的值。
在 CASE 4 中也存在同样的情况,使用了 sqrt() 函数。如果你增加所需的 sqrt() 值,Echidna 将无法找到对应的值。
这里来了测试准备的下一个要求。我们可以通过在函数开始处使用类似 require(secret > 7500 && secret < 8000); 的方式来限制测试中的秘密值(并使用条件 if (sqrt(secret) == 87) // secret =~ 7569)。通过添加这样的限制,我们阻止了许多无用的秘密值并节省了测试迭代。在这种情况下,Echidna 容易找到某个值,使我们得到 sqrt(secret) == 87:
这个测试用例的目标是表明模糊测试可以揭示破坏不变的边缘情况。然而,构建有效的测试并定义有效的不变式,以便高效地进行模糊测试是至关重要的,因为没有保证所有可能的值组合都已被测试,因为模糊测试并不是一种正式的验证方法。
第二个案例要复杂得多。如前所述,我们的目标是测试真实协议和现实场景。本部分的计划是探索一种极有趣的 Echidna 模糊测试类型:优化。这种分析类型通过测试不同输入参数组合来最大化某些参数(即利润)。这种分析可以使安全审计人员不仅能够检测到不变式的破裂,还能够发现可能允许攻击者获得异常奖励的漏洞,即使合约的整体逻辑没有受到损害(例如,铸造错误的奖励、费用等)。
在我们的案例中,我们将测试 Uniswap V3 交换池,旨在找到分配流动性的ticks 范围,以最大化利润(在执行某些预定义交换的情况下)。在 Uniswap V3 中,流动性应分配到特定的价格范围,收集的费用数量取决于交换的数量以及交换价格在价格 ticks 之间的移动。因此,我们计划分配流动性,执行一些交换,收集费用,并尝试使用 Echidna 的优化模式最大化它们。
[注意] 不要过于苛刻于测试代码的质量,这是通过大量的更改成果,以尝试达到最佳性能和展示实际场景。需要对不同的测试参数进行实验,以实现这一点。在测试任何现实协议时,请记住要期望这种复杂性。模糊测试并不困难,但确实需要大量实验、初步测试和时间。
让我们更详细地描述 fuzzing-test-2.sol 中的一些步骤:
准备一些 WETH 和 DAI 的余额
向 WETH/DAI 池添加流动性(将一半的 WETH 代币和相应数量的 DAI) 这里
通过调用 UniswapV3 的 mint() 函数 这里
ticks 范围由输入参数 _tickDiff 确定,而这是 Echidna 尝试优化的参数。
执行多次交换(DAI->WETH,WETH->DAI,DAI->WETH) 这里
向池中放置少量流动性(1) 这里(使池重新计算费用)
调用 collect_rewards(),而不实际调用池的 collect() 函数,仅仅分析我们的仓位 这里。这就足够估计所获费用,让我们的测试更轻量级。
在测试中,我们计算我们在池的流动性中放置了多少 WETH。随后在 collect_rewards() 中,我们计算所获 WETH 费用与放入池中的 WETH 之间的关系。这帮助我们识别最有利的 ticks 范围。
如果我们在 Hardhat 中运行测试(通过取消注释引入调试信息的行 import 'hardhat/console.sol' 和 collect_rewards() 中的 console.log()),我们会收到:
我们看到传入 120 作为 _tickDiff 返回了最大利润,因此让我们尝试使用 Echidna 找到这个值。
此测试的配置文件为 fuzzing-test-2.yaml。在你的模糊测试实验中,你将花费大量时间在此配置中,因此务必检查其含义并根据你的测试需求进行调整。
其他参数与部署合约的起始余额、部署者、发送者以及模仿链上逻辑所需的其他值相关。它们在文档中有很好的描述。
让我们用以下命令运行我们的测试:
time ../bin/echidna contracts/fuzzing-test-2.sol --config ./fuzzing-test-2.yaml --contract FuzzingTest2 --format text
(不要忘记切换到正确的 solc 版本)
Echidna 找到了最佳的 _tickDiff 参数:
但这是多次实验的结果。模糊测试的全球性问题在于,当测试比较繁重时,潜在的“值流”的数量较大,“空”调用序列占用大部分时间。因此,测试不能在可观的时间内返回结果。模糊测试没有保证能发现漏洞,即使已知存在漏洞。为了有效利用模糊测试,模糊测试的测试应经过仔细准备并在大规模上优化。
在处理某些现实协议时,最好从测试场景开始,将其放置在几个函数中,理想情况下在一个函数内。
这样可以防止 Echidna 在检查毫无意义的调用组合上浪费时间。此外,建议减少被优化参数的数量,然后通过逐步“打开”代码部分来开始模糊测试。始终检查模糊测试过程是否仍在真实值上运行,避免测试数百万个繁重的 “revert()”。
另外,请注意“减少不合逻辑的参数值” 可能在你试图使测试轻量时,意外排除危险值。当在现实中,此类参数以某种方式可以传递给协议时,务必小心。
让我们总结一下在何种情况下模糊测试可以有效增强你协议的安全性。它并不是“灵丹妙药”,因为它需要仔细准备测试;否则,你可能只是在浪费硬件,消耗时间进行无用的计算。可能的代码流数量庞大,因此模糊测试的最佳使用是针对从安全角度最有趣的调用序列进行工作。模糊测试可以有效的良好示例包括:
正如前面提到的,模糊测试要求对被分析代码进行广泛优化,并确保不要排除“过多”,因为这可能意外地消除潜在的攻击面。
“这里是合约,一亿次迭代,数十个不变式 - 请发现错误”这种方法在没有超级计算机的情况下无法与模糊测试有效结合。
使用模糊测试时要小心,准备深入研究你的协议,编写大量测试,并保持耐心。保持安全!
MixBytes 是一个专业的区块链审计师和安全研究员团队,专注于为 EVM 兼容和基于 Substrate 的项目提供全面的智能合约审计和技术咨询服务。加入我们的 X ,以便随时了解行业最新趋势和见解。
- 原文链接: mixbytes.io/blog/fuzzing...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!