什么是模糊测试(fuzz tests)

  • cyfrin
  • 发布于 2024-02-02 23:18
  • 阅读 15

本文详尽介绍了智能合约的模糊测试和不变性测试,强调了它们在确保区块链系统安全性方面的重要性。通过使用Foundry框架,文章探讨了如何定义不变性、不同类型的模糊测试以及其相对于传统单元测试的优势,并推荐了几种最佳的模糊测试工具。

什么是模糊测试?在本指南中,你将学习使用 Foundry 进行智能合约的模糊测试和不变性测试所需了解的一切。

本文将教你理解一项关键测试策略,这在确保你的系统可靠性时至关重要:

智能合约的模糊测试(fuzz testing)和不变性测试。

在本指南及其示例中,我们将探索如何使用模糊测试和 Foundry来测试我们的不变性和智能合约函数,同时解决和揭示智能合约行为中的复杂且常常微妙的方面。

想要提升你的智能合约安全技能?参加我们在 Cyfrin Updraft 上提供的20+小时智能合约审计课程,完全免费!

这是一个与 Trail of Bits 安全工程师 Troy 一起的关于模糊测试及其工作原理的解释视频。

视频

首先要理解为什么模糊测试如此重要的,是理解什么是不变性。 让我们来了解一下。

什么是不变性?

智能合约测试包括多种方法,以增强智能合约的链上安全性和效率。我们进行测试以确保智能合约在不同情况下始终按预期行为运行,以避免意外事件和漏洞。

不变性测试涉及定义一组条件——invariants——这些条件无论合约的状态或输入如何,总是必须保持为真。

不变性测试总是需要:

  • 不变性定义:识别必须始终对合约功能有效的关键条件。
  • 状态分析:该过程严格检查合约在不同状态和输入场景下的表现。目的是确保在这些条件下定义的不变性始终合法且不被违反。

注意Foundry通常将不变性测试归类为有状态的模糊测试

例如,在 DeFi 协议中,一个好的不变性可能是:

  • 协议必须始终超额抵押
  • 用户永远无法提取超过其存入的资金
  • 只能有一个公平彩票的赢家

一旦我们找到了不变性,就该测试它们并验证“它们应该始终保持为真”,因此,它们不应改变状态。

测试不变性的一种方法是通过简单的单元测试,但模糊测试解决了一个问题,你将在下一节中看到。

为什么使用 Foundry 进行单元测试不变性不是一个好主意?

让我们考虑一个称为 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 导致“算术下溢或上溢”错误是费时的,甚至不可能或不便。

这就是模糊测试或模糊所带来的便利。让我们了解模糊测试是什么。

什么是模糊测试(fuzz testing)?

模糊测试消除了手动测试变量的每个可能值的需求,例如 testValue

那么,模糊测试是什么呢?

模糊测试使我们可以系统性地将随机数据输入测试,以打破特定的断言(函数)。通过自动化我们在测试中传递给函数的输入的随机化,我们显著简化了该过程,并捕获了我们否则不会发现的场景 **。

也就是说,有两个类型的模糊测试:无状态模糊和有状态模糊。

无状态模糊测试如何工作?

无状态模糊测试类似于在一次随机尝试中对 balloonA 做某些事情以破坏它,然后再吹气成一个新的 balloonB 并以不同方式尝试打破它。

在这种情况下,你永远不会尝试去打破你以前尝试过的气球。这看起来有点傻,因为如果我们的气球不变性是“气球不能被戳破”,我们就希望在同一个气球上多次尝试。

我们可以在下面的 testFuzzRevertOnOverflow 示例中看到无状态模糊测试的一个例子:


function testFuzzRevertOnOverflow(uint8 testValue) public {
        // uint8 testValue; 👈 该值被注释掉
        simpleStorage.setAddition(testValue);
    }

在这个例子中,我们不再声明 testValue 变量。相反,我们将其作为参数传递给该函数,Foundry 将在运行时生成并传递它。

一旦我们运行测试,它将失败,指示用于打破测试的输入:

显示模糊测试(fuzzing)初次尝试即打破不变性的图像

如你所见,使用来打破我们函数的数字旁边还有另一个参数:“运行”——让我们看看它的意思。

运行和反例在上面的示例中,运行计数指示随机生成的输入数量。在这个例子中,我们的模糊测试器需要生成 3 个不同的随机输入,以找到一个破坏我们不变性的值。

我们模糊测试器找到的打破我们不变性的值示例称为Counterexample,即打破我们属性或不变性的输入。

模糊测试如何通过 3 次运行找到反例?

- 它使用某个数字运行单元测试,该数字通过了测试。

- 然后它选择另一个数字。

- 它选择了 128,并且失败了!

因为我们的模糊测试尝试了 3 个不同的数字,所以我们说它进行了 3 次运行。有时,一个模糊测试会尝试数千个潜在的反例,但其中没有一个奏效,因为没有缺陷!当发生这种情况时,开发人员可能会陷入漫长的等待。

我们通常给我们的工具一个最大运行次数以减轻这种情况。在 Foundry 中,我们可以在 foundry.toml 中找到最大运行次数。

显示如何使用 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: 专注于模糊测试

Echidna,由 Trail of Bits 为以太坊智能合约开发,专注于模糊测试并以其在漏洞探测中的有效性而闻名。

  • 功能: 生成多样化的输入以审查合约功能,揭示隐藏的缺陷。
  • 用户友好性: 旨在易于使用,适合不同专业水平的开发者。
  • CI 集成能力: 便于集成到持续集成工作流中,以实现持续测试。

Medusa: 新兴的智能合约模糊测试工具

Medusa 是一个基于 Go-Ethereum 的智能合约模糊测试工具,灵感来自 Echidna。它通过 CLI 和 Go API 支持并行化模糊测试,方便用户扩展测试。

  • 功能: Medusa 支持多个工作进程的并行模糊测试,内置断言和属性测试,变异值生成和覆盖收集。
  • 覆盖技术: 实现覆盖引导模糊测试,通过一组不断增加的调用序列来指导模糊测试活动。
  • 可扩展性: 提供可扩展的低级测试 API,具有事件和Hook,尽管高级测试 API 仍在开发中。

Foundry: 多功能测试套件

Foundry 以综合支持模糊测试和不变性测试而脱颖而出,这也是我们上面所有示例中使用的框架。

  • 模糊测试工具: 简化编写和执行模糊测试,将其与单元测试无缝结合。
  • 不变性测试特性: 配备在测试期间定义和验证不变性的功能。
  • 社区和文档支持: 丰富的资源和活跃的用户基础使其成为开发者的首选。

结论

采用模糊测试和不变性测试超越了智能合约开发的标准实践——这是一个重要的必要性。

在本文中,你了解了什么是模糊测试(fuzzing)及其工作原理。如果你想要实现使用 Foundry 进行模糊测试,请查阅我们的完整教程。

这些方法大大提升了区块链领域合约的安全性和可靠性。模糊测试借助像 Echidna 这样的工具,使合约面临广泛的输入,揭示隐藏的漏洞。不变性测试通过类似 Foundry 的平台,确保合约在不同条件下保持逻辑一致性。

这就是网络3中安全的新标准。如果你想成为行业内的顶级、高薪审计员,你必须理解并每日应用此类技术,而最优秀的区块链开发者也必须掌握这些技术,以构建更强大、更安全、且更可靠的协议。

  • 原文链接: cyfrin.io/blog/smart-con...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.