本文介绍了模糊测试在Web3智能合约安全中的作用,强调了其通过输入无效或随机数据来发现编码错误和漏洞的重要性。文章对比了Web2和Web3模糊测试的区别,并介绍了如何在Foundry中使用模糊测试和不变性测试来保障智能合约的安全性和正确性。
学习模糊测试如何加强 Web3 中智能合约的安全性。探索关键技术、真实案例以及如何开始使用 Foundry。
在本文中,我们将深入探讨模糊测试在区块链安全中的重要作用。在 Foundry 中设置测试之前,让我们先了解这项强大技术背后的核心概念。
模糊测试(Fuzzing),或称模糊测试,是一种将无效、意外或随机数据输入系统,以发现编码错误和漏洞的方法。当应用于 Web 应用程序时,它会针对数据注入点来寻找弱点。在 Web3 的世界中,它成为加强智能合约以抵御极端情况利用的重要工具。
以下是一个简单的方法来理解模糊测试的概念:
想象一下,一个程序要求你输入年龄,并期望得到一个数字。你没有输入“30”,而是输入了奇怪的数据,如“banana”、“9999999999”或将其留空。如果程序崩溃或行为异常,那就是存在 bug 的迹象。
在 Web3 中,可以将模糊测试想象成一个叛逆的机器人,测试一个智能合约,就像一台数字自动售货机,只有在收到有效的代币时才会分配加密货币。如果机器人可以通过错误的输入欺骗自动售货机来分配加密货币,那么合约就存在缺陷。
模糊测试帮助区块链开发者尽早发现这些问题,避免它们变成可利用的 bug。
模糊测试起源于 Web2 时代,主要用于中心化系统,例如托管在公司服务器上的应用程序。AFL、LibFuzzer 和 go-fuzz 等工具会向 Web 应用程序抛出格式错误的数据以破坏它们。目标是什么?使程序崩溃并找到故障点。
相比之下,Web3 模糊测试是去中心化和基于属性的。它不仅仅是尝试使程序崩溃,而是验证智能合约在各种输入下是否表现正确。这一点至关重要,因为区块链交易是不可变的。
在 Web3 模糊测试中,我们定义了属性(或不变量),这些属性描述了预期的合约行为。例如:
mint 或 burn 时才会更改。测试人员负责识别这些不变量并将其嵌入到模糊测试中。
智能合约处理大量的资金和复杂的逻辑。模糊测试有助于:
假设你正在构建一个 DeFi 智能合约,该合约接受加密货币存款并随时间计算利息。
该智能合约负责处理用户存款、计算利息并启用提款。
以下是如何开始使用 Foundry 进行模糊测试,Foundry 是一个用于以太坊开发的快速工具包。
这个快速的 4 步过程将使我们启动并运行:
要在终端上运行的第一个命令是:
curl -L https://foundry.paradigm.xyz | bash
然后,如果你注意到,终端也会指出要运行:
foundryup
现在,你必须安装 cargo 才能使用 Rust 编译器:
curl https://sh.rustup.rs -sSf | sh
最后一步是安装 Forge + Cast、Anvil 和 Chisel:
cargo install --git https://github.com/foundry-rs/foundry --profile local --force foundry-cli anvil chisel
让我们用 Foundry 创建一个新项目并进行尝试。
运行:
forge init safe-example
现在,进入新创建的项目 cd safe-example 并编译它:
forge build
为了运行存储库中的所有测试用例,请运行:
forge test
但是,假设你正在处理特定合约的测试用例(在本例中,让我们假设你正在处理 Safe.t.sol 测试文件),并且你只想执行这些测试用例。那么你可以在这里做什么:
forge test --match-path test/Safe.t.sol
Foundry 在执行测试时提供的另一个很棒的功能是,根据需求在结果中引入更多日志。
这可以通过添加带有两个到五个 -v 的标志来控制,例如:
forge test --match-path test/Safe.t.sol -vvv
你添加的 v 越多,你将在测试结果中获得的详细程度就越高。
1详细级别:
2- 2:打印所有测试的日志
3- 3:打印失败测试的执行跟踪
4- 4:打印所有测试的执行跟踪,以及失败测试的设置跟踪
5- 5:打印所有测试的执行和设置跟踪
我们在使用 Foundry 执行测试时还可以获得一个额外的功能……
它是一个添加标志以从合约函数获取 gas 报告的选项:
forge test --match-path test/Counter.t.sol --gas-report
让我们使用 Foundry 文档中的这个示例来了解这一点。
这是一个简单的合约:
1contract Safe {
2 receive() external payable {}
3
4 function withdraw() external {
5 payable(msg.sender).transfer(address(this).balance);
6 }
7}
然后,我们想要编写一个单元测试来确保 withdraw 函数有效:
1import "forge-std/Test.sol";
2
3contract SafeTest is Test {
4 Safe safe;
5
6 // Needed so the test contract itself can receive ether
7 // when withdrawing
8 receive() external payable {}
9
10 function setUp() public {
11 safe = new Safe();
12 }
13
14 function test_Withdraw() public {
15 payable(address(safe)).transfer(1 ether);
16 uint256 preBalance = address(this).balance;
17 safe.withdraw();
18 uint256 postBalance = address(this).balance;
19 assertEq(preBalance + 1 ether, postBalance);
20 }
21}
此测试只是检查提款前的余额 + 转移的金额是否与提款后的余额相同。
现在,谁说它适用于所有金额,而不仅仅是 1 以太币?
Forge 将运行至少带有一个参数的任何测试作为基于属性的测试,因此让我们重写:
1contract SafeTest is Test {
2 // ...
3
4 function testFuzz_Withdraw(uint256 amount) public {
5 payable(address(safe)).transfer(amount);
6 uint256 preBalance = address(this).balance;
7 safe.withdraw();
8 uint256 postBalance = address(this).balance;
9 assertEq(preBalance + amount, postBalance);
10 }
11}
通过运行此代码,我们可以看到 Forge 正在运行基于属性的测试,但对于 amount 的高值,它会失败:
1$ forge test
2Compiling 1 files with 0.8.10
3Solc 0.8.10 finished in 1.69s
4Compiler run successful
5
6Running 1 test for test/Safe.t.sol:SafeTest
7[FAIL. Reason: EvmError: Revert Counterexample: calldata=0x215a2f200000000000000000000000000000000000000001000000000000000000000000, args=[79228162514264337593543950336]] testWithdraw(uint256) (runs: 47, μ: 19554, ~: 19554)
8Test result: FAILED. 0 passed; 1 failed; finished in 8.75ms
这里我们有第一个模糊测试的例子,不是测试一个场景,而是通过添加我们需要测试的值作为参数,它将使用大量的半随机值。
这可能会导致发现合约的漏洞。
Forge 也会在测试失败时打印一些有用的信息。
testWithdraw(uint256) (runs: 47, μ: 19554, ~: 19554)
如果这种 foundry 的使用已经让人印象深刻,请继续阅读。Foundry 还有更大的潜力有待探索。
Foundry 中的不变量测试是一种有状态的模糊测试,这也是你将听到/读到人们提及相同类型的测试的另一种方式。
这里的关键是,与上面提到的相同,Foundry 中的测试必须以 test_ 和 testFail_ 为前缀开头,为了编写这种类型的测试,你必须使用前缀 invariant。
现在是看看有状态模糊测试实际应用的时候了
让我们通过稍微修改我们之前使用过的合约来了解这一点:
1contract Safe {
2 address public seller = msg.sender;
3 mapping(address => uint256) public balance;
4
5 function deposit() external payable {
6 balance[msg.sender] += msg.value;
7 }
8
9 function withdraw() external {
10 uint256 amount = balance[msg.sender];
11 balance[msg.sender] = 0;
12 (bool s, ) = msg.sender.call{value: amount}("");
13 require(s, "failed to send");
14 }
15
16 function sendSomeEther(address to, uint amount) public {
17 (bool s, ) = to.call{value: amount}("");
18 require(s, "failed to send");
19 }
20}
在这里,我们修改了 withdraw 函数并添加了 deposit 以太币的机会。
我们想要在此处测试的重点是,提取的金额必须始终与存入的金额相同。
1contract InvariantSafeTest is Test {
2 Safe safe;
3
4 function setUp() external {
5 safe = new Safe();
6 vm.deal(address(safe), 100 ether); // Sets an address' balance, (who, newBalance)
7 }
8
9 function invariant_withdrawDepositedBalance() external payable {
10 safe.deposit{value: 1 ether}();
11 uint256 balanceBefore = safe.balance(address(this));
12 assertEq(balanceBefore, 1 ether);
13 safe.withdraw();
14 uint256 balanceAfter = safe.balance(address(this));
15 assertGt(balanceBefore, balanceAfter);
16 }
17
18 receive() external payable {}
19}
这是你第一次运行此代码时将获得的第一个输出(你会喜欢它的):
1% forge test
2[⠢] Compiling...
3[⠒] Compiling 2 files with 0.8.19
4[⠆] Solc 0.8.19 finished in 819.69ms
5Compiler run successful!
6
7Running 1 test for test/Counter.t.sol:InvariantSafeTest
8[PASS] invariant_withdrawDepositedBalance() (runs: 256, calls: 3840, reverts: 883)
9Test result: ok. 1 passed; 0 failed; finished in 199.57ms
是的,它通过了。
但是请记住,始终阅读输出。因为在这种情况下,你可以看到通过运行 invariant_withdrawDepositedBalance() 测试,它会得到以下结果:
(runs: 256, calls: 3840, reverts: 883)
而且,这是正确的,它实际上总共还原了 883 次。
为什么它没有失败?
不用担心,这是一个预期的结果。为了还原,我们必须将以下内容添加到 foundry.toml 文件中。
1[invariant]
2fail_on_revert = true
如果你现在再次运行它,结果会有所不同:
1Running 1 test for test/InvariantSafeTest.t.sol:InvariantSafeTest
2[FAIL. Reason: failed to send]
3 [Sequence]
4 sender=0x0000000000000000000000000000000000000003 addr=[src/Counter.sol:Counter]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f
5 calldata=withdraw(), args=[]
6 sender=0x000000000000000000000000000000000000005b addr=[src/Counter.sol:Counter]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f
7 calldata=sendSomeEther(address,uint256),
8 args=[0xa18255fD90ed742e6439A9b834A179E386DC0c1e, 115792089237316195423570985008687907853269984665640564039457584007913129639935]
9
10 invariant_withdrawDepositedBalance() (runs: 1, calls: 2, reverts: 1)
11Test result: FAILED. 0 passed; 1 failed; finished in 9.57ms
这里发生了一件非常有趣的事情。请注意以下部分
calldata=sendSomeEther(address,uint256)
这就是输出告诉你触发的最后一个函数破坏了此合约的不变量的方式。
通过向合约添加一个可用于将以太币发送到不同帐户的函数,始终提取相同数量的已存入以太币的不变量被破坏。
如果我们从合约中注释掉函数 sendSomeEther,那么结果将如下所示:
1forge test
2[⠆] Compiling...
3[⠊] Compiling 2 files with 0.8.19
4[⠢] Solc 0.8.19 finished in 724.85ms
5Compiler run successful!
6
7Running 1 test for test/InvariantSafeTest.t.sol:InvariantSafeTest
8[PASS] invariant_withdrawDepositedBalance() (runs: 256, calls: 3840, reverts: 0)
9Test result: ok. 1 passed; 0 failed; finished in 218.12ms
模糊测试为区块链安全提供了一种主动的、严谨的方法。它通过改进智能合约的安全性以及生态系统的信任,帮助开发人员在攻击者之前发现漏洞。
随着 Web3 采用的增长,采用智能、自动化测试策略(如模糊测试)的重要性也随之增加。无论你是构建 dApp、部署 DeFi 协议还是审计第三方合约,都应将模糊测试纳入你的流程。
在 Zealynx,我们专注于智能合约审计、模糊测试和渗透测试,以帮助你在漏洞成为威胁之前发现它们。如果你正在 Web3 中构建并希望安心地关注安全性,请聊天。
- 原文链接: zealynx.io/blogs/How-Fuz...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!