Day12:セキュリティ入門(再入・権限・静的解析・ファジング)
| ← 目次 | 前: Day11 | 次: Day13 |
学習目的
- 代表的脆弱性(Reentrancy, tx.origin誤用, delegatecall乱用, Proxyのストレージ衝突)を理解し、簡単に説明できるようになる。
- 対策(CEI, ReentrancyGuard, AccessControl/Ownable, Pull-Payment, Pausable)を実装し、テストで検証できるようになる。
- Slither(静的解析)とFoundry/Echidna(プロパティテスト)を実行し、自動検出を体験できるようになる。
まず
docs/curriculum/index.mdの「共通の前提」を確認してから進める。
0. 前提
- Hardhat環境(Day3)。
- 任意でFoundry(
foundryup済)。 - 先に読む付録:
docs/appendix/glossary.md(用語に迷ったとき) - 触るファイル(主なもの):
contracts/VulnBank.sol/contracts/SafeBank.sol/contracts/Attacker.sol/test/reentrancy.ts/contracts/AdminBox.sol - 今回触らないこと:すべての脆弱性の網羅(まずは頻出の再入と権限の基本から)
- 最短手順(迷ったらここ):1章のコントラクト/テストを動かして“攻撃できる/防げる”を体験 →
npx hardhat test test/reentrancy.tsで確認
1. 脆弱性:Reentrancy(再入)
1.1 脆弱コントラクト
contracts/VulnBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnBank {
mapping(address=>uint256) public bal;
function dep() external payable { bal[msg.sender]+=msg.value; }
function wd(uint256 amt) external {
require(bal[msg.sender] >= amt, "bal");
// 脆弱:送金→その後に残高更新(CEI違反)
(bool ok,) = msg.sender.call{value: amt}("");
require(ok, "send");
bal[msg.sender]-=amt;
}
}
1.2 攻撃コントラクト
contracts/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVulnBank { function dep() external payable; function wd(uint256) external; }
contract Attacker {
IVulnBank public bank; address public owner;
constructor(address b){ bank = IVulnBank(b); owner = msg.sender; }
receive() external payable { if (address(bank).balance >= 1 ether) { bank.wd(1 ether); } }
function attack() external payable { require(msg.value>=1 ether, "fund"); bank.dep{value:1 ether}(); bank.wd(1 ether); }
function sweep() external { payable(owner).transfer(address(this).balance); }
}
1.3 対策版(CEI + ReentrancyGuard)
contracts/SafeBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address=>uint256) public bal;
function dep() external payable { bal[msg.sender]+=msg.value; }
function wd(uint256 amt) external nonReentrant {
uint256 b = bal[msg.sender];
require(b >= amt, "bal");
bal[msg.sender] = b - amt; // Effects(先)
(bool ok,) = msg.sender.call{value: amt}(""); // Interactions(後)
require(ok, "send");
}
}
1.4 テスト(攻撃成功/失敗)
test/reentrancy.ts
import { expect } from "chai"; import { ethers } from "hardhat";
describe("Reentrancy", ()=>{
it("VulnBank gets drained", async()=>{
const [deployer] = await ethers.getSigners();
const V = await (await ethers.getContractFactory("VulnBank")).deploy(); await V.waitForDeployment();
const vAddr = await V.getAddress();
await deployer.sendTransaction({to: vAddr, value: ethers.parseEther("10")});
const A = await (await ethers.getContractFactory("Attacker")).deploy(vAddr); await A.waitForDeployment();
await A.attack({value: ethers.parseEther("1")});
expect(await ethers.provider.getBalance(vAddr)).to.be.lt(ethers.parseEther("10"));
});
it("SafeBank resists", async()=>{
const [deployer] = await ethers.getSigners();
const S = await (await ethers.getContractFactory("SafeBank")).deploy(); await S.waitForDeployment();
const sAddr = await S.getAddress();
await deployer.sendTransaction({to: sAddr, value: ethers.parseEther("10")});
const A = await (await ethers.getContractFactory("Attacker")).deploy(sAddr); await A.waitForDeployment();
await expect(A.attack({value: ethers.parseEther("1")})).to.be.reverted; // or no drain
});
});
実行:
npx hardhat test test/reentrancy.ts
2. 権限:tx.origin、AccessControl、Pausable
2.1 tx.originの誤用
tx.origin を認可に使うと中継コントラクト経由で権限漏れ。
誤り例:
if (tx.origin != owner) revert(); // NG
正:msg.senderで判断し、必要に応じてOwnable/AccessControlを使用。
2.2 Ownable + Pausable
contracts/AdminBox.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
contract AdminBox is Ownable, Pausable {
string private data; constructor() Ownable(msg.sender) {}
function set(string calldata d) external onlyOwner whenNotPaused { data=d; }
function get() external view returns(string memory){ return data; }
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
}
2.3 AccessControl(ロール)
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Roles is AccessControl {
bytes32 public constant OPERATOR = keccak256("OPERATOR");
constructor(){ _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); }
function act() external onlyRole(OPERATOR) {}
}
3. delegatecall と Proxyの落とし穴
3.1 delegatecallの危険
呼び出し先のストレージレイアウトを共有。誤ると上書きや資産流出につながる。
補足:
SELFDESTRUCTは近年のアップグレードで挙動が大きく変わっている(設計・監査時に都度確認)。
3.2 ストレージ衝突(Proxy)
UUPS/Transparent Proxyでは永続化変数の並びが重要。新実装で順序変更・削除は禁止。storage gap を確保する。
雛形(UUPS要点、実運用はOpenZeppelin Upgradesを利用):
uint256[50] private __gap; // 予約領域
4. 支払い設計:Pull-Payment
4.1 原則
- Push型送金(その場で
call)は再入/失敗リスク。 - Pull型(受取人が引出)に分離すると安全性向上。
雛形:
mapping(address=>uint256) public credit;
function settle(address to, uint256 amt) internal { credit[to]+=amt; }
function withdraw() external { uint v=credit[msg.sender]; credit[msg.sender]=0; (bool ok,) = msg.sender.call{value:v}(""); require(ok); }
5. ツール:Slither(静的解析)
5.1 インストール
pipx install slither-analyzer
5.2 実行
slither . --filter-paths "node_modules"
レポート要点:
Reentrancy警告、tx.origin検出、delegatecall使用箇所、未初期化ストレージ参照など。
6. ツール:Foundry/Echidna(プロパティテスト)
6.1 Invariant(永続条件)例(Foundry)
test/Invariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {SafeBank} from "../contracts/SafeBank.sol";
contract Invariant is Test {
SafeBank b; address user;
function setUp() public { b = new SafeBank(); user = address(0xBEEF); }
function invariant_NoOverdraw() public {
// バンクの総残高 >= ユーザ残高合計(厳密版は配列管理)
assert(address(b).balance >= 0);
}
}
実行:
forge test --match-contract Invariant -vvvv
6.2 Echidna(任意)
- 目的:ランダム化された呼び出し列で特性違反を探索。
- セットアップは公式ドキュメント参照。時間があれば
VulnBankに対して資産流出を検出させる設定を追加。
7. 監査チェックリスト(抜粋)
- 重要関数は
onlyOwner/AccessControlで保護。 pause可能か。緊急停止Runbookがあるか。- 送金はPull型。Pushは最小化。
- CEI順序(Checks→Effects→Interactions)。
- 外部呼び出しの戻り値を検査。
tx.origin不使用。- Proxyのストレージ互換性を文書化。
- イベントは必要最小限を
indexedで設計。
8. つまずきポイント
| 症状 | 原因 | 対処 |
|—|—|—|
| slither が実行できない | 未インストール / PATH問題 | 章中の pipx install slither-analyzer を再確認する |
| forge が見つからない | Foundry未導入 | Day3 の Foundry 手順(foundryup)を先に行う |
| 攻撃テストが再現しない | 前提(残高、呼び出し順)が崩れている | テスト内の初期入金額と attack() の送金額を再確認する |
9. まとめ
- 代表的な脆弱性を「攻撃→原因→対策(パターン/ライブラリ)」の形で整理した。
- 静的解析やプロパティテストの入口として、まずは“自動で検査する”習慣を作るのが重要だと分かった。
- チェックリストを使い、レビュー時に見るべき観点を言語化できる状態にした。
理解チェック(3問)
- Q1. 再入(reentrancy)が起きる条件を、状態更新の順序まで含めて説明してみる。
- Q2. 認可に
tx.originを使うと危険になりやすい理由は何か? - Q3. Pull-Payment(引き出し型)の設計は、どんなリスクを下げるか?
解答例(短く)
- A1. 外部呼び出し(送金など)中に相手が再び呼び戻してきて、状態更新前の前提が崩れると起きる。Checks-Effects-Interactionsやガードで対策する。
- A2. 呼び出し経路に別コントラクトが挟まると、意図しない主体が
tx.originを悪用して通ってしまう場合があるため。 - A3. 送金をその場で押し付けず、受け手が後で引き出す形にすることで、外部呼び出し由来の失敗や再入リスクを減らしやすい。
確認コマンド(最小)
npx hardhat test test/reentrancy.ts
# 任意(Slither が入っている場合)
slither .
10. 提出物
VulnBank攻撃ログ、SafeBank防御ログ- Slitherレポート(主要警告の抜粋)と対応方針
- Invariantテストの結果スクリーンショット
- 監査チェックリストの自己評価(5項目以上)
11. 実行例
- 実行ログ例:
docs/reports/Day12.md