こんにちは!日々、新しい技術やシステムの開発に悪戦苦闘している新人のIT担当者さん、そしてセキュリティの世界に一歩を踏み出したばかりの開発者の皆さん、お疲れ様です!
ブロックチェーンやスマートコントラクトの世界って、なんだか最先端でカッコいい響きがしますよね。でも、いざコードを書き始めると「お金(暗号資産)を扱うから一瞬のミスも許されない…」というプレッシャーで、冷や汗をかいてしまうことも多いのではないでしょうか。
今回は、そんなスマートコントラクトのセキュリティにおいて、もっとも有名で、そしてもっとも恐ろしい「再入攻撃(Reentrancy Attack)」という脆弱性について、一緒に紐解いていきたいと思います。
難しい専門用語が出てきても安心してくださいね。身近な防犯の例えを交えながら、一歩ずつ優しく解説していきますので、リラックスして読んでいってください!
—
1. 家の鍵を開けたまま泥棒を招き入れる?「再入攻撃」ってなに?
まずは、再入攻撃がどんなものなのか、私たちの日常生活に置き換えて考えてみましょう。
想像してみてください。あなたは自分の家(スマートコントラクト)の中に、大切な宝箱(預けたイーサリアムなどの資金)を置いています。家には信頼できる管理人さんがいて、あなたが「お金を引き出したいです」と言えば、確認をしてお金を手渡ししてくれます。
通常の正しい手順はこうです。
1. あなたが「1万円ちょうだい」と頼む。
2. 管理人さんが「残高を確認して……よし、1万円だね。あなたの口座から1万円をマイナスしておこう(状態の更新)」と帳簿を書き換える。
3. そのあとで、管理人さんがあなたに1万円を手渡しする(外部への送金)。
これなら安心ですよね。帳簿を先に書き換えているので、あなたが不正をしようとしても、もうお金はありません。
泥棒の手口:終わる前に何度も「お金ちょうだい!」
ところが、もし管理人さんの仕事の順番が逆だったらどうなるでしょうか?
1. あなたが「1万円ちょうだい」と頼む。
2. 管理人さんが、帳簿を書き換える前に、先にあなたに1万円を手渡ししてしまう。
3. あなたが「ありがとうございます!」と財布を受け取る……その瞬間です!
ここで、ちょっと意地悪な仕組み(悪意あるコントラクト)を作っておいた泥棒は、お金を受け取るまさにその直前のフック(割り込み処理)を使って、管理人さんにこう叫びます。
「あ、そうそう!さっきの1万円、もう一度ちょうだい!」
管理人さんは、まだ最初の1万円を渡したときの帳簿の書き換え(自分の残高をマイナスにする作業)をしていません。「あれ?この人の残高はまだ減っていないな。じゃあ、もう1万円渡さなきゃ」と、2回目の1万円を渡してしまいます。
これが「再入攻撃」のメカニズムです。お金を渡すというアクションが終わる前に、まだ帳簿が更新されていない隙を突き、何度も何度も「もう一回!」と部屋に侵入(再入)して、金庫がからっぽになるまでお金を奪い去ってしまうのです。現実の世界でこれをやったら一発レッドカードの詐欺ですよね。
—
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 {
// 1. ユーザーの残高が足りているかチェック
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 2. 【脆弱性】お金(外部コントラクト)を先に送金している!
// 悪意のあるコントラクトはこの送信処理の途中で割り込んできます。
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
// 3. 帳簿の更新(残高を減らす)が「後回し」になっている
// 2番の送金処理の最中に割り込まれると、この行に到達する前に何度もwithdrawが呼ばれてしまう!
balances[msg.sender] -= _amount;
}
}
このコードの最大の欠点は、msg.sender.call{value: _amount}("") という外部への送金(Interactions)が先に行われ、そのあとに balances[msg.sender] -= _amount という帳簿の更新(Effects)が後回しになっている点です。
攻撃者は、この call の仕組みを利用して、お金を受け取った瞬間に自分のコントラクトの receive() や fallback() という自動実行関数を起動させ、再び VulnerableBank.withdraw() を呼び出すループを作ります。結果として、銀行のお金がすべて吸い取られてしまうわけです。
—
3. 鉄壁の守りを作る!防衛策の二大巨頭
「うわぁ、怖いですね……じゃあどうやって自分のスマートコントラクトを守ればいいんですか?」
安心してください!先人たちの努力によって、この再入攻撃を綺麗に防ぐための確実なパターンが確立されています。ここでは代表的な2つの対策を学びましょう。
対策その1:「Checks-Effects-Interactions パターン」
一番シンプルで、まず最初にマスターすべきなのがこのパターンです。日本語に直すと「確認・効果・相互作用」の順番を守ろうね、ということです。
さっきの泥棒の例で言えば、「お金を渡す前に、必ず自分の手元の帳簿を先に書き換えておく」という鉄則です。
安全なコードに書き直してみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 【安全な実装例】Checks-Effects-Interactions パターンを適用
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(効果):お金を動かす「前」に、内部の帳簿を先に更新する!
// これにより、万が一割り込まれても、すでに残高はゼロ(または減っている)なので2回目の引き出しは弾かれます。
balances[msg.sender] -= _amount;
// 3. Interactions(相互作用):最後に外部へお金を送金する
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
どうでしょう? balances[msg.sender] -= _amount; を送金処理の前に持ってきただけです。たったこれだけの順番の入れ替えで、攻撃者が何度「もう一回!」と叫んでも、帳簿の残高がすでに減っているため、require のチェックで「残高不足です!」と跳ね返すことができるようになります。
—
対策その2:「ReentrancyGuard(鍵付きのドア)」を使う
もう一つの強力なアプローチが、OpenZeppelinなどの信頼できるセキュリティライブラリが提供している ReentrancyGuard という仕組みを使う方法です。
これは現実の例えで言うと、「トイレや試着室の鍵」のようなものです。中に入るときにガチャッと鍵(ロック)をかけ、用事が終わって外に出るまで、絶対に他の人が同じ部屋に入れないようにする仕組みです。
実装を見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// OpenZeppelinのReentrancyGuardをインポートします
import "@openzeppelin/contracts/security/ReentrancyGuard.class.sol"; // または類似のパス
// ReentrancyGuardを継承(继承)する
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// nonReentrant 修飾子(Modifier)を追加するだけ!
function withdraw(uint256 _amount) external nonReentrant {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// ここで外部送金を行っても、nonReentrantのおかげでこの関数への「再入」はブロックされます
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount; // (※教育目的のあえての順番ですが、Guardがあればこの順序でも安全です)
}
}
関数に nonReentrant という魔法の言葉(モディファイア)をつけるだけで、コントラクトが自動的に実行中のロックを管理してくれます。実務の現場では、ヒューマンエラーを防ぐためにも、この ReentrancyGuard と先ほどの Checks-Effects-Interactions パターンの両方を組み合わせて使うのが、いわゆる「業界のベストプラクティス(模範解答)」となっています。
—
4. 最後に:安全なコードへの第一歩を踏み出そう!
今回は、再入攻撃の仕組みと、その防衛策についてお話ししました。
- 再入攻撃とは、お金を渡す処理の最中に割り込まれて、帳簿が更新される前に何度も引き出しを繰り返されてしまう脆弱性であること。
- 防ぐためには、「Checks-Effects-Interactions パターン」を守り、状態の更新を先に行うこと。
- さらに、
ReentrancyGuardを使って、関数への多重侵入をガッチリとロックすること。
セキュリティの世界は一見すると難しそうに見えますが、こうして一つひとつのメカニズムを紐解いていくと、論理的でとてもエキサイティングなものです。「どうすれば攻撃者が隙を突けるか」という視点(攻撃者の目線)を持つことで、逆に「どう作れば絶対に破られないか」という強固な守りを作ることができます。
失敗を恐れず、まずはローカルのテスト環境(RemixやHardhatなど)で安全なコードと危険なコードを実際に書いて、動きの違いを体験してみてくださいね。
一歩ずつ、確実にスキルアップしていきましょう!応援しています!
コメント