Cross-Chain Bridge(以下、bridge)は、あるブロックチェーン(以下、チェーン)で資産が金庫に預けられたことを確かめて、別のチェーンで同じ量の引換券(wrapped token)を発行する仕組みです。引換券を焼却(消すこと)すると、元のチェーンの金庫から資産が払い出されます。預ける側を source chain、引換券を受け取る側を destination chain と呼びます。
bridge の安全性は、destination chain が「source chain で預け入れがあった」という主張をどう確かめるかで決まります。2022 年には、この確かめる部分が破られる事故が続きました。

4 件の事故が破った場所。どれも金庫ではなく、間の確かめる部分
上記の図のように、4 件とも金庫やトークンのコントラクト(チェーン上で動くプログラム)自体は壊れていません。偽の預け入れが本物として扱われて引換券が発行されたか、偽の焼却が本物として扱われて金庫から払い出されました。
本記事では、4 件を表の順に説明します。鍵を奪われた Ronin から始め、確かめるコードの欠陥を突かれた 3 件へ進みます。外部の署名者、軽量クライアント、異議の受付期間といった確かめ方の違いは Cross-Chain Bridge を前提にします。金額は当時の公式発表と報道によるもので、ドル換算は当時の価格です。
| 事故 | 時期 | 確かめ方 | 破られた所 |
|---|---|---|---|
| Ronin | 2022 年 3 月 | 外部の署名者 | 署名者の鍵 4 本と、残っていた署名の代行の許可(計 9 人中 5 人分) |
| Wormhole | 2022 年 2 月 | 外部の署名者 | 署名を確かめるコード |
| Nomad | 2022 年 8 月 | 異議の受付期間 | 確認済みの扱いにする初期値 |
| BSC Token Hub | 2022 年 10 月 | 軽量クライアント | Merkle proof を確かめるコード |
Ronin
Ronin は、ゲームの Axie Infinity のために作られたチェーンです。Ethereum との bridge は、9 人の署名者(Ronin では validator と呼びます)のうち 5 人の署名で、Ethereum 側の金庫からの払い出しを受け入れていました。鍵を 5 本集めれば、焼却の無い払い出しに署名できる構造です。
9 人のうち 4 人は、ゲームを運営する Sky Mavis の鍵でした。残りの 5 人のうちの 1 人が Axie DAO(DAO は Decentralized Autonomous Organization の略で、ここでは Axie Infinity の利用者が運営の判断に参加する組織)です。

Ronin で 5 本目の署名が得られるまでの経緯
Halborn の分析によると、事故は次の順序で起きました。
- 2021 年 11 月、利用者の急増による負荷への対策として、Axie DAO が Sky Mavis に署名の代行を許可した。12 月に代行は終わったが、許可は取り消されていなかった。
- 攻撃者は、Sky Mavis の鍵 4 本を手に入れた。さらに残っていた代行の経路から Axie DAO の署名も得て、5 本目にした。
- 2022 年 3 月 23 日、焼却の無い偽の払い出しで、Ethereum 側の金庫から 173,600 ETH と 2,550 万 USDC を引き出した。発覚したのは 6 日後で、利用者が 5,000 ETH を払い出せなかったことがきっかけだった。
Cross-Chain Bridge の実験で、5 本目の鍵で偽の払い出しが通った場面にあたります。金庫が空になっても destination chain 側の引換券の表示は変わらず、金庫の残高を見張る仕組みも無かったため、発覚まで 6 日かかりました。
Wormhole
Wormhole は、19 の guardian(署名者)のうち 13 の署名でメッセージを受け入れる bridge です。Ronin と同じ外部の署名者のタイプですが、鍵は奪われていません。2022 年 2 月 2 日、Solana 側のコントラクトで、署名を確かめる処理そのものが回避されました。
この欠陥を読むには、Solana のプログラムの呼び出し方を知る必要があります。Solana のプログラム(コントラクト)は、呼び出し側から、読み書きするデータを入れたアカウントを引数の一覧として受け取ります。その中には、ランタイムが用意する特別なアカウント(sysvar)も含まれます。アカウントはそれぞれアドレス(識別子)を持ち、sysvar のアドレスは決まっています。
Solana では、署名の検証を組み込みのプログラム(secp256k1 program。secp256k1 は署名方式の名前)の命令として取引に入れ、同じ取引の中で先に実行させます。後から呼ばれるプログラムは、その命令が取引に入っていたかを Instructions sysvar(同じ取引の中で実行される命令の一覧を持つ sysvar)から読んで確かめます。

渡されたアカウントが本物の sysvar かを確かめなかった
Halborn の分析によると、流れは次の通りです。
- Wormhole の
verify_signaturesは、渡された Instructions sysvar を読んで、guardian の署名を検証する secp256k1 program の命令が同じ取引に入っているかを確かめていた。 - 渡されたアカウントのアドレスが本物の sysvar のものかを確かめていなかったため、攻撃者は検証の命令が入っているように見える偽のアカウントを渡した。
- guardian の署名が揃ったと扱われたメッセージで、Ethereum に預け入れの無い 120,000 wETH(Wormhole の wrapped ETH)を Solana 上で発行した。
本物の sysvar かを確かめる load_instruction_at_checked へ置き換える変更の提案(pull request)は、事故の 3 週間前の 2022 年 1 月 13 日に公開リポジトリへ出ていました。しかし、main への取り込みは事故当日の 2 月 2 日で、Solana 上で動いていたプログラムは古いままでした。裏付けの無いまま発行された 120,000 wETH の分の ETH は、Wormhole の開発に関わる Jump Crypto が補填しました。
Nomad
Nomad は、異議の受付期間を使う optimistic な bridge でした。source chain のメッセージは Merkle Tree にまとめられ、その根のハッシュ(root)に updater(root に署名する役)が署名します。relayer(届ける役)が署名済みの root を destination chain へ届け、一定期間に watcher(監視する役)が止めなければ、その root は確認済みになります。root に含まれることを証明したメッセージだけが処理されます。
2022 年 8 月 1 日の事故は、Halborn の分析によると次の順序で起きました。
- コントラクトの更新時の初期化で、0x00 を確認済みの root として登録してしまった。
- Solidity の mapping(Ethereum のコントラクトの言語での連想配列)は、登録されていないキーに 0 を返す。証明されていないメッセージの root を引くと 0x00 になり、それが確認済みとして扱われた。
- どんなメッセージも証明済みとして処理されるようになり、最初の攻撃の取引をコピーして宛先だけ変えた取引が数百のアドレスから送られた。損失は約 1.9 億ドル。
この初期値の誤りは、辞書 2 つで再現できます(lab/nomad_sim.py)。
def message(kind, nonce, to, amount):
return f"{kind}|{nonce}|{to}|{amount}".encode()
ZERO = "00" * 32
proven = {ZERO: True} # the mistake: the initial root 0x00 is marked as proved
root_of = {} # message -> root it belongs to; unknown messages give ZERO
def accept(msg):
return proven.get(root_of.get(msg, ZERO), False)
forged = message("deposit", 99, "mallory", 1000)
print(f"unproved message accepted: {accept(forged)}")
proven = {} # without the mistake
print(f"unproved message accepted after fix: {accept(forged)}")
unproved message accepted: True
unproved message accepted after fix: False
異議の受付期間は、届いた root を watcher が期間内に止める仕組みです。証明されていないメッセージが期間を経ずに受け入れられる状態では、watcher が止める機会もありませんでした。
BSC Token Hub
BNB Chain(通貨は BNB)は、2022 年当時、ステーキングや投票を扱う BNB Beacon Chain と、コントラクトを動かす BNB Smart Chain(BSC)の 2 本のチェーンで構成されていました。Beacon Chain を source chain、BSC を destination chain とする bridge が BSC Token Hub(TokenHub.sol)です。
BSC Token Hub は軽量クライアントのタイプで、Beacon Chain の状態を Merkle proof(Merkle Tree の葉から根までの経路のハッシュ)で確かめていました。木の形式は IAVL という、Cosmos 系のチェーンが使う Merkle Tree の一種です。
Nansen の分析によると、攻撃者は前日に BSC Token Hub の relayer として登録し、2022 年 10 月 6 日に存在しない預け入れの proof を提出して、1,000,000 BNB の発行を 2 回通しました。BNB Chain は validator(BNB Smart Chain でブロックを承認する参加者)と調整してチェーンを止めたため、外のチェーンへ持ち出されたのは一部に留まりました。

預け入れの無い proof で、BSC Token Hub が発行した
Verichains の分析によると、原因は IAVL の範囲の proof(1 つの proof で複数の葉が木に含まれることを示す形式)を検証するコード(iavl の修正)にありました。検証コードは、次の 2 つが両方成り立てば、proof に並んだ葉が木の中にあると判定していました。
- 1 枚目の葉から経路をたどって計算した root が、チェーンに記録された root と一致する。
- 2 枚目以降の葉から計算したハッシュが、経路の要素が持つ右の子の値と一致する。
経路の要素は、通常は左右の子のどちらか一方のハッシュだけを持ちます。左右の子を両方持つ要素を渡された時、検証コードは 1. の計算で右の子の値を使っていませんでした。

root の計算に使われない右の子に、偽の葉のハッシュを入れた
上記の図のように、攻撃者は本物の葉と経路で 1. を通し、右の子に偽の葉のハッシュを入れて 2. を通しました。右の子は 1. の計算に使われないため、何を入れても root は変わりません。軽量クライアントのタイプは source chain の合意を信頼する方式ですが、その前提は、証明を確かめるコードが正しい場合に限ります。証明の形式を曖昧にしない木の設計は Merkle Tree Design で扱っています。