先日開示された0.11.0以下のバージョン*1のEclairに存在したプリイメージ抽出時の脆弱性↓
脆弱性の内容
今回開示された脆弱性は、チャネルを強制クローズした際にオンチェーンでプリイメージを監視する際の処理に問題があった。
相手がコミットメントトランザクションをブロードキャストしてチャネルを強制クローズした場合、そのコミットメントトランザクションにHTLC(転送中の資金)があれば、その資金を回収する必要がある。その際、相手がオンチェーンでそのHTLCの解決を図った場合は、そのオンチェーントランザクションで確認できるプリイメージを使って、上流のHTLCを解決する必要がある。
そのためコミットメントトランザクションにHTLCがある場合、そのプリイメージを使って資金を転送するオンチェーントランザクションを監視する必要がある。
ただ、Eclairでは有効なローカルのコミットメントトランザクションに存在するHTLCのみがチェックされていた。つまり、悪意あるチャネルパートナーが、古いけどまだ有効な(つまり完全に失効していない)コミットメントトランザクションをブロードキャストした場合、ローカルのコミットメントトランザクションからは削除したHTLCが、リモートのコミットメントトランザクションには存在しているという状況が発生する。
※ 以下のシーケンス図のステップ2で、攻撃者がrevoke_and_ackを送っていない状態だと新旧両方の状態を管理する必要がある。
このような状況では、EclairはそのHTLCを認識していないため、オンチェーンで対象のプリイメージを入手できず、
MにはオンチェーンでHTLCを精算され、AはタイムアウトによりBに転送していた資金の払い戻しを受け
結果的にBだけ資金を失うことになる。
脆弱性が含まれるコード(コメントは翻訳してる)↓
def extractPreimages(localCommit: LocalCommit, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = { // ... (txからhtlcSuccessとclaimHtlcSuccessのプリイメージを抽出するコードは省略) val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet paymentPreimages.flatMap { paymentPreimage => // ローカルコミットメントのhtlcのみを考慮する。なぜなら、送信htlcのみを気にしており、 // それらはリモートコミットメントで最初に消えるから // 送信htlcがリモートコミットメントにある場合: // - ローカルコミットメントにもある(決して履行されなかった) // - または既に履行を受け取って上流に転送している localCommit.spec.htlcs.collect { case OutgoingHtlc(add) if add.paymentHash == sha256(paymentPreimage) => (add, paymentPreimage) } } }
コメント部分がコードの正しさを説明しているように思えるけど、 複数の有効なコミットメントの存在が認識されておらず、それが脆弱性の要因だったと。
修正されたコードが↓
def extractPreimages(commitment: FullCommitment, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = { // ... (txからhtlcSuccessとclaimHtlcSuccessのプリイメージを抽出するコードは省略) val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet paymentPreimages.flatMap { paymentPreimage => val paymentHash = sha256(paymentPreimage) // 上流にリレーするプリイメージを学習しようとしているときは、送信HTLCのみを気にします。 // すでにプリイメージを見た場合は、すでに上流に履行をリレーしている可能性があることに注意してください。 val fromLocal = commitment.localCommit.spec.htlcs.collect { case OutgoingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage) } // リモートの観点から、これらは受信HTLCです。 val fromRemote = commitment.remoteCommit.spec.htlcs.collect { case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage) } val fromNextRemote = commitment.nextRemoteCommit_opt.map(_.commit.spec.htlcs).getOrElse(Set.empty).collect { case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage) } fromLocal ++ fromRemote ++ fromNextRemote } }
この修正により、上記のような状況でもHTLCを正しく認識し、必要なプリイメージを抽出することが保証されるようになった。なお、修正は、スプライシング用の大きめのPRに紛れてマージされた模様。
攻撃のシナリオ
この脆弱性を悪用して攻撃者が資金を盗むシナリオは、攻撃者をM、被害者をBとした場合に、A -- B -- Mというチャネルのトポロジーがあったとして、このトポロジーを使用して攻撃者Mが自分宛に支払いをルーティングし、以下の処理を行うことで、Bの資金を盗むことができる。
sequenceDiagram
participant A
participant B as B<br/>(被害者)
participant M as M<br/>(攻撃者)
participant BC as ブロックチェーン
Note over A,M: A -- B -- M のトポロジー
rect rgb(255, 230, 230)
Note over A,M: ステップ1:支払いのルーティング
A->>B: HTLC送信(支払い)
B->>M: HTLC転送
Note over M: Mはプリイメージを知っている<br/>(自分が受信者なので)
end
rect rgb(230, 240, 255)
Note over A,M: ステップ2:支払いの失敗と状態の分岐
M->>B: update_fail_htlc
M->>B: commitment_signed
Note over B: ローカルコミット更新<br/>(HTLCを削除)
B->>M: revoke_and_ack<br/>(古いコミット取消)
B->>M: commitment_signed
Note over M: Mは2つの有効な<br/>コミットメントを保持:<br/>1. HTLCあり(古い)<br/>2. HTLCなし(新しい)
Note over B: Bは1つの有効な<br/>コミットメントのみ:<br/>HTLCなし
end
rect rgb(255, 255, 230)
Note over A,M: ステップ3:悪意のある強制クローズ
M->>BC: 古いコミットメントを<br/>ブロードキャスト<br/>(HTLCあり)
M->>BC: プリイメージで<br/>HTLCを請求
end
rect rgb(255, 230, 230)
Note over A,M: ステップ4:攻撃の成功
BC-->>B: オンチェーントランザクション<br/>を観察
Note over B: ❌ プリイメージ抽出失敗<br/>(HTLCがローカル<br/>コミットメントにない)
Note over B: プリイメージがないため<br/>Aから請求できない
Note over A,B: タイムアウト期限が切れる
BC-->>A: ✅ タイムアウトで返金
Note over B: 💸 Bが損失を被る
end
rect rgb(230, 255, 230)
Note over M: 攻撃結果:<br/>Mは両方の資金を獲得<br/>1. オンチェーンで請求した資金<br/>2. 返金された資金
end
タイムライン
- 2025-03-05:Bastienに脆弱性が報告される
- 2025-03-11:修正がマージされ、Eclair 0.12.0がリリース
- 2025-03-21:6ヶ月後の公開開示に合意
- 2025-09-23:公開開示
*1:0.11.0以下と書いてるけど、Eclairの初期から存在した脆弱性