スマートコントラクトウォレットの盲点:EIP-1271の「再帰的罠」を制圧する
現場でコードを叩いていると、たまに「なぜこんな仕様にしたんだ?」と頭を抱えたくなる設計に出くわす。EIP-1271、つまり「スマートコントラクトによる署名検証」もその一つだ。
EOA(秘密鍵を持つ普通のウォレット)と違い、スマートコントラクトウォレット(Gnosis Safeなど)は、秘密鍵の代わりに「コントラクト内のロジック」で署名の正当性を判断する。便利だが、この「ロジック」の部分に手を入れるとき、お前たちがどれだけ深い落とし穴に足を突っ込んでいるか自覚はあるか?
今日は、EIP-1271の実装でよくある「再帰的な検証リスク」と、それを防ぐための鉄壁の防御策を叩き込む。
—
1. なぜ「署名検証」でハッキングが起きるのか?
EIP-1271は、コントラクトが isValidSignature(bytes32 _hash, bytes memory _signature) という関数を実装し、呼び出し元に対して「この署名は有効だ」と 0x1626ba7e を返す仕組みだ。
問題は、この検証ロジックが「外部の別のコントラクト」を呼び出すように設計されている場合に発生する。
攻撃者の狙い:再帰呼び出し(Reentrancy)
もしお前たちが、署名の検証プロセスの中で「外部の不審なコントラクト」を呼び出すような設計(例:マルチシグの承認ロジックを外部コントラクトで管理するなど)をしていれば、攻撃者はその隙に 「署名検証が完了する前に」 ステータスを書き換えたり、不正な状態遷移を引き起こしたりする。
これがWeb3特有の「再帰的なりすまし」だ。署名検証の最中に、検証対象のデータが改ざんされる。この一瞬の隙を見逃すのが、現場で一番怖いインシデントだ。
—
2. 【実務的防御】セキュアなEIP-1271実装サンプル
脆弱な実装の最大の特徴は、「状態を保持したまま外部呼び出しを行うこと」だ。以下のコードは、最低限守るべき「再帰を防ぐ構造」を持たせたテンプレートだ。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
contract SecureWallet is IERC165 {
// 署名の有効性を判定するマジック値
bytes4 constant internal MAGIC_VALUE = 0x1626ba7e;
// 再帰呼び出しを防ぐためのガード(OpenZeppelinのReentrancyGuardに近い考え方)
bool private _locked;
modifier nonReentrant() {
require(!_locked, "Reentrancy detected");
_locked = true;
_;
_locked = false;
}
// EIP-1271の肝:検証ロジック
function isValidSignature(bytes32 _hash, bytes memory _signature)
public
view
returns (bytes4)
{
// 1. 外部呼び出しを行う前に、再帰の可能性を排除する設計にする
// 2. 署名が正しいかを確認するロジック(暗号学的に安全なecrecover等)
if (_verifySignature(_hash, _signature)) {
return MAGIC_VALUE;
}
return 0xffffffff;
}
function _verifySignature(bytes32 hash, bytes memory sig) internal pure returns (bool) {
// ここに署名検証のガチガチのロジックを書く
// 外部コントラクトへの依存は極力避ける。どうしても必要な場合は
// ステート変更を一切伴わない(Staticcall)設計にすること
return true;
}
function supportsInterface(bytes4 interfaceId) public view override returns (bool) {
return interfaceId == 0x1626ba7e || interfaceId == type(IERC165).interfaceId;
}
}
—
3. セキュリティチーフからの「現場の心得」
コードをコピペするだけでは守れない。次の3点を必ず意識しろ。
1. Staticcall の強制: もし署名検証ロジックで他コントラクトを呼ぶなら、必ず staticcall を使用しろ。これにより、検証中に「ステートの書き込み」が発生しようとした瞬間にリバート(強制終了)がかかる。
2. 検証データの不変性: _hash が検証中に変化しないことを担保しろ。特に署名検証関数の中で msg.sender に依存した条件分岐を入れるな。それは攻撃者に「検証プロセスそのものをハックする鍵」を渡しているのと同義だ。
3. オフチェーン側の検証: スマートコントラクトだけで完結させようとするな。バックエンド(Python/Node.js)で署名検証を行う際、必ず ethers.js や web3.py を用いて、オンチェーンの挙動をシミュレーション(eth_call)してからトランザクションを発行するフローを徹底しろ。
検証用Pythonスニペット (Web3.py)
運用監視チームは、デプロイ前にこうやってテストを通すのが正攻法だ。
from web3 import Web3
# 署名の有効性をシミュレーションで確認する関数
def verify_signature_offchain(contract, hash_val, signature):
try:
# call()を使って、トランザクションを送らずに検証ロジックを叩く
result = contract.functions.isValidSignature(hash_val, signature).call()
return result == "0x1626ba7e"
except Exception as e:
print(f"セキュリティアラート: 検証中に例外発生 {e}")
return False
—
最後に
「仕様通りに動く」ことと「セキュアである」ことは、天と地ほどの差がある。EIP-1271は柔軟性が高い分、設計者の意図がそのまま脆弱性になり得る。
お前たちが書いたコントラクトが、誰かの全財産を預かる可能性を忘れるな。泥臭いログの確認と、徹底した再帰チェックこそが、インシデントを防ぐ唯一の近道だ。何かあればいつでも聞きに来い。現場からは以上だ。
コメント