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. 実行例