こんにちは!IoTデバイスのリバースエンジニアリングや、ブロックチェーンの最前線でセキュリティの調査をしているリサーチャーです。
普段はコードの裏側に隠された脆弱性や、現実世界とつながる制御システムの泥臭いハッキング手法などを追いかけていますが、今回は「これからスマートコントラクトの開発やブロックチェーンセキュリティを学びたい!」という新人のIT担当者や開発者に向けて、最も有名で危険な脆弱性の一つである「再入攻撃(Reentrancy)」についてお話しします。
セキュリティ用語って、なんだか難しそうですよね。「なんだか宇宙語みたいだな…」と感じるかもしれませんが、安心してください!身の回りの防犯の仕組みに例えながら、一歩ずつ優しく紐解いていきましょう。
—
1. 家の鍵に例える「再入攻撃」の恐ろしいメカニズム
まずは、ブロックチェーンの世界でなぜ大事件が起きるのか、身近な「ATMと銀行口座」や「家の鍵」に例えて考えてみます。
想像してみてください。あなたは今、近所のATMでお金を引き出そうとしています。
画面を操作して「1万円を引き出す」とボタンを押しました。すると、ATMの内部では次のような処理が行われますよね。
1. 【確認】 あなたの口座に残高が1万円以上あるかチェックする。
2. 【お金を渡す】 現金をシュッ!とあなたに払い出す。
3. 【記録を更新する】 通帳(データベース)のあなたの残高を「マイナス1万円」に書き換える。
すごく自然な流れに見えますよね? では、もしもこの手順に「あるズル」が使えたらどうなるでしょうか。
もし、ATMが「3. 記録を更新する」よりも前に「2. お金を渡す」を実行し、そのお金をあなたが手にしてカバンにしまっている“まさにその瞬間”に、ATMの窓口に向かってこう叫んだとしたらどうでしょう。
「あ、やっぱりもう一度、さっきの1万円を引き出し続けます!」
ATMはまだ「残高をマイナス1万円にする」という最後の仕事が終わっていません。だから、あなたの口座にはまだ「1万円が残っている」と勘違いして、2回目の1万円をガチャンと払い出してしまうのです。これを無限に繰り返されたら……。そう、銀行の金庫はあっという間にカラッポになってしまいますよね。
これが、スマートチェーンの世界で猛威をふるう「再入攻撃(Reentrancy)」の正体です。コントラクト(プログラム)が外部にお金を送る(外部呼び出し)とき、自分の手元の帳簿を書き換える前に処理を外へ渡してしまうことで、攻撃者に何度も同じお金を引き出されてしまうのです。
—
2. 攻撃者が仕掛ける巧妙な罠(コードで見てみよう)
実際に、Solidityというプログラミング言語で書かれた「危ない銀行コントラクト」を見てみましょう。どこに罠があるか、一緒に探してみましょうね。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 【危険な例】再入攻撃の脆弱性を持つ銀行コントラクト
contract VulnerableBank {
// 預金者の残高を管理する台帳
mapping(address => uint256) public balances;
// お金を預ける関数
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// お金を引き出す関数(ここに致命的なバグがあります!)
function withdraw(uint256 _amount) external {
// 条件チェック:自分の預けた金額より多くは引き出せないよね?
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 【危険なポイント1】お金を外(相手のアドレス)に送る!
// 相手がスマートコントラクトの場合、この瞬間に相手のプログラムが動き出します。
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
// 【危険なポイント2】お金を送った「後」に残高を減らしている!
// 実際には、この行に到達する前に攻撃者に何度もお金を奪われてしまいます。
balances[msg.sender] -= _amount;
}
}
このコードの恐ろしいところは、msg.sender.call{value: _amount}("") という行です。お金を受け取る相手がもし悪意あるプログラム(スマートコントラクト)だった場合、お金を受け取った瞬間に自動で別の関数を呼び返すように仕組むことができます。
結果として、台帳の残高が減らされる前に、何度も withdraw 関数がループして呼び出され、コントラクトの資金がすべて吸い取られてしまうのです。恐ろしい罠ですよね。
—
3. 一歩ずつ学ぶ!二つの強力な防御策
この恐ろしい再入攻撃からスマートコントラクトを守るため、私たち開発者は主に2つのアプローチを使います。どちらも実務の現場では必須のテクニックですので、しっかり押さえていきましょう!
防御策①:Checks-Effects-Interactions パターン(王道の作法)
先ほどの危険な例で、何がダメだったか覚えていますか?
「お金を送る(Interactions)」を先にして、「台帳を書き換える(Effects)」を後にしたからでしたね。
これを順番を逆にするだけで、安全性が劇的に上がります。これが Checks-Effects-Interactions(CEI)パターン です。
1. Checks(チェック):入力値や残高が正しいか最初に確認する。
2. Effects(影響):自分のコントラクト内の状態(残高など)を先に書き換える。
3. Interactions(相互作用):外部への送金など、外のシステムを呼び出す処理は「一番最後」に行う。
実際にこのパターンで書き直した安全なコードを見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 【安全な例】CEIパターンを徹底した銀行コントラクト
contract SecureBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) external {
// 1. Checks(チェック):残高の確認
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 2. Effects(影響):お金を外に送る「前」に残高を減らしておく!
// これにより、万が一ループされても既に残高はゼロなので攻撃を防げます。
balances[msg.sender] -= _amount;
// 3. Interactions(相互作用):すべての状態更新が終わった「最後」に送金する
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
たったこれだけのことですが、状態が先に更新されているため、仮に攻撃者が「もう一度引き出そう!」とループしてきても、残高がすでに減っているため require のチェックで弾かれます。お見事ですね!
—
防御策②:ReentrancyGuard(鍵のかかった個室のルール)
もう一つの強力な武器が、業界標準ライブラリ(OpenZeppelinなど)で提供されている ReentrancyGuard という仕組みです。
これは現実世界で例えるなら、「鍵のついた一人用のトイレ(あるいは試着室)」のようなものです。
1. 人が中に入るときに、ガチャンと鍵を閉めます(Lock)。
2. 用事を済ませている最中は、外からどんなにドアをノックされても、鍵が閉まっているので中には入れません。
3. 用事がすべて終わって外に出るときに、鍵を開けます(Unlock)。
もし、用事をしている最中に誰かが無理やり入ろうとしても、「現在使用中です」と弾くことができますよね。これをスマートコントラクトで実装したものが ReentrancyGuard です。
実際の使い方はとてもシンプルです。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// OpenZeppelinのReentrancyGuardをインポートします
import "@openzeppelin/contracts/security/ReentrancyGuard.class.sol"; // ※実際のパスは環境に合わせて調整してください
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 関数に「nonReentrant」という特別な鍵(修飾子)をペタッと貼るだけ!
function withdraw(uint256 _amount) external nonReentrant {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 外部呼び出し
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount;
}
}
関数の宣言部分に nonReentrant という言葉(モディファイア)が追加されていますね。たったこれ一言を添えるだけで、コントラクト全体(あるいはその関数)が二重に実行されるのを自動でガッチリガードしてくれるのです。なんて心強い味方なんでしょう!
—
まとめ:安全なスマートコントラクト開発への第一歩
今回は、再入攻撃の仕組みと、それを防ぐための「CEIパターン」「ReentrancyGuard」について解説しました。
- 再入攻撃とは:外部にお金を送る処理の途中で、状態(残高)の更新を忘れたり後回しにしたりすることで、何度もお金を不正に引き出されてしまう恐ろしい脆弱性。
- 防御策1(CEIパターン):お金を渡すのは「最後」にする。チェックして、状態を変えてから、外に送る。
- 防御策2(ReentrancyGuard):
nonReentrantという鍵をつけて、処理の途中で別の人が割り込むのを防ぐ。
Web3の世界では、一度スマートコントラクトをブロックチェーン上にデプロイしてしまうと、原則として後からコードを簡単に修正することができません。だからこそ、開発の初期段階からこうした防犯意識をしっかりと持っておくことが何よりも大切です。
「難しそう…」と感じた方も、まずはご自身の書いたコードで「外部呼び出しの順番」や「ガードの有無」を意識することから始めてみてください。一歩ずつ、安全で信頼されるエンジニアへの階段を登っていきましょう!
コメント