こんにちは!ブロックチェーン開発の世界に飛び込んだばかりの新人エンジニアの皆さん、日々のコーディングお疲れ様です。
スマートコントラクトを書いていると、「お金をやり取りするプログラムって、なんだか銀行の金庫番みたいでカッコいいな!」ってワクワクしますよね。でも、その一方で「もし自分の書いたコードにバグがあったら、一瞬で全財産がハッキングされてしまうのでは…」というヒヤヒヤ感もあるはずです。
今回は、そんなスマートコントラクトのセキュリティにおいて最も有名でありながら、今なお多くの開発者を泣かせている「再入攻撃(Reentrancy)」のちょっと高度な変種について、身近な防犯の例えを交えながら、一歩ずつ優しく紐解いていきたいと思います。
難しい専門用語が出てきても置いてけぼりにしませんので、リラックスして読み進めてくださいね!
—
1. 再入攻撃ってそもそも何?(留守番中の空き巣に例えてみよう)
まずは基本の復習からいきましょう。「再入攻撃」がどんなものか、よくある例え話でお話ししますね。
想像してみてください。あなたが自分の家にいて、近所の子供がお小遣いを借りにやってきました。あなたは財布からお金を出して渡そうとします。
通常の正しい手順はこうです:
1. 子供がいくら持っているか(残高)を確認する。
2. 財布からお金を渡す。
3. ノートに「〇〇くんにいくら渡した」と先に書き留めてから、子供を家から帰す。
ところが、もし「お金を渡した後に、ノートに書き留める」という順番になっていたらどうなるでしょう?
子供が賢くて悪知恵が働く子(ハッカー)だと、こう考えます。
「お金をもらった!でもまだノートには『もらった』って書かれていないぞ。ということは、もう一回『お金をください』って言えば、また同じだけもらえるはずだ!」
子供は財布からお金を受け取った瞬間、ノートに記録される前に「まだお小遣いもらってませんけど?」と、再びあなたにお金を要求します。あなたが「あれ?おかしいな、まあいいか」ともう一度お金を渡すと、またノートに書かれる前なので無限にお金を引き出せてしまいますよね。
これが、スマートコントラクトにおける「再入攻撃」の基本的な仕組みです。プログラムの処理が完全に終わる前(ノートに書く前)に、外部のプログラムへコントロールを渡してしまうことで発生します。
—
2. 進化する脅威:高度な変種たち
基本の再入攻撃は「お金を引き出す関数」を何度も呼び出すものでしたが、最近の攻撃者はもっと巧妙です。今回は、その代表格である「クロスファンクション再入攻撃(Cross-function reentrancy)」と「読み取り専用再入攻撃(Read-only reentrancy)」の2つを見ていきましょう。
クロスファンクション再入攻撃とは?
さっきの例えで言うと、子供がお金を要求するだけでなく、別の部屋にある「おもちゃ箱」のルールも同時にハッキングするようなイメージです。
コントラクト(プログラム)の中に、例えば「預金を引き出す関数」と「別のユーザーに送金する関数」があったとします。引き出し処理の途中で外部の怪しいプログラムにコントロールが移ったとき、その隙に「別の送金関数」を呼び出されてしまうのがクロスファンクション再入攻撃です。
「えっ、違う関数なのに狙われるの?」と驚きますよね。はい、コントラクト全体で共有している「残高データ」などの状態が中途半端なままだと、別の窓口から不正にアクセスされてしまうんです。
読み取り専用再入攻撃とは?
もっと厄介なのがこれです。「お金を引き出すわけではないけれど、一時的に数字がおかしくなっている瞬間を、別の別の仕組み(DeFiプロトコルなど)に利用されて大金を奪われる」というものです。
例えば、あるプールの価格計算をしている最中に、外部のプログラムがそのプールを覗き見たとします。その瞬間、計算が途中だったために「今、このトークンは1円の価値しかありません!」という嘘の情報を外部に伝えてしまう。外部のサービスはその嘘を信じ込み、本来の価値よりも安く買い叩いたり、担保の価値を誤認して不正に借金をしたりします。
直接お金を盗まなくても、「一時的なデータの矛盾」を他人に悪用されてしまうのが、この読み取り専用再入攻撃の恐ろしいところです。
—
3. 実際のコードを見てみましょう(脆弱な例と対策)
百聞は一見にしかず。Solidity(スマートコントラクトの言語)で、どのようなコードが危なくて、どう直すべきかを見ていきましょう。
🚨 危ないコードの例(再入攻撃の隙がある状態)
以下のコードは、ユーザーが預けたお金を引き出す処理ですが、「お金を送る(外部への送金)」を先に行い、そのあとに「残高をゼロにする(状態の更新)」という致命的なミスを犯しています。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VotableVault {
mapping(address => uint256) public balances;
// お金を預ける関数
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 【危険!】お金を引き出す関数
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Not enough balance");
// 1. 外部へ先にお金を送っている(ここでコントロールがハッカーのプログラムに移る!)
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
// 2. 残高の更新が「後回し」になっているため、この前に何度もwithdrawが呼ばれてしまう
balances[msg.sender] -= _amount;
}
}
このコードでは、msg.sender.call が実行された瞬間に外部のコードが動き出し、残高がマイナスされる前に再度 withdraw を呼び出すことができます。
—
🛡️ 守りのコード:Mutex(鍵)をかけよう!
この攻撃を防ぐための最も効果的な手段の一つが、「Mutex(ミューテックス:排他制御の鍵)」という仕組みです。
身近な例で言うと、「トイレの鍵」です。トイレに入るときにガチャッと鍵をかければ、中で用事を足している最中に他の人が無理やり入ってくることはできませんよね。プログラムの世界でも、「今、この大事な処理の最中だから、他の人は入ってこないで!」と鍵をかけるフラグを用意するのです。
先ほどのコードを、Mutexを使って安全に書き直してみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecureVault {
mapping(address => uint256) public balances;
// トイレの鍵に相当する状態変数(false = 開いている, true = 使用中)
bool private locked;
// 鍵をかけるための「モディファイア(特殊な条件チェック機能)」を作成します
modifier noReentrancy() {
require(!locked, "Reentrant call detected! Lock is active.");
locked = true; // 部屋に入ったら即座に鍵をかける
_; // 本来の処理をここで実行する
locked = false; // 処理が終わったら鍵を開ける
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 【安全!】noReentrancyモディファイアで鍵をかけています
function withdraw(uint256 _amount) external noReentrancy {
require(balances[msg.sender] >= _amount, "Not enough balance");
// さらに安全性を高めるため、外部に送金する「前」に残高を更新する(Checks-Effects-Interactionsパターン)
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
このコードでは、2つの強力な防御策を組み合わせています。
1. Checks-Effects-Interactions(チェック・影響・相互作用)の原則:お金を外に出す(Interactions)よりも「前」に、自分の頭の中のメモ(状態変数)をきっちり書き換える(Effects)。
2. Mutex(noReentrancy):万が一、処理の途中で外部から横やりが入ろうとしても、「今鍵がかかっているのでダメです!」と門前払いする。
これなら、空き巣も入り込む隙がありませんよね!
—
4. まとめ:一歩ずつ、安全なコードを書けるエンジニアへ
今回は、スマートコントラクトにおける再入攻撃の高度な変種と、その対策であるMutexについてお話しました。
大切なポイントを振り返ってみましょう:
- 再入攻撃は、「処理が完全に終わる前に外部へコントロールを渡し、その隙を突いて何度も同じ処理を実行させる」巧妙な手口。
- クロスファンクションや読み取り専用など、進化系もあるため油断は禁物。
- 防御の基本は、「状態を先に更新する(Checks-Effects-Interactions)」ことと、「Mutexで処理中に鍵をかける(noReentrancy)」こと。
セキュリティの世界は一見すると難しそうに見えますが、こうして身近な防犯や生活の仕組みに置き換えてみると、本質はとてもシンプルです。「自分が書いたプログラムの順番は正しいか?」「途中で邪魔をされる隙はないか?」と、一歩立ち止まって疑う視点を持つことが、優れたセキュリティリサーチャーや開発者への第一歩になります。
焦らず、一歩ずつ確実にお墨付きの安全なコードを書けるエンジニアを目指していきましょう!あなたの開発ライフを応援しています!
コメント