銀行のATMで「お金を引き出す」前に「残高を減らす」のを忘れたら?再入攻撃(Reentrancy Attack)の真実
こんにちは!セキュリティの世界へようこそ。今日は、ブロックチェーン界隈で最も有名で、かつ最も恐ろしい「再入攻撃(Reentrancy Attack)」という現象について、専門用語を抜きにして、身近な例えから紐解いていきたいと思います。
Web3の開発現場では「コントラクトがハックされた!」というニュースをよく目にしますが、その多くはこの「再入攻撃」が原因です。実はこれ、皆さんの家の鍵の管理や、ATMの仕組みを少し想像するだけで、そのメカニズムが手に取るようにわかるんです。
1. なぜ「再入攻撃」は起きるのか?(泥棒のトリック)
まず、ATMで現金を引き出す場面を想像してみてください。通常、銀行のシステムはこんな順番で動いていますよね。
1. チェック(Checks): 残高は十分にあるか?
2. アクション(Effects): 残高から引き出す額を差し引く。
3. インタラクション(Interactions): 現金を排出する。
この順番が完璧なら、泥棒は入り込めません。しかし、もし銀行のプログラムが「先に現金を渡し(インタラクション)、その後に残高を引く(エフェクト)」という順番だったらどうなるでしょう?
泥棒(攻撃者)は、お金が出てきた瞬間に、まだ残高が減っていないことをいいことに、「もう一度お金をください!」と即座にATMのボタンを連打(再入)してしまうのです。銀行が「あ、まだ残高はあるな」と勘違いしている間に、何度も現金を盗み出せてしまいます。これが「再入攻撃」の正体です。
2. スマートコントラクトでの「再入」をコードで見てみる
実際に脆弱なコードを見てみましょう。悪い例(Vulnerable)と良い例(Safe)を比較します。
悪い例:防御が甘いコントラクト
// 警告:これは脆弱なコードの例です!
function withdraw(uint256 _amount) public {
// 1. 残高チェック
require(balances[msg.sender] >= _amount);
// 2. 外部への送金(ここで攻撃者は悪意あるコードを割り込ませる)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
// 3. 残高の更新(これより前に送金してしまうのが致命的!)
balances[msg.sender] -= _amount;
}
攻撃者は、この送金が行われる瞬間に「まだ私の残高は減っていないから、もう一度 withdraw を実行して!」という命令をコントラクトに送り込みます。これが「再入」です。
3. どうやって防ぐのか?「Checks-Effects-Interactions」の鉄則
この攻撃を防ぐための第一のルールが、「Checks-Effects-Interactions(チェック・エフェクト・インタラクション)」パターンです。
先ほどのATMの例で言えば、「現金を渡す前に、必ず残高を減らしておく」というシンプルなルールです。
良い例:順番を正しくしたコード
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);
}
こうすれば、攻撃者が二度目に withdraw を叩いたときには、すでに残高はゼロになっているので、攻撃は失敗します。これだけで、多くの事故を防ぐことができます。
4. さらに強固にするなら:ReentrancyGuard
さらに安全を期すために、多くの開発者は OpenZeppelin が提供している ReentrancyGuard という「鍵」を使います。これは、関数に「入っている間は、他の誰かが入ってこられないようにする」というガードマンを設置する仕組みです。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// ReentrancyGuardを継承する
contract MyVault is ReentrancyGuard {
// nonReentrantという鍵をかける
function withdraw(uint256 _amount) public nonReentrant {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
}
}
nonReentrant という修飾子(モディファイア)を付けるだけで、関数が実行中に再び呼び出されそうになると、自動的に「今は取り込み中です!」と拒否してくれます。便利ですよね!
まとめ:セキュリティは「一歩ずつ」
今回学んだポイントを振り返りましょう。
- 再入攻撃とは: 処理の順番を悪用して、残高が減る前に何度も引き出しを繰り返す手口。
- 最大の防御: 「送金(外部呼び出し)」をする前に、「残高の書き換え(状態更新)」を終わらせること。
- プロのツール:
ReentrancyGuardを使って、二重実行を物理的にブロックすること。
セキュリティと聞くと難しく感じますが、要は「現実世界の防犯と同じ」なんです。プログラムの流れを泥棒の視点で想像し、隙を作らない。この意識を持つだけで、あなたはもう立派なセキュリティの第一歩を踏み出せています。
もし現場でコードを書く際は、ぜひこの「順番」を意識してみてくださいね。一歩ずつ、安全なWeb3の世界を一緒に作っていきましょう!
コメント