Coboセキュリティチーム:ETHのハードフォークに潜むリスクと裁定取引のチャンス
TechFlow厳選深潮セレクト
Coboセキュリティチーム:ETHのハードフォークに潜むリスクと裁定取引のチャンス
本稿はこれらの2つの事例を用いて、リプレイ攻撃がフォークチェーンに与える影響と、プロトコルがこのような攻撃をどのように防ぐべきかについて分析する。
本稿はCoboのブロックチェーンセキュリティチームによる寄稿です。チームメンバーは著名なブロックチェーンセキュリティ企業出身で、多数のDeFiプロジェクトにおいて高リスクな脆弱性を発見した経験を持つ、豊富なスマートコントラクト監査の専門家から構成されています。現在、チームは主にスマートコントラクトのセキュリティおよびDeFiセキュリティに注力し、最先端のブロックチェーンセキュリティ技術の研究と情報共有を行っています。
暗号資産分野に対して探究心を持ち、科学的なアプローチで継続的に学び続ける方々にも、私たちの仲間として業界にインサイトや研究結果を発信していただきたいと考えています!
本記事はCobo Globalの第16回目となる投稿です。
はじめに
ETHがPoS合意アルゴリズムへアップグレードしたことに伴い、一部のコミュニティの支持を受け、元のPoW方式のETHチェーンはハードフォークに成功しました(以下ではETHWと略称)。しかし、いくつかのオンチェーンプロトコルが当初設計段階でこのようなハードフォークへの備えをしていなかったため、対応するプロトコルはETHWフォークチェーン上で一定のセキュリティリスクを抱えることになり、特に深刻な問題がリプレイ攻撃です。
ハードフォーク完了後、ETHWメインネット上では少なくとも2件のリプレイ攻撃が確認されており、それぞれOmniBridgeおよびPolygon Bridgeに対するものです。本稿ではこれら二つの事例を取り上げ、フォークチェーンにおけるリプレイ攻撃の影響と、プロトコル側がどのようにこうした攻撃を防ぐべきかについて分析します。
リプレイ攻撃の種類
まず、分析に入る前に、リプレイ攻撃の種類について基本的な理解をしておきましょう。一般的に、リプレイ攻撃は「取引リプレイ」と「署名メッセージリプレイ」の2種類に分けられます。以下に、この2種類の違いについて説明します。
取引リプレイ
取引リプレイとは、元のチェーン上の取引をそのまま目標チェーンに転送する操作であり、取引レベルでのリプレイに該当します。リプレイされた取引は正常に実行され、検証も可能になります。最も有名なケースはWintermuteがOptimism上で受けた攻撃事件で、これにより2000万以上のOPトークンが失われました。しかし、EIP-155導入以降、取引の署名にはchainId(チェーン自体を他のフォークチェーンと区別する識別子)が含まれるようになり、リプレイ先のチェーンのchainIdが異なる場合、取引は検証に失敗するため、リプレイは成立しません。
署名メッセージリプレイ
署名メッセージリプレイは、取引リプレイとは異なり、秘密鍵で署名されたメッセージ(例:「Cobo is the best」)を対象とするリプレイです。このタイプの攻撃では、攻撃者は取引全体ではなく、署名済みのメッセージそのものを再利用します。例えば「Cobo is the best」というメッセージは、chainIdなどのチェーン固有のパラメータを含まないため、理論的には任意のフォークチェーン上で検証が通ってしまう可能性があります。これを防ぐには、メッセージ内容にchainIdを追加し、「Cobo is the best + chainId()」のようにすることで、各チェーンごとに異なるメッセージ内容となり、署名も異なり、直接のリプレイは不可能になります。
OmniBridgeとPolygon Bridgeの攻撃原理
次に、OmniBridgeおよびPolygon Bridgeの攻撃原理を分析します。結論から述べると、これら2件の攻撃はいずれも「取引リプレイ」ではなく、ETHWがETHメインネットとは異なるchainIdを使用しているため、取引をそのままリプレイしても検証に失敗します。したがって残る可能性は「署名メッセージリプレイ」のみであり、以下でそれぞれがどのようにETHWフォークチェーン上で攻撃されたのかを個別に解説します。
OmniBridge
OmniBridgeは、xDAIチェーンとETHメインネット間の資産移動に使用されるブリッジであり、主に指定されたvalidatorがクロスチェーンメッセージを提出することで資産移転を実現しています。OmniBridgeにおいて、validatorが提出する検証メッセージのロジックは以下の通りです。
function executeSignatures(bytes _data, bytes _signatures) public { _allowMessageExecution(_data, _signatures); bytes32 msgId; address sender; address executor; uint32 gasLimit; uint8 dataType; uint256[2] memory chainIds; bytes memory data; (msgId, sender, executor, gasLimit, dataType, chainIds, data) = ArbitraryMessage.unpackData(_data); _executeMessage(msgId, sender, executor, gasLimit, dataType, chainIds, data); }
この関数では、#L2行目の署名チェックによって提出された署名が正当なvalidatorによるものかどうかを確認し、その後#L11行でdataメッセージをデコードします。デコード結果を見ると、返却値にchainIdフィールドが含まれていることがわかります。これはつまり、署名メッセージのリプレイはできないということでしょうか?さらに分析を進めます。
function _executeMessage( bytes32 msgId, address sender, address executor, uint32 gasLimit, uint8 dataType, uint256[2] memory chainIds, bytes memory data ) internal { require(_isMessageVersionValid(msgId)); require(_isDestinationChainIdValid(chainIds[1])); require(!relayedMessages(msgId)); setRelayedMessages(msgId, true); processMessage(sender, executor, msgId, gasLimit, dataType, chainIds[0], data); }
_executeMessage関数を追跡すると、#L11行でchainIdの正当性がチェックされていることがわかります。
function _isDestinationChainIdValid(uint256 _chainId) internal returns (bool res) { return _chainId == sourceChainId(); } function sourceChainId() public view returns (uint256) { return uintStorage[SOURCE_CHAIN_ID]; }
さらにロジックを追っていくと、chainIdのチェックにEVMのネイティブchainidオペコードを使用せず、uintStorage変数に格納された値を使っていることがわかります。この値は管理者が設定したものであり、メッセージ自体にチェーン識別子が含まれていないため、理論的には署名メッセージのリプレイが可能であるといえます。
ハードフォークの過程で、フォーク前のすべての状態が両チェーンに完全に保持されるため、xDaiチームが追加の措置を講じない限り、フォーク後のETHWとETHメインネット上のOmniBridgeコントラクトの状態は変化しません。つまり、validatorも同一のままです。このことから、validatorがメインネットで行った署名は、ETHW上でも検証可能であると推測できます。また、署名メッセージにchainIdが含まれていないため、攻撃者はこの署名をリプレイすることで、ETHW上で同じコントラクトの資産を引き出すことが可能になります。
Polygon Bridge
OmniBridgeと同様に、Polygon BridgeはPolygonチェーンとETHメインネット間の資産移動を行うブリッジです。ただし、OmniBridgeとは異なり、Polygon Bridgeはブロック証明に基づいて出金を処理します。そのロジックは以下の通りです。
function exit(bytes calldata inputData) external override { //...省略 // 取引の包含証明 require( MerklePatriciaProof.verify( receipt.toBytes(), branchMaskBytes, payload.getReceiptProof(), payload.getReceiptRoot() ), "RootChainManager: INVALID_PROOF" ); // チェックポイントの包含証明 _checkBlockMembershipInCheckpoint( payload.getBlockNumber(), payload.getBlockTime(), payload.getTxRoot(), payload.getReceiptRoot(), payload.getHeaderNumber(), payload.getBlockProof() ); ITokenPredicate(predicateAddress).exitTokens( _msgSender(), rootToken, log.toRlpBytes() ); }
この関数のロジックから、コントラクトは2つのチェックを通じてメッセージの正当性を確認していることがわかります。すなわち、transactionRootとBlockNumberを用いて取引がサブチェーン(Polygon Chain)上で実際に発生したことを保証します。最初のチェックは回避可能です。なぜなら、誰でも取引データから自身のtransactionRootを構築できるためです。しかし、2つ目のチェックは回避できません。_checkBlockMembershipInCheckpointのロジックを見てみると:
function _checkBlockMembershipInCheckpoint( uint256 blockNumber, uint256 blockTime, bytes32 txRoot, bytes32 receiptRoot, uint256 headerNumber, bytes memory blockProof ) private view returns (uint256) { ( bytes32 headerRoot, uint256 startBlock, , uint256 createdAt, ) = _checkpointManager.headerBlocks(headerNumber); require( keccak256( abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot) ) .checkMembership( blockNumber.sub(startBlock), headerRoot, blockProof ), "RootChainManager: INVALID_HEADER" ); return createdAt; }
ここで必要なheaderRootは_checkpointManagerコントラクトから取得されます。このロジックをたどり、_checkpointManagerがheaderRootを設定している箇所を確認します。
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external { (address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi .decode(data, (address, uint256, uint256, bytes32, bytes32, uint256)); require(CHAINID == _borChainID, "Invalid bor chain id"); require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA"); // check if it is better to keep it in local storage instead IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress()); uint256 _reward = stakeManager.checkSignatures( end.sub(start).add(1), /** prefix 01 to data 01 represents positive vote on data and 00 is negative vote malicious validator can try to send 2/3 on negative vote so 01 is appended */ keccak256(abi.encodePacked(bytes(hex"01"), data)), accountHash, proposer, sigs ); //....以下省略
#L2行目で、署名データはborChainIdのチェックしか行われておらず、チェーン自体のchainIdはチェックされていません。このメッセージはコントラクトで指定されたproposerによって署名されたものであるため、攻撃者は理論的にフォークチェーン上でproposerの署名メッセージをリプレイし、正当なheaderRootを提出することが可能になります。その後、Polygon Bridgeのexit関数をETHW上で呼び出し、対応するMerkle証明を提出することで、出金に成功し、headerRootのチェックも通過できます。
アドレス0x7dbf18f679fa07d943613193e347ca72ef4642b9を例に挙げると、このアドレスは以下の手順でETHWチェーンでの裁定取引に成功しています。
-
まず中央取引所からメインネットで出金。
-
Polygonチェーン上でPolygon BridgeのdepositFor関数を使って入金。
-
ETHメインネットでPolygon Bridgeのexit関数を呼び出して出金。
-
メインネットのproposerが提出したheaderRootをコピー。
-
ETHW上で上記のproposerの署名メッセージをリプレイ。
-
ETHW上のPolygon Bridgeでexit関数を呼び出して出金。
なぜこのような事態が起きたのか?
上記の2つの事例から明らかなように、これらのプロトコルがETHW上でリプレイ攻撃を受けた原因は、プロトコル自体がリプレイ防止策を講じていなかったため、対応する資産がフォークチェーン上で枯渇してしまったことです。ただし、これらのブリッジ自体はETHWフォークチェーンをサポートしていないため、ユーザーに直接的な損失はありませんでした。しかし、そもそもなぜこれらのブリッジが設計段階でリプレイ保護を組み込んでいなかったのでしょうか?その理由は単純です。OmniBridgeもPolygon Bridgeも、当初の想定用途は非常に限定的で、あくまで指定された特定チェーンとのみ資産移転を行うものであり、マルチチェーン展開の計画がなかったため、リプレイ保護がなくてもプロトコル自体のセキュリティに影響しなかったのです。
一方、ETHWを利用するユーザーにとっては、こうしたブリッジがマルチチェーンに対応していないため、ETHWフォークチェーン上で操作を行うと、逆にETHメインネット上でメッセージのリプレイ攻撃を受けるリスクがあります。
UniswapV2を例にすると、現在UniswapV2のプールコントラクトにはpermit関数があり、この関数内にはPERMIT_TYPEHASHという変数が含まれており、その中にDOMAIN_SEPARATORが含まれています。
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external { require(deadline >= block.timestamp, 'UniswapV2: EXPIRED'); bytes32 digest = keccak256( abi.encodePacked( '\x19\x01', DOMAIN_SEPARATOR, keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) ) ); address recoveredAddress = ecrecover(digest, v, r, s); require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE'); _approve(owner, spender, value); }
この変数はEIP712で最初に定義され、chainIdを含んでおり、マルチチェーン環境下でのリプレイ防止が設計段階から考慮されています。しかし、UniswapV2プールコントラクトのロジックでは:
constructor() public { uint chainId; assembly { chainId := chainid } DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), keccak256(bytes(name)), keccak256(bytes('1')), chainId, address(this) ) ); }
DOMAIN_SEPARATORはコンストラクタ内で決定されており、ハードフォーク後もチェーンのchainIdが変わっても、プールコントラクトは新しいchainIdを取得してDOMAIN_SEPARATORを更新することはできません。したがって、将来ユーザーがETHW上で許可操作を行う場合、そのpermit署名はETHメインネット上でリプレイされる可能性があります。Uniswap以外にも、特定バージョンのyearn vaultコントラクトなど、同様に固定のDOMAIN_SEPARATORを使用しているプロトコルが多く存在します。ユーザーはETHW上でこうしたプロトコルとやり取りする際、リプレイリスクに注意する必要があります。
設計段階での予防策
開発者にとって、プロトコルのメッセージ署名機構を設計する際には、将来的なマルチチェーン展開の可能性を考慮すべきです。もしマルチチェーン展開の計画がある場合、署名メッセージにchainIdを変数として含めるべきです。また、署名検証時には、ハードフォークによってフォーク前の状態が維持されるため、署名検証に使用するchainIdはコントラクト変数ではなく、毎回検証時にリアルタイムで取得し、署名検証を行うことで安全性を確保すべきです。
影響
ユーザーへの影響
プロトコルがフォークチェーンをサポートしていない場合、一般ユーザーはフォークチェーン上でいかなる操作も避けるべきです。そうしないと、署名メッセージがメインネット上でリプレイされ、資産を失う可能性があります。
取引所および機関投資家への影響
多くの取引所はすでにETHWトークンをサポートしているため、こうした攻撃によって引き出されたトークンが取引所に充電され、売却される可能性があります。ただし、このような攻撃はチェーンの合意メカニズム自体の問題による悪意ある増発ではないため、取引所としては特別な対策は必要ありません。
まとめ
マルチチェーン環境の発展に伴い、リプレイ攻撃は理論的な脅威から主流の攻撃手法へと変化しつつあります。開発者はプロトコル設計に細心の注意を払い、メッセージ署名機構を設計する際には、可能な限りchainIdなどの要因を署名内容に組み込み、関連するベストプラクティスに従うことで、ユーザーの資産損失を防ぐ必要があります。
Coboはアジア太平洋地域最大の暗号資産機関向けホットウォレットサービスを提供する企業であり、設立以来、500以上の業界トップクラスの機関およびハイネットワース個人に卓越したサービスを提供してきました。暗号資産の安全保管を前提に、安定した資産運用利回りも実現しており、世界中のユーザーから高い信頼を得ています。Coboは拡張性のあるインフラ基盤の構築に注力し、機関向けに多様な資産の安全保管、資産運用、オンチェーン操作、クロスチェーン・クロスレイヤー対応などを包括的に支援し、Web 3.0時代への移行を強力にバックアップしています。CoboグループにはCobo Custody、Cobo DaaS、Cobo MaaS、Cobo StaaS、Cobo Ventures、Cobo DeFi Yield Fundなどの事業部門があり、幅広いニーズに対応しています。
TechFlow公式コミュニティへようこそ
Telegram購読グループ:https://t.me/TechFlowDaily
Twitter公式アカウント:https://x.com/TechFlowPost
Twitter英語アカウント:https://x.com/BlockFlow_News














