ほとんどのハックは、開発者が考慮していなかったエッジケースで発生します。ファズテストは、コードが破綻するまでランダムな入力をコントラクトに与え続けます。Foundryならコミット前に実行できるほど高速です。ここでは私が実際に使用しているパターンを紹介します。
はじめに
forge install tiancaijb366-pixel/foundry-security-tests
Enter fullscreen mode Exit fullscreen mode
サンプルリポジトリには、これらの例をすべて実行可能な状態で収録しています。Forkして forge test を実行し、必要に応じて自作コントラクトへパターンを組み込んでください。
1. bound / vm.assume による基本的なファズテスト
10個もの test_RevertIf_* 関数を書く必要はありません。ファザーに範囲を与え、オフバイワンを発見させましょう。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract TokenFuzzTest is Test {
Token t;
function setUp() public {
t = new Token(1_000_000e18);
}
function testFuzz_Transfer_Bounds(address sender, address to, uint256 amount) public {
// `bound` により実用的な入力に絞る
amount = bound(amount, 1, t.balanceOf(sender));
// `vm.assume` でフィルタリング — 必要最小限に(ファザーが遅くなるため)
vm.assume(sender != address(0) && to != address(0) && sender != to);
vm.prank(sender);
t.transfer(to, amount);
assertGe(t.balanceOf(sender), 0);
assertEq(t.totalSupply(), 1_000_000e18);
}
}
Enter fullscreen mode Exit fullscreen mode
ポイント: bound は vm.assume より高速です。範囲を狭めるだけで、入力の破棄を行いません。ファザーが推論できない関係(例: sender != to)のみで assume を使用してください。
2. 不変条件テスト: ERC20 の totalSupply
不変条件テストは、ランダムな呼び出しシーケンスを通じてプロパティが常に成立することを検証します。単一関数のファズテストでは捉えられない状態機械のバグを発見できます。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Token} from "../src/Token.sol";
contract TokenInvariantTest is StdInvariant, Test {
Token t;
function setUp() public {
t = new Token(1_000_000e18);
targetContract(address(t));
}
// 常に成立する必要がある
function invariant_totalSupply() public {
assertEq(t.totalSupply(), 1_000_000e18);
}
}
Enter fullscreen mode Exit fullscreen mode
実行コマンド:
forge test --match-test invariant -vvv
Enter fullscreen mode Exit fullscreen mode
本格的なファズテストには --fuzz-runs 50000 を追加してください。一般的なノートPCで約30秒で完了し、手動では書けないテストケースを発見できます。
ゴースト変数パターン: コントラクト状態が時間とともに変化する不変条件では、ハンドラ呼び出しごとに期待状態を追跡するゴースト変数をテストコントラクトに用意します。
3. ファズによる再入検知
Foundryは各ファズ実行後に状態をロールバックするため、シナリオごとに別個の攻撃コントラクトを用意せずに再入経路を力任せに探索できます。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract ReentrancyFuzzTest is Test {
Vault v;
// 再入試行を記録
bool public attackAttempted;
uint256 public balanceBefore;
receive() external payable {
if (attackAttempted) return; // 1回のみ再入
attackAttempted = true;
// 最初の呼び出し完了前に引き出しを試行
v.withdraw(balanceBefore);
}
function testFuzz_Reentrancy(uint256 depositAmount) public {
depositAmount = bound(depositAmount, 1 ether, 100 ether);
// 被害者資金供給
v.deposit{value: depositAmount}();
balanceBefore = depositAmount;
// このコントラクトから攻撃
attackAttempted = false;
v.withdraw(depositAmount);
// 再入が成功した場合、vault残高は < 0 になる
assertLe(address(v).balance, depositAmount);
}
}
Enter fullscreen mode Exit fullscreen mode
仕組み: Foundryは各ファズ実行を分離します。 expectRevert は不要で、最終状態を検査するだけで十分です。 totalSupply や残高が乖離した場合、ファザーが経路を発見したことになります。
4. アクセス制御のファズテスト
実監査で最もよく見つかる問題は、onlyOwner 修飾子がすべての状態変更パスをカバーしていないケースです。あらゆる呼び出し元から全関数をファズしてください。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract AccessControlFuzzTest is Test {
Vault v;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
function setUp() public {
vm.prank(owner);
v = new Vault();
deal(attacker, 100 ether);
}
// 攻撃者として状態変更関数をすべてファズ
function testFuzz_AccessControl_Withdraw(uint256 amount) public {
vm.assume(amount > 0 && amount <= 100 ether);
vm.prank(attacker);
vm.expectRevert(); // 非オーナーは常にリバート
v.emergencyWithdraw(amount);
}
function testFuzz_AccessControl_Pause(bool paused) public {
vm.prank(attacker);
vm.expectRevert();
v.setPaused(paused);
}
function testFuzz_AccessControl_Mint(uint256 amount) public {
amount = bound(amount, 0, 1_000_000e18);
vm.prank(attacker);
vm.expectRevert();
v.mint(attacker, amount);
}
}
Enter fullscreen mode Exit fullscreen mode
プロTips: 権限付き関数をすべてラップする handler コントラクトを作成し、権限あり/なしの両アドレスから不変条件テストスイート内で呼び出します。アクセスレベルごとに1関数です。
5. オラクル操作のファズテスト
DeFiのエクスプロイトは、ほぼ常に価格オラクルが操作された値を返すことに起因します。オラクル入力をファズし、コア会計処理への影響を検証してください。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {LendingPool} from "../src/LendingPool.sol";
import {MockOracle} from "../src/MockOracle.sol";
contract OracleManipulationFuzzTest is Test {
LendingPool pool;
MockOracle oracle;
address user = makeAddr("user");
function setUp() public {
oracle = new MockOracle(1000e8); // ETH/USD = $1000
pool = new LendingPool(address(oracle));
deal(user, 100 ether);
}
// オラクル価格が大きく変動した場合の挙動
function testFuzz_OracleManipulation(uint256 manipulatedPrice, uint256 collateral) public {
collateral = bound(collateral, 1 ether, 50 ether);
manipulatedPrice = bound(manipulatedPrice, 1e8, 100_000e8); // $1 〜 $100k
// ユーザーが担保を入金
vm.prank(user);
pool.deposit{value: collateral}();
// オラクルが操作される(フラッシュローン、サンドイッチなど)
oracle.setPrice(manipulatedPrice);
// ユーザーは水増し担保で借入、または不当に清算される
vm.prank(user);
pool.borrow();
// 検証: プールが全入金をカバーできるか
assertGe(address(pool).balance, pool.totalDeposits());
}
}
Enter fullscreen mode Exit fullscreen mode
検出対象: borrow() や liquidation の計算が前回値の±5%以内に留まると仮定している場合、ファザーはそれを破る正確な値を見つけます。その後、サーキットブレーカーやTWAPウィンドウを導入してください。
フルスイートの実行
# 標準ファズ(デフォルト: テストごとに256回)
forge test
# ヘビーファズ(監査人が実行する規模)
forge test --fuzz-runs 50000 --ffi
# 不変条件テスト(呼び出しシーケンス)
forge test --match-test invariant --fuzz-runs 50000
Enter fullscreen mode Exit fullscreen mode
CIでは、プッシュごとに軽量スイートを、夜間にヘビースイートを実行してください。
参考になるリソース:
- Foundry Cheatsheet — 忘れがちな forge コマンド、チートコード、フラグを網羅
- Contract Audit Checklist — デプロイ前に確認すべき事項
- Solidity Snippets — よく使うパターンのコピペ集
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.