EIP-712の罠:クロスチェーンリプレイ攻撃と型付きデータ署名の深層解析
スマートコントラクトのセキュリティ監査を行っていると、開発者が「オフチェーン署名=安全」という神話を盲信している現場に幾度となく直面する。特に、複雑なDeFiプロトコルやブリッジコントラクトにおいて、EIP-712の仕様を正しく理解せず実装した結果、致命的なクロスチェーンリプレイ攻撃(Cross-Chain Replay Attack)の踏み台となっているケースが後を絶たない。
生粋のセキュリティリサーチャーとして断言するが、EIP-712の本質は単なる「人間が読めるUIの提供」ではない。あれは、バイトコードの海を漂う生のエントロピー(secp256k1のr, s, v)に、厳密な「文脈(Context)」という名の鎖をつなぐための暗号学的防壁なのだ。
本稿では、EIP-712の実装不備が引き起こす脆弱性の根源を、低レイヤのバイトコード構造とEVMのメモリ挙動から紐解き、実務で即座に使える堅牢な防御アーキテクチャを提示する。
—
1. 脆弱性の根本原因:なぜEIP-712署名は破られるのか
従来のeth_signやpersonal_signでは、任意のバイト列に署名するため、ユーザーは何に署名しているのかを目視できず、盲目的な署名(Blind Signing)が常態化していた。これを解決するために導入されたのがEIP-712である。
しかし、EIP-712が定めた構造化データのハッシュ化プロセス(eip712HashStruct)において、開発者が以下の要素を適切に分離・検証しない場合、攻撃者は異なるチェーンや異なるコントラクト間で署名を再利用(リプレイ)することが可能になる。
1. ドメインセパレータ(Domain Separator)の欠陥
2. chainIdの動的バインドの欠落
3. verifyingContractのハードコードまたは未検証
攻撃者は、あるチェーン(例えばPolygonやArbitrum)でユーザーが正当に行った署名をキャプチャし、全く同じコントラクトアドレスがデプロイされている(あるいはプロキシの不備をつける)別のチェーンへと流し込む。EVM上では、ecrecoverが返すアドレスが一致しさえすれば、それがどのネットワークで生成された署名であるかをデフォルトでは関知しない。ここに最大の盲点がある。
—
2. 脆弱な実装パターンと攻撃シナリオ
まずは、セキュリティ監査で頻繁に発見される「危殆化したEIP-712実装」を見てみよう。以下のコードは、一見すると標準的なEIP-712に準拠しているように見えるが、致命的な欠陥を抱えている。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// 【危険な実装例】chainIdやverifyingContractの検証が不十分なコントラクト
contract VulnerablePermitBridge {
using ECDSA for bytes32;
// 脆弱なEIP-712のタイプハッシュ
bytes32 public constant TRANSFER_TYPEHASH = keccak256(
"Transfer(address to,uint256 amount,uint256 nonce)"
);
mapping(address => uint256) public nonces;
mapping(bytes32 => bool) public executed;
// ドメインセパレータの構築においてchainIdやコントラクトアドレスをハードコードしている、
// あるいはコンストラクタで動的に設定していない場合
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("VulnerableBridge")),
keccak256(bytes("1")),
1, // <-- ハードコードされたchainId (例: Ethereum Mainnet)
address(this)
)
);
}
function transferWithSignature(
address to,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
require(nonce == nonces[msg.sender], "Invalid nonce");
// 構造化データのハッシュ化
bytes32 structHash = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
to,
amount,
nonce
)
);
// ダイジェストの生成
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
// 署名者の復元
address signer = digest.recover(signature);
require(signer != address(0), "Invalid signature");
require(!executed[digest], "Already executed");
executed[digest] = true;
nonces[msg.sender]++;
// 実際の転送処理(省略)
}
}
この実装の何が問題なのか?
もしこのコントラクトが、Ethereum Mainnet(Chain ID: 1)だけでなく、OptimismやBaseなどのL2チェーンに同じアドレス(CREATE2や安価な初期化手法の悪用による)でデプロイされた場合を想像してほしい。
コンストラクタでchainIdを 1 にハードコードしている、あるいはデプロイ時のチェーン環境を正しく反映していない場合、L2上で生成された署名やメインネットで生成された署名が、他のチェーンの同名コントラクトでそのまま有効になってしまう。攻撃者はユーザーにL2上で「無害なトランザクション」と偽って署名させ、その署名をメインネット側のコントラクトへ投下することで、不正な資産移動を引き起こす。
—
3. 堅牢なアーキテクチャ:OpenZeppelinを活用した安全なEIP-712実装
この脆弱性を完全に断ち切るためには、OpenZeppelinのEIP712ライブラリを継承し、ドメインセパレータに現在の実行コンテキスト(block.chainid と address(this))を動的にバインドさせる必要がある。
以下に、実務のプロダクション環境で使用すべきセキュアな実装を示す。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
// 【セキュアな実装例】EIP712を正しく継承し、リプレイ攻撃を防止するコントラクト
contract SecureBridge is EIP712, ReentrancyGuard {
using ECDSA for bytes32;
// タイプハッシュの定義
bytes32 public constant TRANSFER_TYPEHASH = keccak256(
"Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public nonces;
// 再生攻撃防止のための実行済み管理(ダイジェストベース)
mapping(bytes32 => bool) public executed;
event TransferExecuted(address indexed from, address indexed to, uint256 amount, uint256 nonce);
// コンストラクタで名前とバージョンを渡す。
// OpenZeppelinのEIP712内部で、block.chainidとaddress(this)を用いた動的なドメインセパレータが計算される。
constructor() EIP712("SecureBridge", "1") {}
function transferWithSignature(
address to,
uint256 amount,
uint256 nonce,
uint256 deadline,
bytes calldata signature
) external nonReentrant {
require(block.timestamp <= deadline, "Signature expired");
require(nonce == nonces[msg.sender], "Invalid nonce");
// 1. 構造化データのハッシュ化
bytes32 structHash = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
to,
amount,
nonce,
deadline
)
);
// 2. EIP-712に準拠したダイジェストの生成 (_hashTypedDataV4を使用)
// これにより、現在のchainIdとコントラクトアドレスが自動的にドメインセパレータに組み込まれる
bytes32 digest = _hashTypedDataV4(structHash);
// 3. 署名者の復元と検証
address signer = digest.recover(signature);
require(signer != address(0), "Invalid signature");
require(!executed[digest], "Signature already executed");
// 4. ステートの更新(Checks-Effects-Interactionsパターン)
executed[digest] = true;
nonces[msg.sender]++;
// 5. アセットの転送ロジック
// _safeTransfer(signer, to, amount);
emit TransferExecuted(signer, to, amount, nonce);
}
// 外部から現在のドメインセパレータを確認するためのヘルパー(フロントエンド連携用)
function getDomainSeparator() external view returns (bytes32) {
return _domainSeparatorV4();
}
}
—
4. フロントエンド・オフチェーン側の実装における注意点
スマートコントラクト側どれほど強固に組んでいても、オフチェーン(TypeScript / ethers.js v6 / viem 等)側の実装でミスがあれば、ユーザーはウォレット上で予期せぬデータに署名させられる。特に、ウォレットの拡張機能やDAppsのUI層における型定義の不一致は、インシデントの温床となる。
以下に、安全なEIP-712ペイロードの構築と署名依頼を行うTypeScriptのコード例を示す。
import { ethers } from "ethers";
// フロントエンド側での厳密な型定義とドメイン設定
async function signTransferData(
signer: ethers.Signer,
verifyingContractAddress: string,
chainId: number,
recipient: string,
amount: bigint,
nonce: bigint,
deadline: bigint
) {
// EIP-712 Domainの定義 (コントラクト側の設定と完全に一致させる必要がある)
const domain = {
name: "SecureBridge",
version: "1",
chainId: chainId, // 動的に取得した正しいチェーンIDを渡す
verifyingContract: verifyingContractAddress,
};
// 構造化データのタイプ定義
const types = {
Transfer: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
// 署名対象の値
const value = {
to: recipient,
amount: amount,
nonce: nonce,
deadline: deadline,
};
// ユーザーのウォレット(MetaMask等)にEIP-712形式で署名を要求
// これにより、ウォレットのUI上に構造化されたデータが人間が読める形式で表示される
const signature = await signer.signTypedData(domain, types, value);
return signature;
}
テックリードやセキュリティアーキテクトは、CI/CDパイプラインやユニットテストにおいて、「意図したchainId以外からの署名が確実にリジェクトされるか」「デプロイ先が異なる環境間で署名がクロスファイアしないか」をファジングテスト(Foundryのvm.sign等を使用)で必ず検証しなければならない。
—
結びにかえて
ブロックチェーンのセキュリティにおいて、「動かないこと」と「安全であること」は同義ではない。EIP-712は、適切に実装されていれば強力な盾となるが、ドメインセパレータの設計を一つ誤るだけで、それは攻撃者にとって最高の「マルチチェーン強奪ツール」へと変貌する。
泥臭いコードの隅々まで目を光らせ、コンテキストの剥離を見逃さないこと。それこそが、真のセキュリティアーキテクトに求められる姿勢である。
コメント