「あの署名、もう一度使われてない?」スマートコントラクトを守る、署名リプレイ攻撃とEIP-712の秘密
皆さん、こんにちは!サイバーセキュリティの世界は、まるで終わりのない宝探しのようなもの。日々、新しい攻撃手法が生まれ、それに対抗するための新しい防御策が生まれています。特に、IoTやOT(制御システム)といった、私たちの生活や産業を支える重要なインフラと、ブロックチェーン技術が融合する今、そのセキュリティはますます重要になっています。
今回は、そんなブロックチェーンセキュリティの中でも、特にスマートコントラクトに潜む「署名リプレイ攻撃」という、ちょっと厄介な攻撃とその巧妙な防御策である「EIP-712」について、皆さんと一緒にじっくり紐解いていきたいと思います。
「え、署名リプレイ攻撃?EIP-712?なんだか難しそう…」と思われた方も、ご安心ください!この記事では、まるで家の鍵や泥棒、身の回りの防犯の仕組みに例えながら、攻撃のメカニズムから防御策の意味まで、できるだけ優しく、丁寧に解説していきます。IT初心者の方や、セキュリティに初めて触れる開発者の方にも「なるほど!」と思っていただけるよう、一歩ずつ学んでいきましょう。
そもそも、なぜ「署名」が重要なのか?
まず、スマートコントラクトの世界で「署名」がなぜそれほど重要なのか、考えてみましょう。
ブロックチェーン上のトランザクションは、誰が、いつ、どのような操作を行ったのかを記録する、まさに「デジタルな証拠」です。そして、そのトランザクションが本当に本人の意思で送信されたものであることを証明するのが、「デジタル署名」なんです。
これは、現実世界で私たちが「印鑑」や「サイン」を使うのと似ています。契約書に印鑑を押せば、「この内容に同意した」という証になりますよね。デジタル署名も、それと同じような役割を果たします。秘密鍵で署名し、公開鍵で検証することで、そのトランザクションが正当な送信者によって承認されたものであることを確認できるんです。
泥棒は、あなたの「印鑑」を狙う?署名リプレイ攻撃の恐ろしさ
さて、ここで現実世界に目を向けてみましょう。もし、あなたが大切な契約書に押した「印鑑」を、悪意のある泥棒が盗んでしまったらどうなるでしょうか?
泥棒は、その印鑑を使って、あなたの名前で偽の契約書を作成し、あたかもあなたが同意したかのように見せかけることができます。これが、現実世界での「印鑑の悪用」ですよね。
デジタル署名の世界でも、これと似たような恐ろしい攻撃が存在します。それが、今回ご紹介する「署名リプレイ攻撃(Signature Replay)」です。
署名リプレイ攻撃のメカニズムを覗いてみよう
署名リプレイ攻撃は、攻撃者が正当なユーザーから取得した「有効な署名」を、後から別のトランザクションで「再利用(リプレイ)」することで、不正な操作を可能にする攻撃です。
具体的に、どのような流れで攻撃が行われるのか、例を挙げてみましょう。
1. 正規のユーザーがトランザクションに署名する:
例えば、あるゲームで「アイテムAを交換する」というトランザクションに、プレイヤーXが秘密鍵で署名したとします。この署名は、そのトランザクションとプレイヤーXの公開鍵によって正当なものであると検証できます。
2. 攻撃者がその署名を「盗む」:
何らかの方法で、攻撃者はプレイヤーXの「アイテムAを交換する」というトランザクションとその署名を傍受・取得します。
3. 署名を「再利用」して別のトランザクションを実行する:
ここで攻撃者が巧妙な手を使います。例えば、本来「アイテムBと交換する」という別のトランザクションを作成し、そこに先ほど盗んだ「アイテムAを交換する」トランザクションの署名をそのまま付けて、スマートコントラクトに送信します。
この時、もしスマートコントラクトが、送信されてきた署名が「正当なものであるか」だけをチェックし、「この署名は過去に一度使われたものではないか」というチェックを怠っていた場合、どうなるでしょうか?
スマートコントラクトは、署名自体は有効であると判断してしまい、攻撃者の不正な「アイテムBと交換する」というトランザクションを、まるでプレイヤーXが承認したかのように実行してしまう可能性があるのです!
これは、まさに泥棒があなたの印鑑を盗んで、別の契約書に勝手に押印するのと同じくらい危険な状況ですよね。
「 nonce 」という、秘密の回数券でリプレイを防ぐ!
さて、このような恐ろしい署名リプレイ攻撃から、私たちのスマートコントラクトをどうやって守れば良いのでしょうか?
ここで登場するのが、「nonce(ノンセ)」という、ちょっとした工夫です。
Nonceとは、「Number used once」の略で、その名の通り「一度しか使われない数」という意味です。
現実世界で例えるなら、これは「秘密の回数券」のようなものです。
例えば、あなたが毎回、お店で「1枚目の回数券」を使って商品を受け取ったとします。お店側は、「この回数券はもう使われたから、次からは2枚目の回数券を持ってきてくださいね」と記録しておきます。もし泥棒が、あなたから1枚目の回数券を盗んだとしても、お店側は「この回数券は既に使われていますよ」と断ることができるのです。
スマートコントラクトでも、この nonce を活用します。
- トランザクションごとにユニークな nonce を割り振る:
ユーザーがトランザクションを送信する際に、そのトランザクションにユニークな番号(nonce)を付けます。例えば、最初のトランザクションには nonce: 0、次のトランザクションには nonce: 1、といった具合です。
- スマートコントラクト側で nonce を管理・検証する:
スマートコントラクトは、受け取ったトランザクションの nonce を記録しておきます。そして、新しいトランザクションが送られてきたら、「この nonce は、私が記録している次に来るべき nonce と一致するか?」「既に使われた nonce ではないか?」をチェックします。
- 一致しない nonce は無効とする:
もし、攻撃者が過去のトランザクションの署名を再利用しようとしても、その署名に付随する nonce は、既に使われた古い番号か、あるいは次に期待される番号ではないため、スマートコントラクトはそれを無効と判断し、実行を拒否します。
このように、nonce を使うことで、たとえ署名が正当であったとしても、それが「過去に一度使われたもの」であれば、リプレイ攻撃を防ぐことができるんです。これは、まさに「二度同じ回数券は使えない!」という、確実な防犯策と言えるでしょう。
でも、nonce だけでは完璧じゃない?EIP-712で「ドメイン分離」という究極の防御を!
nonce は強力な防御策ですが、それでもまだ、攻撃者が巧妙に nonce を操作してくる可能性がゼロではありません。例えば、攻撃者がユーザーの nonce の値を推測したり、不正に操作したりするようなシナリオが考えられます。
そこで、さらに一歩進んだ、より堅牢な防御策として登場するのが、「EIP-712」です。
EIP-712とは?「ドメイン分離」の魔法
EIP-712は、「Ethereum Improvement Proposal 712」の略で、オフチェーン署名(ブラウザなどのウォレットで生成される署名)をより安全にするための標準規格です。
EIP-712の最大の特徴は、「ドメイン分離(Domain Separation)」という考え方を取り入れている点です。
これは、まるで「家の鍵」と「車の鍵」を別々にするようなものです。
- 家の鍵: 家のドアを開けるためだけの鍵ですよね。車のドアを開けるのには使えません。
- 車の鍵: 車のドアを開けるための鍵です。家のドアを開けるのには使えません。
このように、それぞれの目的に合わせた「鍵」を用意することで、もし片方の鍵が盗まれたとしても、もう一方の鍵で守られているものは安全である、という考え方です。
EIP-712では、この「ドメイン分離」の考え方を、スマートコントラクトのトランザクション署名に適用します。
EIP-712によるドメイン分離の仕組み
EIP-712では、署名するデータ構造を、以下のように分割して扱います。
1. ドメインセパレーター (Domain Separator):
これは、署名するデータが、どの「ドメイン(=どのブロックチェーン、どのアプリケーション、どのバージョン)」に属しているのかを示す、ユニークな識別子です。
具体的には、name(アプリケーション名)、version(バージョン)、chainId(ブロックチェーンID)、verifyingContract(スマートコントラクトアドレス)などの情報から生成されます。
このドメインセパレーターをデータに含めることで、たとえ同じ内容のトランザクションであっても、異なるドメインに属していれば、署名は無効になります。
2. 構造化されたデータ (Structured Data):
署名したい実際のデータ(例えば、送金先アドレス、金額、nonce など)を、明確な型定義(string, uint256, address など)を持つ構造体として定義します。
これにより、データがどのように解釈されるべきかが明確になり、攻撃者がデータを誤解釈させたり、不正に操作したりすることを防ぎます。
EIP-712では、このドメインセパレーターと構造化されたデータを連結し、それをハッシュ化したものに対して署名を行います。
EIP-712がリプレイ攻撃をどう防ぐか?
EIP-712によるドメイン分離は、署名リプレイ攻撃に対して非常に強力な防御となります。
なぜなら、攻撃者が過去の有効な署名を盗んだとしても、その署名は、特定のドメイン(特定のアプリケーション、特定のチェーン)でのみ有効だからです。
もし攻撃者が、その署名を別のドメイン(例えば、別のブロックチェーンや、古いバージョンのアプリケーション)で再利用しようとしても、EIP-712のドメインセパレーターによる検証で「この署名は、このドメインでは有効ではありません」と判断され、トランザクションは実行されません。
これは、まるで「家の鍵」を「車のドア」に無理やり使おうとしても、全く合わないから開けられない、という状況と同じですよね。
さらに、EIP-712はnonceの検証も同時に行うことができます。つまり、nonceによる「一度しか使えない」という制約と、EIP-712による「このドメインでしか使えない」という制約の、二重の防御を同時に実現できるのです。
実践!EIP-712を使った署名の例(JavaScript)
では、実際にEIP-712を使って署名を行うには、どのようなコードになるのでしょうか?
ここでは、JavaScriptの例を見てみましょう。多くの場合、Web3ライブラリ(例:ethers.js や web3.js)がEIP-712の署名をサポートしています。
// 必要なライブラリをインポート(例: ethers.js)
import { ethers } from 'ethers';
// 1. ドメインセパレーターの定義
const domain = {
name: 'MyAwesomeDApp', // アプリケーション名
version: '1', // アプリケーションのバージョン
chainId: 1, // Ethereum Mainnet の Chain ID (テストネットの場合は異なる)
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCc333333333333', // スマートコントラクトのアドレス
};
// 2. 署名したい構造化データの定義
const message = {
from: '0x3333333333333333333333333333333333333333', // 送信元アドレス
to: '0x4444444444444444444444444444444444444444', // 送信先アドレス
amount: ethers.utils.parseEther('1.0'), // 送金額 (1 Ether)
nonce: 1, // nonce (一度しか使われない数)
};
// 3. 構造化データの型定義
const types = {
// EIP-712の構造体名(通常は "EIP712Domain" と署名したいメッセージの型名)
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
// 署名したいメッセージの型名と、各フィールドの型
MyMessage: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" },
],
};
// 4. 署名の生成
// ユーザーのウォレット(MetaMaskなど)に署名を要求します。
// この部分が、ユーザーの秘密鍵を使って署名を生成する部分です。
try {
// metamaskなどのウォレットプロバイダーを取得
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
// signTypedDataAsync は、EIP-712形式のデータを署名するためのメソッドです。
// ユーザーに署名リクエストが表示され、承認された場合に署名が返されます。
const signature = await signer.signTypedData(domain, types, message);
console.log('生成された署名:', signature);
// この署名を、nonceやメッセージデータと一緒にスマートコントラクトに送信します。
// スマートコントラクト側では、この署名と元のデータ、ドメイン情報を使って検証を行います。
} catch (error) {
console.error('署名中にエラーが発生しました:', error);
}
スマートコントラクト側での検証例(Solidity)
次に、スマートコントラクト側で、このEIP-712署名をどのように検証するのか、簡単な例を見てみましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // OpenZeppelinのECDSAライブラリを使用
contract MyContract {
// 過去に使用された nonce を記録するためのマッピング
mapping(address => uint256) public nonces;
// EIP-712ドメインセパレーターを生成するための関数
// (name, version, chainId, verifyingContract は、フロントエンドで定義したものと一致させる必要があります)
function _EIP712DomainSeparator() internal pure returns (bytes32) {
return keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyAwesomeDApp"), // name
keccak256("1"), // version
block.chainid, // chainId (実行時のチェーンID)
address(this) // verifyingContract (このコントラクトのアドレス)
)
);
}
// EIP-712形式のメッセージのハッシュを生成する関数
function _hashMessage(
address from,
address to,
uint256 amount,
uint256 nonce
) internal pure returns (bytes32) {
// 構造化データの型定義に基づいて、ハッシュを生成
return keccak256(
abi.encode(
keccak256("MyMessage(address from,address to,uint256 amount,uint256 nonce)"),
from,
to,
amount,
nonce
)
);
}
// 署名を検証し、トランザクションを実行する関数
function processTransaction(
address from,
address to,
uint256 amount,
uint256 nonce,
bytes memory signature // フロントエンドから送信された署名
) public {
// 1. nonce の検証
// ユーザーの現在の nonce が、送信されてきた nonce と一致するか確認します。
// 一致しない場合は、リプレイ攻撃の可能性があります。
require(nonce == nonces[from], "Invalid nonce");
// 2. EIP-712 ドメインセパレーターの取得
bytes32 domainSeparator = _EIP712DomainSeparator();
// 3. 署名したいメッセージのハッシュの取得
bytes32 messageHash = _hashMessage(from, to, amount, nonce);
// 4. EIP-712 形式のメッセージの完全なハッシュを生成
// (ドメインセパレーターとメッセージハッシュを連結してハッシュ化)
bytes32 structHash = keccak256(
abi.encodePacked("\x19\x01", domainSeparator, messageHash)
);
// 5. 署名の検証
// ECDSA.recover() 関数は、メッセージハッシュと署名から、署名したアドレスを復元します。
// 復元されたアドレスが、トランザクションの送信元アドレス (from) と一致するか確認します。
address recoveredAddress = ECDSA.recover(structHash, signature);
require(recoveredAddress == from, "Invalid signature");
// 署名と nonce の検証が成功したら、nonce をインクリメントして、次のトランザクションに備えます。
nonces[from]++;
// ここで、本来実行したい処理(例: トークンの送金など)を行います。
// ... トランザクション実行処理 ...
// 例: tokens.transfer(to, amount);
// 処理が成功したことを示すイベントを発行するなど
emit TransactionProcessed(from, to, amount, nonce);
}
event TransactionProcessed(address indexed from, address indexed to, uint256 amount, uint256 nonce);
}
コードのポイント:
ECDSA.recover(): OpenZeppelin のライブラリに含まれるこの関数が、署名から元の署名者のアドレスを復元する、まさに「魔法の杖」のような役割を果たします。_EIP712DomainSeparator(): フロントエンドで定義したドメイン情報と一致させることで、正しいチェーン、正しいコントラクトに対する署名であることを確認します。_hashMessage(): 署名したいデータ構造のハッシュを生成します。"\x19\x01": これは、EIP-712で定義されている、リプレゼンテーションプレフィックスと呼ばれるものです。これを含めることで、他のハッシュ計算と区別し、ドメイン分離を確実に行います。nonces[from]++: トランザクションが成功したら、必ず nonce をインクリメントすることが重要です。これにより、同じ nonce を持つトランザクションが再度実行されるのを防ぎます。
まとめ: nonce と EIP-712 で、スマートコントラクトの扉を堅牢に守ろう!
さて、今回は署名リプレイ攻撃のメカニズムと、それを防ぐための nonce、そしてより高度な防御策である EIP-712 について、じっくりと見てきました。
- 署名リプレイ攻撃: 過去の有効な署名を盗み、別のトランザクションで再利用して不正を行う攻撃。泥棒が印鑑を盗んで偽の契約書を作るようなものです。
- nonce: トランザクションごとにユニークな番号を割り振り、一度しか使えないようにする仕組み。秘密の回数券のようなものです。
- EIP-712: アプリケーションやチェーンごとに署名を分離し、さらに安全性を高める規格。家の鍵と車の鍵を分けるような「ドメイン分離」の考え方です。
nonce と EIP-712 を組み合わせることで、スマートコントラクトは署名リプレイ攻撃に対して、非常に堅牢な防御を構築できます。
もちろん、セキュリティの世界は常に進化しており、新たな脅威や対策が登場します。ですが、今回学んだ nonce と EIP-712 のような基本的な概念をしっかりと理解しておくことは、スマートコントラクト開発者やIT担当者にとって、非常に強力な武器となるはずです。
「ちょっと難しかったかな?」と思った方も、大丈夫です!大切なのは、一つ一つの技術を、身近な例えで理解しようとすること。そして、実際にコードを書いて動かしてみることです。
これからも、皆さんと一緒に、サイバーセキュリティの奥深い世界を、一歩ずつ、楽しく学んでいけたら嬉しいです。
それでは、また次回のブログでお会いしましょう!
コメント