EIP-712の落とし穴:なぜ「なんとなく署名」が資産を溶かすのか
現場で数多のスマートコントラクト監査を行っていると、必ずと言っていいほど遭遇するのが「オフチェーン署名の設計ミス」だ。
「メタトランザクション(ガス代を肩代わりする仕組み)」や「オフチェーンでの承認プロセス」を実装する際、開発者はよく eth_sign を使いがちだ。だが、あれは悪夢の入り口だ。署名データが生の文字列(Hex)として投げられると、ユーザーは自分が「何に」署名しているのかすら理解できない。これを突くのがフィッシング攻撃の常套手段だ。
そこで登場するのが EIP-712 である。これはデータを型定義し、人間に読める形(Domain Separator)で提示するための標準仕様だ。今回は、この実装でエンジニアが陥りがちな罠と、現場で通用する「コピペ可能なセキュア実装」を伝授する。
—
1. 攻撃者が狙う盲点:「ドメインセパレータの使い回し」
EIP-712における最大の脆弱性は、DOMAIN_SEPARATOR の不適切な管理にある。多くのプロジェクトが、本番環境とテスト環境、あるいは複数のコントラクト間で同じドメインセパレータを使い回している。
攻撃シナリオ:
攻撃者は、あなたが別のプロジェクトで使っている署名要求をキャプチャし、それを全く別のコントラクトの permit 関数や execute 関数に流し込む(リプレイ攻撃)。もし chainId や verifyingContract が検証ロジックに含まれていない、あるいは固定値であれば、その署名はどこでも通用する「万能鍵」と化す。
—
2. セキュアな実装:JavaScript (Frontend) 側
まずはフロントエンドでの署名生成コードだ。ethers.js v6を使用し、型定義を厳格に行う。
// EIP-712の型定義と署名処理
const domain = {
name: 'MySecureProtocol',
version: '1',
chainId: 1, // メインネットのみに限定
verifyingContract: '0xYourContractAddressHere', // コントラクトアドレスをハードコード
};
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const message = {
owner: userAddress,
spender: spenderAddress,
value: 1000,
nonce: currentNonce, // 必ずコントラクトから取得した最新のnonceを使うこと
deadline: Math.floor(Date.now() / 1000) + 3600, // 有効期限は必須
};
// 署名実行(MetaMask等のウォレットに型付きデータを表示させる)
const signature = await signer.signTypedData(domain, types, message);
ポイント: nonce と deadline を忘れるな。これが欠けていると、署名データが流出した瞬間に資産が無限に引き抜かれる。
—
3. セキュアな実装:Solidity (Contract) 側
コントラクト側で検証を怠れば、全ては水の泡だ。OpenZeppelinの EIP712 ライブラリを使うのが最も堅牢だ。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SecureVault is EIP712 {
// コンストラクタでドメイン名を定義
constructor() EIP712("MySecureProtocol", "1") {}
function execute(address owner, bytes32 structHash, bytes memory signature) public view {
// _hashTypedDataV4 はEIP-712の仕様に基づき、Domain Separatorを自動で付与する
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, signature);
require(signer == owner, "Invalid signature");
// ここで処理を実行
}
}
—
4. 現場の教訓:インフラ側の防衛線
コードがどれほど完璧でも、バックエンドのAPIが脆弱であれば意味がない。オフチェーン署名を扱うAPIサーバーは、以下の設定を徹底せよ。
Nginxの設定(レートリミットによるDoS対策):
# 署名検証エンドポイントへの過剰なリクエストを遮断
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
location /api/v1/verify-signature {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://backend_app;
}
IAM/クラウド環境のTips:
- 署名検証キーの管理: 署名検証を行うバックエンドサーバーには、
Secrets ManagerやKMSを活用せよ。環境変数に秘密鍵をベタ書きするのは、玄関に鍵を置いて出かけるのと同じだ。 - 監査ログ: 署名の
nonceは必ずDBに記録し、一度使われたnonceが再利用(リプレイ)されないよう、DB側でユニーク制約をかけておくこと。
—
最後に:セキュリティは「疑うこと」から始まる
EIP-712は非常に強力だが、開発者が「仕様の意図」を理解していないと、ただの複雑な手続きになってしまう。
「なぜこの chainId を入れているのか?」「なぜ deadline が必要なのか?」――コードを書くたびに自分に問いかけてほしい。
セキュリティリサーチャーとしての経験則だが、最も危険なのは「ライブラリがやってくれるから大丈夫」という慢心だ。常に Domain Separator の中身を検証し、リプレイ攻撃の余地を徹底的に潰す。それが、我々エンジニアが資産を守るための唯一の道だ。
現場からは以上だ。また次の脆弱性報告で会おう。
コメント