本文へスキップ
BecomeCoder

Solidityコース · 第5章 発展 ― 継承・トークン・セキュリティ · レッスン23

セキュリティ ― リエントランシー攻撃

ローカル実施

導入

スマートコントラクト改ざんできない=バグも直せないうえ、本物のお金を扱います。だからセキュリティは最重要。歴史的な大事件を生んだ リエントランシー(再入)攻撃 を、図で理解します。

説明

危ないのは、第4章でも触れた「送金してから残高を減らす」順序です。

// ❌ 危険な例:送金が先、残高更新が後
function withdrawBad(uint256 amount) public {
    require(balances[msg.sender] >= amount, "not enough");
    (bool ok, ) = payable(msg.sender).call{value: amount}("");  // 先に送金
    require(ok);
    balances[msg.sender] -= amount;                              // 後で減らす(手遅れ)
}

送金先が悪意あるコントラクトだと、送金を受け取った瞬間にもう一度 withdrawBad を呼び返します。残高はまだ減っていないので、審査(require)を通ってしまい、何度も引き出されます。

sequenceDiagram
    participant A as 攻撃コントラクト
    participant V as 金庫(脆弱)
    A->>V: withdrawBad(1 ETH)
    V-->>A: 1 ETH 送金(この時点で残高未更新)
    Note over A: 送金を受けた瞬間に…
    A->>V: もう一度 withdrawBad(1 ETH)
    V-->>A: また 1 ETH 送金(審査を再度通過)
    A->>V: さらに withdrawBad(1 ETH)
    V-->>A: 金庫が空になるまで繰り返し

防ぐには、第4章の Checks-Effects-Interactions を守ります。

// ✅ 安全な例:先に残高を減らし、最後に送金
function withdrawSafe(uint256 amount) public {
    require(balances[msg.sender] >= amount, "not enough");  // Checks
    balances[msg.sender] -= amount;                          // Effects(先に締める)
    (bool ok, ) = payable(msg.sender).call{value: amount}(""); // Interactions(最後に送金)
    require(ok);
}

先に残高を 0 にしておけば、再入されても審査(require)で弾かれます。加えて、処理中の再入自体を禁じる nonReentrant 修飾子(OpenZeppelin の ReentrancyGuard)を併用するのが定石です。2016年の「The DAO 事件」ではこの脆弱性で巨額の ETH が流出し、Ethereum が分岐する大事件になりました。

やってみよう

他にも「整数の扱い(0.8 以降は自動チェックあり)」「tx.origin認証に使わない」「乱数をブロック情報で作らない」など、定番の落とし穴があります。本番のコントラクトは必ず**専門家の監査(audit)**を受ける、という文化があることも知っておきましょう。