こんにちは!スマートコントラクトの開発やブロックチェーンの世界へようこそ。
新しい技術に触れるとき、「何から手をつけたらいいんだろう?」とワクワクする反面、セキュリティの話を聞くと「なんだか難しそうだな…」と少し身構えてしまいますよね。でも、安心してください!一歩ずつ、身近な例えから一緒に学んでいけば、必ず理解できるようになりますよ。
今回は、Web3開発で絶対に避けて通れない「コントラクトの初期化関数(Initializer)の保護」というテーマについて、お話ししていきますね。
スマートコントラクトの世界では、「一度ブロックチェーンに書き込んだプログラム(コード)は、原則として後から書き換えられない」というルールがあります。でも、「やっぱり少し機能を直したい」「新しい機能を追加したい」と思うことってありますよね。そんなときに使われるのがプロキシパターンという便利な仕組みなのですが、ここに「初期化のし忘れ」を狙ったちょっと厄介な落とし穴があるんです。
それでは、家の鍵の防犯にたとえながら、その仕組みを優しく紐解いていきましょう!
—
1. 家の鍵にたとえて理解する「初期化関数」の危険な落とし穴
まずは、現実の世界で考えてみてください。あなたが新しく一軒家を建てたとします。
家が完成したとき、一番最初にしなければならない大切な作業は何でしょうか? そう、「玄関の鍵を新しいものに交換して、自分だけが合鍵を持つこと」ですよね。
もし、工事業者が作業を終えたあと、うっかり玄関の鍵を開けっぱなしにして、さらに「誰でも自由にこの家の合鍵を新しく作っていいですよ」という看板を掲げたままだとしたらどうでしょう?
あなたが引っ越してくる前に、通りすがりの泥棒が勝手に家に入り込み、自分が「新しい家のオーナー(所有者)」だと宣言して、合鍵を自分専用に変えてしまったら……家の中の財産はすべて奪われてしまいますよね。
ブロックチェーン上のスマートコントラクトにおける「初期化関数(Initializer)」も、これとまったく同じなんです。
プロキシパターンを使ったコントラクトでは、中身のプログラム(実装コントラクト)を後から入れ替えることができます。そのため、新しいプログラムをデプロイ(配置)した直後に、誰がオーナーなのかを設定する「初期化」という作業が必ず必要になります。
この初期化を行うための関数(initializeなど)に、もし「誰でも自由に呼び出せる状態」という隙(アクセス制御の欠如)が残っているとどうなるでしょうか?
攻撃者は、あなたが初期化するのを待ち構えています。あなたが設定を完了するよりもほんの一瞬早く、悪意ある攻撃者がその初期化関数を呼び出し、自分自身を「コントラクトのオーナー(管理者)」に設定してしまうのです。
これが、初期化関数が乗っ取られる恐怖のメカニズムです。家の鍵を泥棒に先に替えられてしまうようなものですね。
—
2. 脆弱性のあるコードを見てみましょう
百聞は一見に如かず。実際に、どんなコードが危険なのかを見てみましょう。
以下のSolidity(ブロックチェーンで使われる言語)のコードは、典型的な「初期化関数が保護されていない」危険な例です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 危険な実装コントラクトの例
// ※ このコードは脆弱性を含んでいます!真似しないでくださいね。
contract VulnerableWallet {
address public owner;
bool private initialized;
// 誰でも呼び出せる初期化関数
// アクセス制御(修飾子)が何もついていないのが問題です!
function initialize() public {
// 初期化済みかどうかのチェックすらない場合、さらに危険です
owner = msg.sender; // 最初にこの関数を呼んだ人をオーナーにしてしまう
}
function withdraw() public {
require(msg.sender == owner, "Owner only");
// お金を引き出す処理
payable(owner).transfer(address(this).balance);
}
}
このコードの何がいけないのでしょうか?
initialize() 関数には、public という修飾子がついているだけで、「誰がこの関数を呼んでもよい」状態になっています。さらに、もし「すでに初期化されたかどうか」をチェックするガードマン(フラグ)が甘かったりすると、誰でも自由にオーナーの座を奪い取ることができます。
実務の現場では、プロキシコントラクトと組み合わせた際に、この初期化関数がデプロイのトランザクションとは別のトランザクションとして切り離されて実行されることが多いため、この「デプロイから初期化までの空白の時間」をボット(自動プログラム)に狙われてしまうインシデントが実際に起きているのです。
—
3. 防御の鉄則:しっかり鍵をかける実装方法
それでは、この恐ろしい乗っ取りを防ぐためにはどうすればよいのでしょうか?
答えは簡単です。「一度きりしか実行できないようにする(initializer)」ことと、「正当な人しか実行できないように制限する(または初期化関数自体をコンストラクタで保護する)」ことです。
業界標準であるOpenZeppelin社のライブラリを利用すると、安全な初期化関数を簡単に作ることができます。実際のコードを見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// OpenZeppelinの安全な初期化保護ライブラリをインポートします
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.co";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
// 安全に保護された実装コントラクトの例
contract SecureWallet is Initializable, OwnableUpgradeable {
// コンストラクタで実装コントラクト自体の初期化をロックする(推奨パターン)
constructor() {
_disableInitializers(); // 実装コントラクトが悪用されるのを防ぎます
}
// プロキシ経由で呼び出される初期化関数
function initialize(address _initialOwner) public initializer {
// Ownableの初期化(オーナーを設定する)
__Ownable_init(_initialOwner);
}
// 健全な引き出し処理
function withdraw() public onlyOwner {
// オーナーだけが引き出せるように保護されています
payable(owner()).transfer(address(this).balance);
}
}
コードのポイントを優しく解説!
1. Initializable の継承と initializer 修飾子
関数につけられている initializer という言葉に注目してください。これはOpenZeppelinが用意してくれている特別なガードマンです。この修飾子がついた関数は、「ライフサイクルの中で絶対に一度しか実行できない」ように強制してくれます。二度目は自動的にエラー(弾かれる)になります。
2. コンストラクタでの _disableInitializers()
ここが実務での非常に重要なテクニックです! 実体である「実装コントラクト」そのものが直接呼び出されて初期化されるのを防ぐため、あらかじめコンストラクタ(生まれたとき)の段階で初期化機能に鍵をかけて無効化してしまいます。これにより、攻撃者が大本のコントラクトを乗っ取る道を完全に断つことができます。
—
4. 実務やインフラ構築におけるチェックリスト
スマートコントラクトをデプロイし、世の中に公開する前には、ご自身の開発フローやインフラ環境で以下のポイントを必ず確認する癖をつけましょう。
- [ ] 初期化関数に適切な修飾子がついているか? (
initializerやアクセス制御が正しく適用されているか確認する) - [ ] デプロイと初期化が一連のトランザクションで行われているか?(別々のスクリプトで実行する場合、タイムラグを突かれないよう細心の注意を払う)
- [ ] 実装コントラクト単体が不正に初期化されない対策(
_disableInitializers()など)が実装されているか? - [ ] CI/CDパイプラインやテストコードで、初期化関数の二重実行テスト(リレントレスな攻撃テスト)を行っているか?
最初は難しく感じるかもしれませんが、要するに「家の鍵(初期化権限)は一度しか回せないようにして、勝手に知らない人が触れないようにガードする」、たったこれだけのルールを守るだけです。
セキュリティの基本は、こうした「うっかり」を仕組みでカバーすることにあります。一歩ずつ、安全で堅牢なスマートコントラクトを作れるエンジニアを目指して一緒にがんばっていきましょう!
コメント