【実務・中級編】 EIP-2612 (Permit) における署名検証の脆弱性とフロントランニング – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

現場で戦うエンジニア諸君。今日は「便利さ」の裏に潜む「死の罠」について話をしよう。

EIP-2612、いわゆる permit 関数だ。ユーザーにガス代を支払わせることなく、オフチェーンでの署名だけでトークンの承認(Approve)を完結させる――UXの観点からは魔法のような機能だが、実装を少しでも誤れば、それは攻撃者への招待状に変わる。

特に「署名の再利用」と「Nonceの管理」は、単なるバグではない。資産が湯水のように流出するトリガーだ。今日は、理論的な話はそこそこに、現場で即座に使える防衛ラインを共有する。

—

1. なぜ「Permit」が狙われるのか?

Permitの実装において、攻撃者が狙うのは主に以下の2点だ。

1. 署名の再現性(Replay Attack): 同じ署名を複数回送ることで、意図しない承認を繰り返させる。
2. フロントランニングによるNonceの無効化: ユーザーが送出した署名を攻撃者が先回りしてコントラクトに送信し、Nonceをインクリメントさせることで、ユーザー本人のトランザクションを失敗させる(DoS攻撃)。

これらは「なんとなくOpenZeppelinをコピペした」だけでは見抜けない。コントラクトの内部構造と、署名検証のロジックが完全に一致していないと、悪魔は入り込む。

—

2. 実践:セキュアな署名検証の実装(JavaScript/Ethers.js)

バックエンド側で署名を生成する際、あるいはDAppフロントエンドから送信する際、最も重要なのは「Domain Separator」と「Nonce」の正確な管理だ。

以下は、ethers.js を用いた、署名再利用を防ぐための堅牢な署名生成ロジックのサンプルだ。

/**
 * セキュアなPermit署名生成の雛形
 * @param {ethers.Wallet} wallet - 署名者のウォレット
 * @param {string} tokenAddress - トークンコントラクトのアドレス
 * @param {string} spender - 承認先のアドレス
 * @param {ethers.BigNumber} value - 承認額
 * @param {number} nonce - 現在のコントラクト上のnonce
 */
async function generateSecurePermit(wallet, tokenAddress, spender, value, nonce) {
  const deadline = Math.floor(Date.now() / 1000) + 3600; // 1時間の有効期限

  // EIP-712のドメイン定義(チェーンIDのハードコーディングは厳禁!)
  const domain = {
    name: "MySecureToken",
    version: "1",
    chainId: await wallet.getChainId(),
    verifyingContract: tokenAddress,
  };

  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: wallet.address,
    spender: spender,
    value: value,
    nonce: nonce,
    deadline: deadline,
  };

  // 署名生成
  const signature = await wallet._signTypedData(domain, types, message);
  
  // 署名を分解(v, r, s)して返却
  return ethers.utils.splitSignature(signature);
}

ここでのポイント:

  • deadline を短く設定すること。1時間以上持たせる必要などほとんどない。
  • chainId は実行環境から動的に取得すること。テストネットの署名をメインネットで使われるリスクをこれで遮断する。

—

3. 防御の要:コントラクト側のガードレール

コントラクト側で最も恐ろしいのは、nonce の更新タイミングだ。必ず「状態更新→アクション実行」の順序を守らなければならない。

// SolidityによるセキュアなPermit実装の抜粋
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    require(block.timestamp <= deadline, "Permit: expired");

    // Nonceを先にインクリメントしてReplayを物理的に不可能にする
    uint256 currentNonce = _nonces[owner]++;
    
    bytes32 structHash = keccak256(
        abi.encode(_PERMIT_TYPEHASH, owner, spender, value, currentNonce, deadline)
    );
    
    // ... 以下、ECDSAによる署名検証プロセス ...
}

なぜこの実装なのか:
_nonces[owner]++ を検証ロジックの「前」に行うことで、たとえ攻撃者が署名をキャプチャして先出ししようとしても、Nonceが既に消費されているため、その後の検証で必ず revert する。この順序が逆転している実装が非常に多い。注意深く見ろ。

—

4. 運用上の盲点:インフラからの防御

Web3のアプリ開発者であっても、WAFやクラウドIAMの管理を怠ってはならない。

  • 署名生成APIの保護: もしバックエンドで署名を生成しているなら、そのエンドポイントにはIPレートリミットをかけろ。
  • Nginxの設定: 署名用APIへのPOSTリクエストに対して、リクエストボディのサイズ制限(client_body_buffer_size)を厳格にし、DDoSによるNonce枯渇攻撃を防ぐ。
# Nginx設定例: 署名リクエストの保護
location /api/v1/permit/sign {
    limit_req zone=one burst=5 nodelay;
    client_max_body_size 1k; # 必要最小限に絞る
    proxy_pass http://backend_cluster;
}

—

最後に:エンジニアとしての矜持

「ライブラリが安全だから大丈夫」という思考停止こそが、最大の脆弱性だ。
Permitの署名検証は、暗号学的な正しさと、コントラクトのステートマシンとしての正しさが組み合わさって初めて成立する。

何かを実装する際は、常に「自分が攻撃者だったら、どのタイミングでこの処理を割り込ませるか?」を自問自答してほしい。その視点があれば、不意のインシデントに震える夜はなくなるはずだ。

コードは嘘をつかない。だが、書いた人間の油断はすべて見抜く。精進しろ。

コメント

タイトルとURLをコピーしました