署名は「白紙の小切手」ではない:EIP-712の罠とフロントエンドで殺すフィッシング攻撃
現場のエンジニア諸君、お疲れ様。今日もどこかのWeb3プロジェクトで「メタマスクの署名リクエストがなぜか意図しないコントラクトを実行した」という悲鳴が上がっていないことを祈るよ。
SCADA環境のPLC制御コードを解析する時と同じだ。Web3における eth_sign や personal_sign の乱用は、セキュリティ上の致命的な「バイパス」そのものだ。今日は、EIP-712を正しく実装し、フロントエンドから悪意ある署名リクエストを排除する「防御の要」について話そう。
なぜ「生の署名」は地雷なのか
かつてのWeb3開発者は、適当な文字列を personal_sign で署名させていた。これがどれほど危険か分かるか?ユーザーは「何に署名しているか」を具体的に認識できない。UIに表示されるのは16進数の羅列だけだ。
攻撃者はここに付け込む。0x4769766520... のような難読化されたデータに、実は「NFTの全所有権を攻撃者に移転する関数呼び出し」を混ぜ込める。これが「Blind Signing(盲目的な署名)」の恐怖だ。
EIP-712:構造化データ署名の真実
EIP-712は、署名対象のデータを「ドメイン定義」と「構造体」として定義する規格だ。これにより、メタマスク等のウォレットは「誰が、どのコントラクトで、何をしようとしているか」を人間に読める形式で表示できる。
これが実装されていないdAppは、「セキュリティホールを放置している」と公言しているのと同じだ。
1. セキュアなフロントエンド実装(JavaScript)
フロントエンドで署名を要求する際は、必ず厳格なスキーマを定義すること。以下は、EIP-712準拠の署名リクエストを行う実装例だ。
// フロントエンドでのセキュアな署名リクエスト例
async function requestSecureSignature(provider, account) {
const domain = {
name: 'MySecureApp', // ドメイン名
version: '1',
chainId: 1, // チェーンIDを固定し、フィッシングサイトでのリプレイを防止
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' // 正当なコントラクトアドレス
};
const types = {
TransferData: [
{ name: 'from', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'uint256' } // リプレイ攻撃を防ぐためのナンス
]
};
const value = {
from: account,
amount: 100,
nonce: 1 // サーバー側で管理した最新のナンスを使用すること
};
const data = JSON.stringify({ domain, types, message: value, primaryType: 'TransferData' });
// eth_signTypedData_v4 を必ず使用する(それ以外は使うな)
const signature = await provider.request({
method: 'eth_signTypedData_v4',
params: [account, data],
});
return signature;
}
バックエンドでの検証(Pythonによる実装)
フロントエンドがどれほど頑張っても、バックエンドが甘ければ即終了だ。eth_account ライブラリを使用して、署名が正しいか、そして「リプレイ攻撃」が行われていないかを厳格にチェックする。
from eth_account.messages import encode_structured_data
from eth_account import Account
def verify_signature(signature, message_data, public_address):
# EIP-712の構造化データメッセージをエンコード
msg = encode_structured_data(message_data)
# 署名から署名者のアドレスを復元
recovered_address = Account.recover_message(msg, signature=signature)
# アドレスの一致と、ナンスの正当性を検証
if recovered_address.lower() == public_address.lower():
# ここでDBのナンスと比較(リプレイ攻撃対策)
return True
return False
現場で生き残るための「鉄の掟」
1. eth_sign は禁止: レガシーな署名メソッドは絶対に使うな。あれはハッカーへの招待状だ。
2. UI/UXでの警告: ユーザーが署名ボタンを押す前に、署名内容の要約(「あなたは〇〇に、△△を送信しようとしています」)をモーダルで明示せよ。
3. ドメインの固定: chainId と verifyingContract をハードコードすることで、フィッシングサイトが正規の署名を横流ししようとしても、署名データそのものが機能しなくなる。
4. WAFでの保護: フロントエンドの script インジェクションを防ぐため、Content-Security-Policy (CSP) で外部スクリプトの読み込みを厳格に制限しろ。
NginxでのCSP設定例
# 悪意あるスクリプトの実行を阻止するヘッダー
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://trusted-cdn.com; connect-src 'self' https://api.my-dapp.com;";
まとめ:セキュリティは「性悪説」で設計せよ
フロントエンドは常に「ハッカーが改ざん済み」という前提で動くべきだ。署名はただの認証ではない。それは、ユーザーの資産を動かすための「契約」だ。
「面倒だから」という理由で実装を簡略化すれば、それがそのまま貴社の名前を冠したインシデントニュースとしてネットの海に流れることになる。今日紹介したコードは、今すぐ君のプロジェクトのメインブランチにマージしてくれ。
もし実装で詰まったら、またいつでも聞いてくれ。現場からは以上だ。
コメント