在真實世界的複雜 DeFi 專案中,合約狀態往往非常龐大。如果完全依賴 Mythril 的數學推演,很容易遇到「路徑爆炸 (Path Explosion)」的問題,導致掃描跑了三天三夜還算不出結果。而我們自己手寫的單元測試(Unit Test),又往往只會測到我們「大腦能想到的正常數值」。
如果駭客輸入了一個我們從沒想過的極端數字(例如最大值減一,或是剛好等於某個常數),合約會不會崩潰?為了解決這個盲區,目前 Web3 業界公認 CP 值最高、能有效抓出邊界漏洞的測試手法就是:模糊測試 (Fuzzing)。
什麼是模糊測試 (Fuzzing)?
模糊測試的概念非常暴力且直白:向你的程式碼狂丟幾千、幾萬個隨機生成的數值,看看會不會把程式搞壞。
在傳統的單元測試中,你可能會這樣寫:「測試存入 100 顆代幣,檢查餘額是否增加 100。」但駭客不會乖乖存 100 顆。在模糊測試中,我們改為這樣宣告:「測試存入 X 顆代幣,檢查餘額是否增加 X。」然後,測試框架會在幾秒鐘內,自動生成數以萬計的 X 值(包含 0、1、極大整數 2^{256}-1 等各種邊界值)來瘋狂轟炸你的合約。
Foundry:為 Fuzzing 而生的怪物
在之前我們選擇安裝 Foundry 作為開發環境,除了因為它速度極快,更是因為它內建了地表最強的 Solidity 模糊測試引擎。
在 Foundry 中寫模糊測試,不需要學習新的工具,只要在測試函數中加入「輸入參數」,Foundry 就會自動辨識並啟動 Fuzzing 引擎。
實戰:用 Fuzzing 抓出隱藏的數學 Bug
讓我們來看一個實際的例子。假設我們寫了一個看似正常的代幣兌換函數
'''
// src/MathVault.sol
pragma solidity ^0.8.20;
contract MathVault {
// 一個有隱藏 Bug 的提款計算函數
function withdraw(uint256 amount) public pure returns (uint256) {
// 開發者可能不小心留下了一個沒有處理的邊界條件
require(amount != 42069, "Unknown calculation error");
return amount * 2;
}
}
'''
如果我們只寫普通的單元測試(測 100, 200, 1000 等常見數字),這個 Bug 永遠不會被發現。現在,我們用 Foundry 來寫 Fuzz 測試:
'''
// test/MathVault.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MathVault.sol";
contract MathVaultTest is Test {
MathVault vault;
function setUp() public {
vault = new MathVault();
}
// 注意這裡!我們把 uint256 amount 當作參數傳入,這就啟動了 Fuzzing!
function testFuzz_Withdraw(uint256 amount) public {
// 我們預期所有數字乘以 2 後,都不應該發生 Revert
vault.withdraw(amount);
}
}
'''
執行與過濾雜訊 (vm.assume 與 bound)
打開終端機,執行測試:
'''
forge test
'''
不到一秒鐘,終端機會立刻噴出紅字錯誤!Foundry 會精準地告訴你:當 amount = 42069 的時候,測試失敗了。它自動幫你找到了那個會讓合約崩潰的極端值!
在實務上,合約往往有一些我們預期內的限制條件(例如提款金額不能為 0,或不能超過總供應量)。如果 Fuzzing 引擎丟了這些合理的錯誤數字進來,導致交易 Revert,這並不算 Bug。這時我們就需要用 vm.assume() 或 bound() 來限制亂數產生的範圍:
'''
function testFuzz_SecureWithdraw(uint256 amount) public {
// 告訴 Fuzzing 引擎:我只關心 1 到 10000 之間的數字
amount = bound(amount, 1, 10000);
// 或者使用 assume(如果遇到 0 就跳過這次亂數)
// vm.assume(amount > 0);
vault.withdraw(amount);
}
'''
透過這些過濾條件,我們可以確保 Fuzzing 引擎把火力集中在「理論上應該要成功,卻意外失敗」的致命盲區。
檢測工具大集結到今天為止,我們已經湊齊了資安稽核的三大神器: