なぜ「再入攻撃(Reentrancy)」は死なないのか?――その本質と鉄壁の防御術
現場でコードを叩いていると、セキュリティのベストプラクティスを「教科書的なお作法」として捉えている奴が多い。だが、スマートコントラクト、特にイーサリアム系のEVM環境において、再入攻撃(Reentrancy)を甘く見ることは、銀行の金庫を開けっ放しにして「誰も入ってこないだろう」と祈るに等しい行為だ。
今日は、なぜ Checks-Effects-Interactions パターンが重要なのか、そしてなぜ ReentrancyGuard だけでは不十分なのか、その「泥臭い現実」を解説しよう。
—
1. そもそも「再入攻撃」で何が起きるのか
再入攻撃のロジックは極めてシンプルだ。攻撃者は、コントラクトの「残高を更新する前」に、コントラクトが外部(攻撃者のコントラクト)へ送金を行う隙を突く。
以下のコードを見てくれ。これが、世の中で最も典型的な「死ぬコード」のパターンだ。
// 脆弱な実装例
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
// 1. 外部呼び出し(Interaction)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
// 2. 状態更新(Effects)← ここで攻撃者が再帰的に呼び出す!
balances[msg.sender] -= _amount;
}
攻撃者が fallback() 関数を持つ悪意あるコントラクトからこれを叩くと、送金処理の最中に制御権が奪われ、balances が減算される前に何度も withdraw が実行される。結果、残高が無限に引き出されるというわけだ。
—
2. 鉄則:Checks-Effects-Interactions パターン
この攻撃を未然に防ぐための第一の防波堤が、Checks-Effects-Interactions パターンだ。シンプルに言えば、「外の世界と関わる前に、自分の中の計算を終わらせろ」ということだ。
修正後の実装はこうなる。
// セキュアな実装例
function withdraw(uint256 _amount) public {
// 1. Checks: まず条件を確認
require(balances[msg.sender] >= _amount, "残高不足");
// 2. Effects: 先に状態を更新する
balances[msg.sender] -= _amount;
// 3. Interactions: 最後に外部送金を行う
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "送金失敗");
}
これだけで多くの再入攻撃は防げる。だが、複雑なトークン標準(ERC777など)や、複数のコントラクトが絡む処理では、これだけでは足りないこともある。そこで登場するのが ReentrancyGuard だ。
—
3. ReentrancyGuard による「二重の鍵」
OpenZeppelinの ReentrancyGuard は、関数実行中に別の呼び出しが入ることを物理的に防ぐ「排他制御」を行う。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
// nonReentrant 修飾子を付けることで、関数実行中の再入を拒否する
function withdraw(uint256 _amount) external nonReentrant {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
}
}
この nonReentrant 修飾子を付けると、関数実行中に内部でまた自分自身が呼ばれた際、コントラクト内の _status フラグがそれを検知して処理を強制終了させる。まさに「物理的な鍵」だ。
—
4. Webアプリエンジニアへの教訓
スマートコントラクトの話に限定せず、Webアプリ開発者にも伝えたいことがある。
君たちが書くAPIでも同じだ。例えば、決済処理を行う際に「DBの残高を更新する前」に「外部の決済ゲートウェイAPIを叩く」ような実装をしていないか?もしそのAPIが何らかの理由でタイムアウトしたり、二重送信されたりしたらどうなる?
「外部呼び出しは常に信頼できない(Untrusted)」という原則を忘れてはいけない。
インフラ層での防御(WAFの考え方)
Webサービスであれば、AWS WAFなどで「異常なリクエストレート」を検知するのはもちろん、アプリケーション側で「冪等性(Idempotency)」を担保することが重要だ。リクエストIDをDBのユニークキーとして持ち、同じ処理が2回走らないように制御する。これはスマートコントラクトの nonReentrant と同等の思想だ。
—
まとめ:防御の哲学
1. 信頼の最小化: 外部呼び出しは常に攻撃の起点となり得ると考えろ。
2. 状態更新を先に: 内部の状態(DBやストレージ)は、外部との通信が始まる前に確定させる。
3. 多層防御: Checks-Effects-Interactions を基本としつつ、重要な操作には ReentrancyGuard のような排他制御を重ねる。
セキュリティとは、完璧なコードを書くことではない。「どこが破られたらヤバいか」を理解し、その一点を徹底的に守り抜く執念だ。現場のコードで再入攻撃の匂いがしたら、即座に修正を入れること。それがプロのリサーチャーとしての最低限の流儀だ。
コメント