おい、みんな。今日も一日お疲れさん!
急だけど、ちょっと顔貸してくれ。最近、Web3のL2ソリューション、特にOptimistic Rollup周りで、俺たちが本当に警戒すべきポイントについて、改めて話しておきたいんだ。表面的な情報じゃなく、実際に現場で攻撃者の目線を意識してシステムを守ってきた俺たちの経験からくる、もっと深掘りした話だ。
今日のテーマは「Optimistic Rollupにおける不正証明(Fraud Proof)の遅延と検閲耐性」。
「Fraud Proof? 知ってますよ、チャレンジ期間中に不正を証明すればいいんですよね?」なんて、教科書通りの回答はもう聞き飽きた。その『チャレンジ期間』という言葉の裏に潜む、攻撃者が狙う盲点と、そこをどうやって守り抜くか、実戦形式で解説する。
Webアプリ開発者だろうが、インフラエンジニアだろうが、L2を扱うならこれは避けて通れない問題だ。しっかりと頭に叩き込んでくれ。
—
盲点だらけの「楽観主義」:Optimistic Rollupの影
まずは基本中の基本だが、Optimistic Rollup(以下、OR)は、トランザクションの正当性を「楽観的」に信頼することから始まる。L2で処理されたトランザクションの束(ロールアップブロック)は、L1(Ethereumなど)にコミットされる際、その内容が正しいと一旦は仮定される。そして、約1週間程度の「チャレンジ期間」という猶予が設けられ、この期間内に誰かが不正(例えば、無効なトランザクションが含まれていたり、L2の状態遷移が間違っていたり)を発見し、Fraud Proof(不正証明)を提出すれば、その不正なロールアップブロックはL1でRevertされる。
ここまでが一般的な説明だ。だがな、セキュリティの現場はそんなお伽話じゃ済まされない。
攻撃者が狙う「チャレンジ期間」の脆弱性
チャレンジ期間があるから安全? まったく違う。攻撃者にとって、この期間はまさに「時間稼ぎ」のチャンスであり、「検閲」の絶好の舞台にもなり得る。
1. 不正シーケンサーによる資金窃取シナリオ:PoCレベルの脅威
ORでは、通常、単一のシーケンサー(または少数の許可されたシーケンサー)がL2のトランザクションを収集し、実行順序を決定し、L1にロールアップブロックを提出する役割を担う。ここがポイントだ。もしこのシーケンサーが悪意を持っていたらどうなる?
攻撃シナリオの具体例:
1. 不正なL2状態遷移のコミット: 悪意あるシーケンサーが、自身のウォレットに大量のトークンを不正に送金するトランザクションを含む、偽りのL2状態ルートをL1にコミットする。もちろん、このトランザクションは実際にはL2で正当に実行されてはいないか、無効な署名を持っている。
2. 不正証明の妨害(検閲):
- L1ガススパム攻撃: 攻撃者は、自身が提出した不正なロールアップブロックが確定するのを阻止されないよう、チャレンジ期間中にL1のネットワークを大量の低価値トランザクションでスパムし、ガス代を高騰させる。これにより、正直な参加者(ホワイトハッカーやウォッチタワー)が不正証明を提出するコストが跳ね上がり、経済的に提出が困難になる。
- MEVリレイヤーの悪用: より巧妙な攻撃者は、L1のMEV(Miner Extractable Value)リレイヤーを利用して、不正証明トランザクションがブロックに含まれないように検閲を試みる。例えば、マイナーに賄賂を渡し、特定のトランザクション(不正証明)をブロックに含めないよう依頼する。
- DDoS攻撃: 不正証明を提出するウォッチタワーや監視ノードに対し、DDoS攻撃を仕掛けることで、物理的に提出作業を妨害する。
3. チャレンジ期間終了と資金窃取: チャレンジ期間が終了するまでに、何らかの理由で不正証明が提出されなければ、不正なL2状態はL1で「正当」なものとして確定する。結果として、攻撃者のウォレットに資金が不正に送金されてしまう。
これは絵空事じゃない。実際に、L1での高騰するガス代や、MEVが絡む環境を考えれば、十分に起こり得る攻撃ベクトルだ。
2. トランザクション検閲によるユーザー体験の毀損と機会損失
不正シーケンサーは、直接的な資金窃取だけでなく、特定のユーザーのトランザクションを意図的に検閲することで、システム全体の信頼性を揺るがすことができる。
検閲シナリオの具体例:
- 資金引き出しの妨害: 大口のユーザーがL2からL1へ資金を引き出そうとした際、シーケンサーがその引き出しトランザクションを故意にL2ブロックに含めず、保留にし続ける。ユーザーは資金がロックされ、流動性を提供できなくなったり、急な市場変動に対応できず大きな機会損失を被る。
- Arbitrage(裁定取引)の妨害: デックスなどで裁定取引を行うボットやユーザーのトランザクションを検閲し、特定の参加者のみに有利な取引を成立させる。
ORには「強制インクルージョン」という、L1からトランザクションを直接キューに入れてL2に含めるメカニズムがあることが多いが、これとて即時性はなく、L1へのトランザクション手数料もかかる。その間にも、ユーザーは損害を被る可能性があるんだ。
「Challenge Period」の意外な盲点
このチャレンジ期間は、本来セキュリティを担保するためのものだが、その長さ自体がユーザーにとってはUXのボトルネックになる。そして、多くのユーザーは「誰かが監視しているだろう」と漠然と期待しているだけで、自ら不正証明を提出するインセンティブも能力も持たない。
この「誰もが提出できるが、誰も提出しないかもしれない」という構造こそが、攻撃者が突く隙間なんだ。
—
現場で役立つ防御策と実践的アプローチ
じゃあ、俺たちはどうすればいいのか? 漠然とした不安を抱えるだけじゃなく、具体的な対策を打っていく必要がある。
防御策1: L2状態監視と不正検知アラートシステム
まず基本中の基本だが、俺たちのシステムが依存するL2のロールアップブロックが正しくL1にコミットされているかを常に監視する体制を築くことだ。ただ監視するだけでなく、不正の兆候を検知したら即座にアラートを飛ばす自動化されたシステムが必要だ。
ここでは、Pythonとweb3.pyを使ったシンプルな監視スクリプトの例を示す。これはL1のイベントを監視し、L2の特定のアドレス(例えば、ロールアップコントラクト)から発行されるイベントをチェックする。
import os
import json
import time
from web3 import Web3
from dotenv import load_dotenv
# .envファイルから環境変数を読み込む
load_dotenv()
# Web3プロバイダーの設定
# L1 EthereumノードのURLを設定 (Infura, Alchemyなど)
# 環境変数から取得することで、本番環境と開発環境で切り替え可能にする
L1_PROVIDER_URL = os.getenv("L1_PROVIDER_URL", "https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID")
w3_l1 = Web3(Web3.HTTPProvider(L1_PROVIDER_URL))
# ロールアップコントラクトのアドレスとABI
# これはOptimistic Rollupの種類によって異なるので、使用するORの公式ドキュメントを確認すること
# 例: OptimismのL1CrossDomainMessengerやL1StandardBridgeなど
ROLLUP_CONTRACT_ADDRESS = Web3.to_checksum_address(os.getenv("ROLLUP_CONTRACT_ADDRESS", "0x..."))
# ABIは長くなるので、ここでは最小限の例として省略。実際のABIはコントラクトから取得する。
ROLLUP_CONTRACT_ABI = json.loads(os.getenv("ROLLUP_CONTRACT_ABI_JSON", """
[
{
"anonymous": false,
"inputs": [
{"indexed": true, "internalType": "uint256", "name": "index", "type": "uint256"},
{"indexed": true, "internalType": "bytes32", "name": "root", "type": "bytes32"},
{"indexed": false, "internalType": "uint256", "name": "l1BlockNumber", "type": "uint256"}
],
"name": "RollupBlockCommitted",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "internalType": "uint256", "name": "index", "type": "uint256"},
{"indexed": true, "internalType": "bytes32", "name": "root", "type": "bytes32"}
],
"name": "RollupBlockChallenged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "internalType": "uint256", "name": "index", "type": "uint256"},
{"indexed": true, "internalType": "bytes32", "name": "root", "type": "bytes32"}
],
"name": "RollupBlockFinalized",
"type": "event"
}
]
"""))
# 監視するイベントの種類
# 例えば、新しいロールアップブロックがコミットされたイベントを監視
EVENT_NAME_COMMITTED = "RollupBlockCommitted"
EVENT_NAME_CHALLENGED = "RollupBlockChallenged"
EVENT_NAME_FINALIZED = "RollupBlockFinalized"
# アラート通知用の関数 (Slack, PagerDuty, Emailなど)
def send_alert(message):
print(f"[ALERT] {message}")
# TODO: ここに実際の通知ロジックを実装
# 例: Slack Webhook, PagerDuty API, Email送信など
pass
def monitor_rollup_events():
contract = w3_l1.eth.contract(address=ROLLUP_CONTRACT_ADDRESS, abi=ROLLUP_CONTRACT_ABI)
print(f"L1ブロックチェーンを監視中... コントラクト: {ROLLUP_CONTRACT_ADDRESS}")
# 最終監視ブロックを記録 (永続化推奨)
last_block_number = w3_l1.eth.block_number
while True:
try:
current_block_number = w3_l1.eth.block_number
if current_block_number > last_block_number:
# 新しいロールアップブロックコミットイベントをチェック
committed_filter = contract.events[EVENT_NAME_COMMITTED].create_filter(
fromBlock=last_block_number + 1, toBlock=current_block_number
)
for event in committed_filter.get_all_entries():
block_index = event['args']['index']
block_root = event['args']['root'].hex()
l1_block = event['args']['l1BlockNumber']
print(f"--- 新しいRollupブロックがコミットされました ---")
print(f" Rollup Block Index: {block_index}")
print(f" State Root: {block_root}")
print(f" L1 Block Number: {l1_block}")
# TODO: ここでL2の状態を検証するロジックを呼び出す
# 例えば、別のL2フルノードからこのstate_rootが正しいか検証する
# もし不正が検知されたら、send_alertで通知
# 例: validate_l2_state(block_root, block_index)
send_alert(f"新しいRollupブロックがコミットされました (Index: {block_index}, Root: {block_root[:10]}...) L1ブロック: {l1_block}")
# チャレンジイベントをチェック
challenged_filter = contract.events[EVENT_NAME_CHALLENGED].create_filter(
fromBlock=last_block_number + 1, toBlock=current_block_number
)
for event in challenged_filter.get_all_entries():
block_index = event['args']['index']
block_root = event['args']['root'].hex()
print(f"--- Rollupブロックがチャレンジされました! ---")
print(f" Rollup Block Index: {block_index}")
print(f" State Root: {block_root}")
send_alert(f"緊急!Rollupブロックがチャレンジされました! (Index: {block_index}, Root: {block_root[:10]}...) これは不正の可能性あり、要確認!")
# ファイナライズイベントをチェック (不正証明が提出されずに確定した場合など)
finalized_filter = contract.events[EVENT_NAME_FINALIZED].create_filter(
fromBlock=last_block_number + 1, toBlock=current_block_number
)
for event in finalized_filter.get_all_entries():
block_index = event['args']['index']
block_root = event['args']['root'].hex()
print(f"--- Rollupブロックがファイナライズされました ---")
print(f" Rollup Block Index: {block_index}")
print(f" State Root: {block_root}")
# ここで、ファイナライズされたブロックが過去に不正検知されていないかをクロスチェック
send_alert(f"Rollupブロックがファイナライズされました (Index: {block_index}, Root: {block_root[:10]}...)")
last_block_number = current_block_number
time.sleep(5) # 5秒ごとにチェック
except Exception as e:
print(f"エラーが発生しました: {e}")
send_alert(f"Rollup監視スクリプトでエラーが発生しました: {e}")
time.sleep(10) # エラー時は少し長めに待機
if __name__ == "__main__":
if not w3_l1.is_connected():
print("L1プロバイダーに接続できません。設定を確認してください。")
exit()
monitor_rollup_events()
このスクリプトのポイント:
web3.pyを使ってL1のノードに接続し、指定したORコントラクトのイベントをポーリング。- 新しいロールアップブロックがコミットされたら、その
state_rootを取得。 - 【重要】 取得した
state_rootが本当に正しいかを検証するロジック(validate_l2_stateのような関数)を別途実装する必要がある。これは、L2のフルノードを動かして、そのstate_rootに対応するL2の状態を再計算・検証するプロセスだ。これがなければ、ただイベントを監視しているだけで、不正は見抜けない。 RollupBlockChallengedイベントの監視: これが発火したら、即座にセキュリティチームに緊急アラートを飛ばす。誰かが不正を検知した証拠だから、詳細調査が必要だ。- アラート通知:
send_alert関数を実装し、SlackやPagerDuty、メールなどで即座に通知が飛ぶようにする。夜間でも対応できるよう、オンコール体制も必須だ。
防御策2: 不正証明トランザクションの確実な実行
万が一、監視システムが不正を検知したとして、その不正証明をL1に提出できなければ意味がない。攻撃者による検閲リスクを考慮し、不正証明トランザクションを確実にL1ブロックに含めるための工夫が必要だ。
import os
from web3 import Web3
from web3.middleware import geth_poa_middleware # Geth PoAネットワークの場合
from dotenv import load_dotenv
load_dotenv()
# Web3プロバイダーの設定 (L1 Ethereumノード)
L1_PROVIDER_URL = os.getenv("L1_PROVIDER_URL", "https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID")
w3_l1 = Web3(Web3.HTTPProvider(L1_PROVIDER_URL))
# 署名用アカウントの秘密鍵
# 環境変数から取得し、本番環境ではHSMなどのセキュアなストレージを使用すること
PRIVATE_KEY = os.getenv("PRIVATE_KEY")
if not PRIVATE_KEY:
raise ValueError("PRIVATE_KEYが設定されていません。")
# ロールアップコントラクトのアドレスとABI (不正証明を呼び出すためのもの)
ROLLUP_CHALLENGE_CONTRACT_ADDRESS = Web3.to_checksum_address(os.getenv("ROLLUP_CHALLENGE_CONTRACT_ADDRESS", "0x..."))
# 不正証明用のABI (これもORによって異なる。ここではchallengeRollupBlockという関数があるとする)
ROLLUP_CHALLENGE_CONTRACT_ABI = json.loads(os.getenv("ROLLUP_CHALLENGE_CONTRACT_ABI_JSON", """
[
{
"inputs": [
{"internalType": "uint256", "name": "_blockIndex", "type": "uint256"},
{"internalType": "bytes32", "name": "_incorrectRoot", "type": "bytes32"},
{"internalType": "bytes", "name": "_proof", "type": "bytes"}
],
"name": "challengeRollupBlock",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
"""))
# 不正証明を提出する関数
def submit_fraud_proof(block_index: int, incorrect_root: str, proof_data: str):
if not w3_l1.is_connected():
print("L1プロバイダーに接続できません。設定を確認してください。")
return False
account = w3_l1.eth.account.from_key(PRIVATE_KEY)
challenge_contract = w3_l1.eth.contract(address=ROLLUP_CHALLENGE_CONTRACT_ADDRESS, abi=ROLLUP_CHALLENGE_CONTRACT_ABI)
try:
# トランザクションを構築
# ガス代を高く設定し、検閲耐性を高める
# L1の現在のガス価格の2倍や3倍など、積極的に高いガスを設定する戦略も考慮する
gas_price = w3_l1.eth.gas_price
# EIP-1559対応の場合
latest_block = w3_l1.eth.get_block('latest')
base_fee_per_gas = latest_block['baseFeePerGas']
max_priority_fee_per_gas = Web3.to_wei(5, 'gwei') # 優先手数料は高めに設定
max_fee_per_gas = base_fee_per_gas + max_priority_fee_per_gas + Web3.to_wei(20, 'gwei') # さらに余裕を持たせる
# 不正証明のデータは、L2の特定の状態に関する情報と、それがどのように不正であるかを示す証拠を含む
# このproof_dataは非常に複雑であり、ORの実装に依存するため、ここではダミーデータとしている。
# 実際の運用では、L2の検証ノードが生成した完全なproofを渡す必要がある。
# bytes()関数でbytes型に変換
proof_bytes = Web3.to_bytes(hexstr=proof_data) # proof_dataはhex文字列を想定
transaction = challenge_contract.functions.challengeRollupBlock(
block_index,
Web3.to_bytes(hexstr=incorrect_root), # rootもhex文字列からbytes32に変換
proof_bytes
).build_transaction({
'from': account.address,
'nonce': w3_l1.eth.get_transaction_count(account.address),
'gas': 3000000, # ガスリミットは十分に大きく設定
'maxFeePerGas': max_fee_per_gas,
'maxPriorityFeePerGas': max_priority_fee_per_gas,
'chainId': w3_l1.eth.chain_id
})
# トランザクションに署名
signed_txn = w3_l1.eth.account.sign_transaction(transaction, private_key=PRIVATE_KEY)
# トランザクションを送信
tx_hash = w3_l1.eth.send_raw_transaction(signed_txn.rawTransaction)
print(f"不正証明トランザクションを送信しました。Tx Hash: {tx_hash.hex()}")
# トランザクションのマイニングを待機
receipt = w3_l1.eth.wait_for_transaction_receipt(tx_hash, timeout=600) # 10分待機
if receipt.status == 1:
print(f"不正証明トランザクションが成功しました!ブロック番号: {receipt.blockNumber}")
return True
else:
print(f"不正証明トランザクションが失敗しました。レシート: {receipt}")
return False
except Exception as e:
print(f"不正証明の送信中にエラーが発生しました: {e}")
return False
if __name__ == "__main__":
# 実際の運用では、監視システムが不正を検知した際にこの関数を呼び出す
# 例としてダミーデータを設定
DUMMY_BLOCK_INDEX = 123
DUMMY_INCORRECT_ROOT = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" # 実際は不正なroot
DUMMY_PROOF_DATA = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" # 実際はL2ノードが生成する複雑な証明
# 不正証明を提出
# success = submit_fraud_proof(DUMMY_BLOCK_INDEX, DUMMY_INCORRECT_ROOT, DUMMY_PROOF_DATA)
# if success:
# print("不正証明の提出に成功しました。")
# else:
# print("不正証明の提出に失敗しました。")
print("このスクリプトは不正証明を提出するロジックの例です。実行するには適切な設定と不正証明データが必要です。")
print("本番環境での秘密鍵の管理には、HSMやKMSなどのセキュアなソリューションを必ず使用してください。")
このスクリプトのポイント:
- ガス代の戦略的設定:
maxFeePerGasやmaxPriorityFeePerGasを積極的に高めに設定することで、マイナーがトランザクションを優先的にブロックに含めるインセンティブを与える。攻撃者がL1をスパムしてガス代を高騰させても、それ以上のガス代を支払ってでも不正証明をねじ込む覚悟が必要だ。 - リトライロジック: トランザクションがRevertされたり、一定時間マイニングされなかったりした場合に、ガス代をさらに上げて再送信するリトライロジックを実装することも重要だ。(上記のコードには含んでいないが、実運用では必須)
- 秘密鍵の管理:
PRIVATE_KEYの管理は極めて重要だ。本番環境では、ハードウェアセキュリティモジュール(HSM)やクラウドのキーマネジメントサービス(KMS)と連携させ、絶対に平文で保存したり、安易に環境変数に置いたりしないこと。 proof_dataの生成: 最も複雑な部分だ。これはL2のプロトコルに特化した検証ロジックと、L2の完全な状態データ(通常はL2フルノード)が必要となる。このデータがなければ、不正証明は無効となる。
防御策3: シーケンサーの健全性監視と緊急時の対応
単一のシーケンサーに依存するORプロトコルでは、シーケンサーの健全性を監視することも重要だ。
- L2トランザクションの遅延監視: 特定の期間(例: 5分以上)でL2のトランザクションがL1にロールアップされていない場合、シーケンサーに問題が発生している可能性がある。
- トランザクションプール監視: ユーザーがL2に送信したトランザクションが、シーケンサーによって意図的に長時間保留されていないか監視する。これには、L2のシーケンサーが持つトランザクションプールを監視する専用のツールやAPIが必要になることが多い。
- 緊急時の資金引き出しパス: 万が一、シーケンサーが停止したり、検閲を行ったりした場合に備え、ユーザーがL1経由で直接資金を強制的に引き出す「強制インクルージョン」や「エマージェンシーブリッジ」のようなメカニズムがあることを確認し、その利用方法を周知徹底する。
防御策4: L1ガスの高騰対策とMEVリレイヤーの活用
攻撃者がL1のガス代高騰やMEVリレイヤーを利用して不正証明を妨害してくることを考慮すると、我々もその対抗策を講じる必要がある。
- MEVリレイヤーの積極的利用: 不正証明のような極めて重要なトランザクションは、パブリックなトランザクションプール(mempool)に直接送信せず、FlashbotsのようなMEVリレイヤーを通じてプライベートにマイナーに直接送信することを検討する。これにより、トランザクションがブロックに確実かつ迅速に含まれる可能性が高まり、検閲のリスクを低減できる。
# web3.pyとFlashbotsを連携させる場合の概念コード
# from flashbots import flashbots
# from web3.middleware import construct_sign_and_send_raw_middleware
# ... (前述のトランザクション構築部分) ...
# Flashbotsアカウントの設定 (別途、Flashbotsへの登録とキーが必要)
# flashbots_account = w3_l1.eth.account.from_key(FLASHBOTS_PRIVATE_KEY)
# w3_l1.middleware_onion.add(construct_sign_and_send_raw_middleware(flashbots_account))
# bundle = [signed_txn.rawTransaction] # 署名済みトランザクションのリスト
# block_number = w3_l1.eth.block_number + 1 # 次のブロックをターゲット
# response = flashbots.send_bundle(bundle, target_block_number=block_number)
# print(f"Flashbots bundle sent. Response: {response}")
# # レスポンスを監視し、トランザクションが含まれたか確認
これは高度な実装になるが、ミッションクリティカルな状況では検討すべきだ。
—
最後に:信頼は築くもの、盲信するものではない
今日の話は、Optimistic Rollupが持つ「楽観的」という言葉の裏に潜む、現実の脅威についてだった。L2ソリューションは素晴らしい技術だが、それは完璧ではない。特にセキュリティの領域では、プロトコル設計上の弱点や、運用上のヒューマンエラー、そして外部要因(L1の混雑やMEV)が複合的に絡み合って、大きなインシデントに発展する可能性がある。
「誰かがやってくれるだろう」という受け身の姿勢は、セキュリティエンジニアとして最も危険な考え方だ。自分たちのシステム、自分たちのユーザーを守るためには、常に最悪のシナリオを想定し、先手を打つ必要がある。
今回提示した監視スクリプトや不正証明の送信ロジックは、あくまで基本的なテンプレートだ。これをベースに、自分たちのシステム環境やORの具体的な実装に合わせてカスタマイズし、堅牢な運用体制を築いてほしい。
信頼は築くものだ。決して盲信するものではない。
この言葉を胸に刻んで、これからも最高のセキュリティを追求していこう。
以上だ。解散!
コメント