導入
スマートコントラクトは改ざんできない=バグも直せないうえ、本物のお金を扱います。だからセキュリティは最重要。歴史的な大事件を生んだ リエントランシー(再入)攻撃 を、図で理解します。
説明
危ないのは、第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)**を受ける、という文化があることも知っておきましょう。