本文详尽介绍了智能合约的模糊测试和不变性测试,强调了它们在确保区块链系统安全性方面的重要性。通过使用Foundry框架,文章探讨了如何定义不变性、不同类型的模糊测试以及其相对于传统单元测试的优势,并推荐了几种最佳的模糊测试工具。
什么是模糊测试?在本指南中,你将学习使用 Foundry 进行智能合约的模糊测试和不变性测试所需了解的一切。
本文将教你理解一项关键测试策略,这在确保你的系统可靠性时至关重要:
智能合约的模糊测试(fuzz testing)和不变性测试。
在本指南及其示例中,我们将探索如何使用模糊测试和 Foundry来测试我们的不变性和智能合约函数,同时解决和揭示智能合约行为中的复杂且常常微妙的方面。
想要提升你的智能合约安全技能?参加我们在 Cyfrin Updraft 上提供的20+小时智能合约审计课程,完全免费!
这是一个与 Trail of Bits 安全工程师 Troy 一起的关于模糊测试及其工作原理的解释视频。
首先要理解为什么模糊测试如此重要的,是理解什么是不变性。 让我们来了解一下。
智能合约测试包括多种方法,以增强智能合约的链上安全性和效率。我们进行测试以确保智能合约在不同情况下始终按预期行为运行,以避免意外事件和漏洞。
不变性测试涉及定义一组条件——invariants
——这些条件无论合约的状态或输入如何,总是必须保持为真。
不变性测试总是需要:
注意:Foundry通常将不变性测试归类为有状态的模糊测试。
例如,在 DeFi 协议中,一个好的不变性可能是:
一旦我们找到了不变性,就该测试它们并验证“它们应该始终保持为真”,因此,它们不应改变状态。
测试不变性的一种方法是通过简单的单元测试,但模糊测试解决了一个问题,你将在下一节中看到。
让我们考虑一个称为 SimpleStorage
的智能合约中的简单函数。
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
contract SimpleStorage {
// 不变性在下面定义 👇👇👇
uint8 public storedData;
function setAddition(uint8 x) public {
storedData = x * 2;
}
}
函数 setAddition
将任何输入数字乘以 2
。
我们的不变性或“系统应始终保持的属性”在这种情况下将是:
storedData
永远不应回退简单来说,我们可以说我们的不变性是:
不变性:`storedData` 必须永远不会回退
你可以在这个 GitHub 仓库中找到流行智能合约标准的不变性(属性)列表。
现在,使用 Foundry,让我们为这个合约编写一个传统的单元测试,并检查我们的不变性是否成立:
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/SimpleStorage.sol";
contract SimplestorageTest is Test {
SimpleStorage simpleStorage;
function setUp() public {
simpleStorage = new SimpleStorage();
}
function testAddsWithoutRevert() public {
uint8 testValue = 100;
simpleStorage.setAddition(testValue);
(bool success, ) = address(simpleStorage).call(
abi.encodeWithSignature("setAddition(uint8)", testValue)
);
assert(success);
}
}
在此测试场景中,我们初始化一个名为 testValue
的测试变量,传递给我们的 setAddition 函数,并为它赋值 100
。
在执行该函数后,我们使用 assert 确保该函数已成功返回:
很好!在这种情况下,我们使用 Foundry 的单元不变性测试告诉我们,值为 100
不会破坏不变性。
然而,它并没有告诉我们的是,任何值从 128
及以上都会确实破坏我们的不变性,使我们的 uint8 storedData 变量溢出:
function testAddsWithoutRevert() public {
// 在这里我们明确设置一个将破坏不变性的值
// 👇👇👇
uint8 testValue = 128;
simpleStorage.setAddition(testValue);
(bool success, ) = address(simpleStorage).call(
abi.encodeWithSignature("setAddition(uint8)", testValue)
);
assert(success);
}
如果我们重新运行该测试,这将导致事务回退并抛出溢出错误:
如你所见,单元测试不变性并不有效。在非常明显的情况下,逐个配置参数以识别每种情况下值 testValue
导致“算术下溢或上溢”错误是费时的,甚至不可能或不便。
这就是模糊测试或模糊所带来的便利。让我们了解模糊测试是什么。
模糊测试消除了手动测试变量的每个可能值的需求,例如 testValue
。
那么,模糊测试是什么呢?
模糊测试使我们可以系统性地将随机数据输入测试,以打破特定的断言(函数)。通过自动化我们在测试中传递给函数的输入的随机化,我们显著简化了该过程,并捕获了我们否则不会发现的场景 **。
也就是说,有两个类型的模糊测试:无状态模糊和有状态模糊。
无状态模糊测试类似于在一次随机尝试中对 balloonA
做某些事情以破坏它,然后再吹气成一个新的 balloonB
并以不同方式尝试打破它。
在这种情况下,你永远不会尝试去打破你以前尝试过的气球。这看起来有点傻,因为如果我们的气球不变性是“气球不能被戳破”,我们就希望在同一个气球上多次尝试。
我们可以在下面的 testFuzzRevertOnOverflow 示例中看到无状态模糊测试的一个例子:
function testFuzzRevertOnOverflow(uint8 testValue) public {
// uint8 testValue; 👈 该值被注释掉
simpleStorage.setAddition(testValue);
}
在这个例子中,我们不再声明 testValue
变量。相反,我们将其作为参数传递给该函数,Foundry 将在运行时生成并传递它。
一旦我们运行测试,它将失败,指示用于打破测试的输入:
如你所见,使用来打破我们函数的数字旁边还有另一个参数:“运行”——让我们看看它的意思。
运行和反例在上面的示例中,运行计数指示随机生成的输入数量。在这个例子中,我们的模糊测试器需要生成 3 个不同的随机输入,以找到一个破坏我们不变性的值。
我们模糊测试器找到的打破我们不变性的值示例称为Counterexample
,即打破我们属性或不变性的输入。
模糊测试如何通过 3 次运行找到反例?
- 它使用某个数字运行单元测试,该数字通过了测试。
- 然后它选择另一个数字。
- 它选择了 128,并且失败了!
因为我们的模糊测试尝试了 3 个不同的数字,所以我们说它进行了 3 次运行。有时,一个模糊测试会尝试数千个潜在的反例,但其中没有一个奏效,因为没有缺陷!当发生这种情况时,开发人员可能会陷入漫长的等待。
我们通常给我们的工具一个最大运行次数以减轻这种情况。在 Foundry 中,我们可以在 foundry.toml
中找到最大运行次数。
正如我们之前所说,该测试被归类为无状态,因为storedData
变量(我们的气球)在每次迭代后都会重置为其默认值 0。
另一方面,当变量的状态跨多个运行保留时,例如此场景中的 counter
值,测试则转变为有状态模糊测试。
因此,在气球示例中,我们将在每次迭代中对同一个气球进行不同的打破尝试。
在另一个智能合约场景中,让我们建立一个新的不变性,名为 alwaysEvenNumber
。该不变性的核心条件是该数值必须始终为偶数,绝不能为奇数。
为了说明这一概念,我们添加一个名为 hiddenValue
的新变量。该变量的值在函数中被增加并等于 inputNumber 值。
如果 hiddenValue
等于 8,则 alwaysEvenNumber
被设置为 3。这个行为违反了不变性,因为它应该保持偶数状态:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
contract AlwaysEven {
uint256 public alwaysEvenNumber;
uint256 public hiddenValue;
function setEvenNumber(uint256 inputNumber) public {
if (inputNumber % 2 == 0) {
alwaysEvenNumber += inputNumber;
}
// 这个条件将打破必须始终为偶数的不变性
// 👇👇👇 在一个无状态的场景中,这个条件永远不会成立,因为 hiddenValue 将永远为 0
if (hiddenValue == 8) {
alwaysEvenNumber = 3;
}
// 我们在函数末尾将 hiddenValue 设置为 inputNumber
// 在有状态场景中,此值将在下一次调用中被记住
hiddenValue = inputNumber;
}
}
在无状态设置中,hiddenValue
在每次执行时重置为0
,我们设定的特定条件将不会激活。
然而,与我们的气球类比相似,在有状态模糊测试中,我们重复使用同一个“气球”,变量的先前值得以保留,从而允许我们揭示潜在的更系统性的问题。
与无状态模糊测试不同,该测试不需要我们在函数声明中设置随机输入参数。
相反,通过导入 StdInvariant.sol
我们可以设置一个 targetContract
,Ali Foundry 将自动触发合约的随机函数,使用随机参数并记住其先前状态。
以下是我们如何编写此测试。
// SPDX-License-Identifier: MIT
import {Test} from "forge-std/Test.sol";
import {AlwaysEven} from "../src/AlwaysEven.sol";
// 我们需要从 forge-std 导入不变性合约
import {StdInvariant} from "forge-std/StdInvariant.sol";
pragma solidity ^0.8.12;
contract AlwaysEvenTestStateful is StdInvariant, Test {
AlwaysEven alwaysEven;
function setUp() public {
alwaysEven = new AlwaysEven();
// 我们必须定义将开始执行函数的目标合约,以随机输入
targetContract(address(alwaysEven));
}
function invariant_testsetEvenNumber() public view {
assert(alwaysEven.alwaysEvenNumber() % 2 == 0);
}
}
AlwaysEven 只有一个函数,因此它只会用不同的值调用 setEvenNumber,并断言函数返回的值是否保持为偶数。
正如我们所注意到的,使用 Foundry 运行我们的有状态模糊测试,我们能够给函数传入随机值,直到 8 被用作参数,使 AlwaysEven 的值变为 3,最终打破了我们的不变性规则。同样,这只是一个非常简单的用例,但在现实应用中,找到能够打破不变性的值并不是那么直观,因此有状态模糊测试有时是最快的,甚至是唯一的解决方案。
在不同的测试类型中,术语可能会引起混淆。
例如,Foundry 通常将不变性测试归类为有状态的模糊测试,尽管我们在本文中展示了使用无状态的模糊测试进行的不变性测试。
此外,虽然‘不变性’这个术语可以在有状态测试的上下文中使用,但并不是强制的,甚至有时会引起混淆,因为正如你看到的,我们还创建了一个使用不变性的单元测试。
为了澄清这些区分,以下是由 Nisedo 提供的详细图示,概述了各种测试类型:
最后,这里是针对智能合约审计人员的最佳模糊测试工具的列表。
Echidna,由 Trail of Bits 为以太坊智能合约开发,专注于模糊测试并以其在漏洞探测中的有效性而闻名。
Medusa 是一个基于 Go-Ethereum 的智能合约模糊测试工具,灵感来自 Echidna。它通过 CLI 和 Go API 支持并行化模糊测试,方便用户扩展测试。
Foundry 以综合支持模糊测试和不变性测试而脱颖而出,这也是我们上面所有示例中使用的框架。
采用模糊测试和不变性测试超越了智能合约开发的标准实践——这是一个重要的必要性。
在本文中,你了解了什么是模糊测试(fuzzing)及其工作原理。如果你想要实现使用 Foundry 进行模糊测试,请查阅我们的完整教程。
这些方法大大提升了区块链领域合约的安全性和可靠性。模糊测试借助像 Echidna 这样的工具,使合约面临广泛的输入,揭示隐藏的漏洞。不变性测试通过类似 Foundry 的平台,确保合约在不同条件下保持逻辑一致性。
这就是网络3中安全的新标准。如果你想成为行业内的顶级、高薪审计员,你必须理解并每日应用此类技术,而最优秀的区块链开发者也必须掌握这些技术,以构建更强大、更安全、且更可靠的协议。
- 原文链接: cyfrin.io/blog/smart-con...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!