Appearance
EllipticToken CTF 問題解析:ECDSA ドメイン混同脆弱性と署名偽造攻撃
背景紹介
ブロックチェーンセキュリティの分野において、楕円曲線デジタル署名アルゴリズム(ECDSA)は、トランザクションやメッセージの完全性を保護する中核的な技術です。しかし、開発者が「最適化」や独自改変を試みると、深刻なセキュリティ脆弱性を招くことがあります。本稿で分析する CTF 問題「Elliptic Token」は、その典型的な例を示しています。
EllipticToken($ETK)は ERC20 標準に基づくトークンコントラクトであり、楕円曲線署名を利用した2つの機能を実装しています:
- バウチャー引き換えシステム:コントラクト所有者 Bob がオフチェーンでバウチャーを発行し、ユーザーがオンチェーンで $ETK を受け取る
- パーミット機能:署名ベースの承認機構(ERC20 の permit に類似)
コントラクト構造の分析
コアデータ構造
solidity
contract EllipticToken is Ownable, ERC20 {
mapping(bytes32 => bool) public usedHashes;
// エラー定義
error HashAlreadyUsed();
error InvalidOwner();
error InvalidReceiver();
error InvalidSpender();
}
コントラクトは usedHashes マッピングを用いてリプレイ攻撃を防止し、各ハッシュが一度しか使われないようにしています。これは ECDSA ベースのシステムにおける一般的な対策です。
バウチャー引き換え機構
solidity
function redeemVoucher(
uint256 amount,
address receiver,
bytes32 salt,
bytes memory ownerSignature,
bytes memory receiverSignature
) external {
bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
require(!usedHashes[voucherHash], HashAlreadyUsed());
// 所有者の署名検証
require(ECDSA.recover(voucherHash, ownerSignature) == owner(), InvalidOwner());
// 受取人の署名検証
require(ECDSA.recover(voucherHash, receiverSignature) == receiver, InvalidReceiver());
usedHashes[voucherHash] = true;
_mint(receiver, amount);
}
この関数は比較的安全です。理由は以下の通り:
- パラメータ全体を用いてハッシュを生成している
- 2つの署名(所有者と受取人)を検証している
- ハッシュを使用済みとして記録している
問題箇所:permit 関数
solidity
function permit(uint256 amount, address spender, bytes memory tokenOwnerSignature, bytes memory spenderSignature)
external
{
bytes32 permitHash = keccak256(abi.encode(amount));
require(!usedHashes[permitHash], HashAlreadyUsed());
require(!usedHashes[bytes32(amount)], HashAlreadyUsed());
// 重大な脆弱性
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());
usedHashes[permitHash] = true;
_approve(tokenOwner, spender, amount);
}
脆弱性分析:ECDSA ドメイン混同攻撃
ECDSA 署名検証の基本
ECDSA 検証には以下の3要素が必要です:
- メッセージハッシュ
- 署名(r, s, v)
- 復元される公開鍵(アドレス)
OpenZeppelin の ECDSA.recover は「ハッシュ化済みのメッセージ」を前提としています。
脆弱性の本質
solidity
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
ここでは amount(uint256)をそのまま bytes32 に変換し、「ハッシュ」として扱っています。しかし実際にはこれは単なる整数であり、正規のハッシュではありません。
攻撃ベクトル
攻撃者は次のように行動できます:
bytes32(amount)が既存の有効な署名のメッセージハッシュと一致するような値を見つける- 被害者(例:Alice)の既存署名を利用
- それを「amount に対する署名」として再利用
数学的背景
ECDSA の署名生成:
- r = (k × G).x mod n
- s = k⁻¹ × (h + r × d) mod n
ここで攻撃者が h(=bytes32(amount))を制御できるため:
- 既知の署名 (r, s, v) を取得
- 対応するハッシュ h' を特定
- amount = uint256(h') に設定
- 同じ署名で検証を通過
攻撃手順
ステップ1:Alice のトランザクション分析
以下を調査:
redeemVoucher呼び出し- その他署名付きトランザクション
ステップ2:署名の抽出
solidity
bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
Alice の署名はこのハッシュに対するもの。
ステップ3:攻撃パラメータ構築
solidity
bytes32 Ar = 0x6690...;
bytes32 As = 0x3e38...;
uint8 Av = 27;
amount = uint256(0xfd88...);
ALICE_new_sig = abi.encodePacked(Ar, As, Av);
ステップ4:攻撃実行
solidity
EllipticToken(ELLIPTICTOKEN_INST).permit(amount, player, ALICE_new_sig, sig);
EllipticToken(ELLIPTICTOKEN_INST).transferFrom(
ALICE,
player,
EllipticToken(ELLIPTICTOKEN_INST).balanceOf(ALICE)
);
修正方法
修正案1:正しいハッシュ使用
solidity
bytes32 permitHash = keccak256(abi.encodePacked(
"PERMIT",
amount,
spender,
block.chainid,
address(this)
));
修正案2:EIP-712 の導入
solidity
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
domainSeparator,
structHash
)
);
セキュリティ対策
- 生データに直接署名しない
- EIP-712 を使用する
- nonce によるリプレイ防止
- セキュリティ監査の実施
- 信頼済みライブラリの利用
まとめ
EllipticToken の脆弱性は、ECDSA における「ドメイン混同」の危険性を示しています。bytes32(amount) をハッシュとして扱う誤りにより、既存署名の再利用が可能となり、不正な承認が成立してしまいます。
この問題は、暗号実装において標準を遵守する重要性を強く示しています。
覚えておくべき教訓:楕円曲線はドメイン混同を許さない。