こんにちは!セキュリティリサーチャーとして、日々SCADA(制御システム)の物理的な隙間から、ブロックチェーンのコードの1行までを舐めるように解析している筆者です。
皆さんは「スマートコントラクト」の開発に触れたとき、最初に「なんだか魔法みたいだな」と思いませんでしたか? 自動でお金が動き、契約が執行される。しかし、その「魔法」の裏には、攻撃者が虎視眈々と狙っている「一瞬の隙」が存在します。
今日は、数々のプロジェクトを崩壊させてきた恐ろしい攻撃、「再入攻撃(Reentrancy Attack)」についてお話しします。難しそうな名前ですが、実は「家の鍵をかけるタイミング」と同じくらい身近な知恵で防げるものなんです。
初心者の方でも迷わないよう、泥棒と家の防犯に例えて、優しく、でも本質を突いた解説をしていきますね。一歩ずつ、一緒に学んでいきましょう!
—
1. 再入攻撃ってなに?「魔法の返金手続き」の罠
まずは、この攻撃がどんなものか、身近な例でイメージしてみましょう。
想像してみてください。あなたは近所の無人販売所で、1,000円の商品を返品して返金を受けようとしています。
1. あなたが「返金してください」とボタンを押します。
2. 機械が「はい、1,000円返しますね」とお金を出口に出します。
3. あなたがそのお金を手に取った後、機械が「返金済み」と帳簿に記録します。
一見、何も問題ないように見えますよね? でも、もしあなたが「凄腕の泥棒」だったらどうするでしょう。
機械がお金を出した瞬間(手順2の後)、帳簿に「返金済み」と書かれる前(手順3の前)に、もう一度「返金してください!」とボタンを押したらどうなるでしょうか?
機械はまだ帳簿を書き換えていないので、「おや、まだこの人は返金を受けていないな」と勘違いして、また1,000円を出してしまいます。 これを繰り返せば、機械の中のお金が空っぽになるまで引き出せてしまいますよね。これが「再入攻撃」の正体です。
—
2. なぜプログラムでこんなことが起きるの?
スマートコントラクト(Solidity)の世界では、外部の口座にお金を送る際、相手のプログラムが実行されるタイミングがあります。
攻撃者は自分の口座(コントラクト)に「お金を受け取った瞬間に、もう一度相手の返金関数を呼び出す」という特殊な仕掛けを組み込んでおきます。
【危険なコードの例】
まずは、あえて「隙」のあるコードを見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableBank {
mapping(address => uint256) public balances;
// お金を預ける
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// お金を引き出す(ここが危険!)
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "残高が足りません");
// 1. 先にお金を送ってしまう
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "送金失敗");
// 2. 送金が終わった後に残高をゼロにする(手遅れ!)
balances[msg.sender] = 0;
}
}
このコードの何がマズいかというと、「お金を渡してから、帳簿(balances)を書き換えている」点です。送金処理(call)の最中に攻撃者が戻ってきて、まだ 0 になっていない残高を元に、何度も引き出しを繰り返せてしまうのです。
—
3. 鉄壁の守りその1:Checks-Effects-Interactionsパターン
この攻撃を防ぐ最も基本的で強力なルールが、「Checks-Effects-Interactions(確認・実行・相互作用)」パターンです。
これは、防犯で言えば「品物を渡す前に、必ず領収書にサインをもらい、在庫台帳を消し込む」というルールを徹底することに似ています。
- Checks: 条件の確認(残高はあるか?)
- Effects: 自分の状態の更新(先に残高を0にする!)
- Interactions: 外部とのやり取り(最後にお金を送る)
【安全なコードの例】
先ほどのコードを、このルールに従って書き換えてみましょう。
function withdraw() public {
uint256 amount = balances[msg.sender];
// [Checks] まずは条件をチェック!
require(amount > 0, "残高が足りません");
// [Effects] ここが超重要! お金を送る前に「帳簿」を先に書き換えます
balances[msg.sender] = 0;
// [Interactions] 最後に実際のお金を送ります
(bool success, ) = msg.sender.call{value: amount}("");
// もし送金が失敗したら、帳簿を元に戻す(ロールバックされる)ので安心です
require(success, "送金失敗");
}
こうしておけば、攻撃者が送金の途中で「もう一回返して!」と戻ってきても、既に balances[msg.sender] は 0 になっているので、2回目は拒否されます。鍵を閉めてから中身を確認しに行くようなものですね。
—
4. 鉄壁の守りその2:ReentrancyGuard(二重ロック)
「ルールはわかったけど、もっと物理的にガチッと守る方法はないの?」という方におすすめなのが、ReentrancyGuard です。
これは、関数の入り口に「使用中」という札をかけ、処理が終わるまで誰も(自分自身でさえも)入れないようにする仕組みです。
OpenZeppelinという世界的に信頼されているライブラリを使うのが一般的です。
【ガードを実装した例】
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// ReentrancyGuardを継承します
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
// nonReentrant という「お守り」を関数につけます
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "残高が足りません");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "送金失敗");
}
}
nonReentrant という言葉を添えるだけで、この関数が実行されている間は、同じコントラクト内の他の(同じガードがついた)関数も呼び出せなくなります。まるで、「一度入ったら、用事が済んで外に出るまでドアをロックする」強力な自動ドアのようなものです。
—
5. 現場のリサーチャーが見る「盲点」:クロス関数再入
ここまでは「同じ関数にまた入ってくる」話でしたが、実はもっとずる賢い攻撃があります。それが「クロス関数再入」です。
例えば、「お金を引き出す関数」と「自分の残高を誰かに譲渡する関数」の2つがあったとします。
1. 「引き出し関数」でお金を送る(まだ帳簿は書き換わっていない)。
2. その途中で「譲渡関数」を呼び出し、まだ残っているはずの残高を別のアカウントに移動させる。
3. 移動先のアカウントからもお金を引き出す。
このように、複数の関数をまたいで「帳簿の書き換え待ち」を狙われることがあります。だからこそ、「お金に関連するすべての関数にガードをつける」ことと、「常にChecks-Effects-Interactionsを守る」ことがセットで重要になるんです。
—
最後に:一歩ずつ、安全なコードへ
いかがでしたでしょうか?
再入攻撃は、たった1行の書く順番を間違えるだけで、何十億円という資産が失われる恐ろしい脆弱性です。でも、その本質は「後片付けを先にする」という、私たちの日常生活でも大切な習慣と同じなんです。
1. Checks-Effects-Interactions を合言葉にする。
2. 大事な処理には nonReentrant の鍵をかける。
3. 「もしこの処理の途中で割り込まれたら?」と常に疑ってみる。
この3点を意識するだけで、あなたの書くコードの安全性は飛躍的に高まります。
セキュリティの世界は奥が深いですが、こうして一つずつ仕組みを解き明かしていけば、決して怖いものではありません。これからも一緒に、安全でワクワクするWeb3の世界を作っていきましょう!
もし分からないことがあれば、いつでもドキュメントに戻ってきたり、信頼できるコミュニティで質問してみてくださいね。「一歩ずつ」が、最強の防御への近道です!
コメント