2026年9月6日に、BlockstreamのLiquid Networkから約4,000BTCが流出した件(サイドチェーン上の資金の約95%が抜かれた)について、その攻撃手法について確認してみた(※ 現時点では、Blockstreamから原因に対する公式な発表はまだない)。
Confidential Transactionと範囲証明
まず前提として、Liquid Networkでは取引金額を第三者に対して秘匿するConfidential Transaction(CT)という仕組みを採用している(CTについては過去のブログ記事やGBEC動画参照)。
トランザクションの各アウトプットの金額とアセット種別は、以下のようなPedersen Commitment(楕円曲線上の点)として表現される。
C = v・H(asset) + r・G
- Gは楕円曲線上の生成点(固定値)
- vは秘匿対象となる金額
- rはブラインドファクター
- H(asset)は、アセット毎に決まる生成点*1
一般的に、トランザクション内で勝手にコインを新規発行されていないか(=アウトプットのコインの合計額がインプットのコインの合計額を超えていないか)をチェックする必要がある。Bitcoinのような価格が明示的に示されているケースであればこれは簡単にチェックすることができるけど、金額が上記のように秘匿されている場合、以下の2つのチェックが必要になる。
- バランスチェック(
secp256k1_pedersen_verify_tally):インプットの金額のコミットメントの総和とアウトプットの金額のコミットメントの総和を引いて、結果が単位元になるかをチェックすることで、インプット/アウトプットのバランスが釣り合っていることをチェックする。 - 範囲証明(
secp256k1_rangeproof_verify):秘匿されているvの値がの範囲内に収まっているかどうかチェックする。これがマイナスの金額になっているとバランスチェックをパスする形でインフレーションが可能になるので、バランスチェックとセットで必要になる。
加えて、LiquidのCTでは、コミットメントで使用されているアセットの生成点が正しいアセットに対応しているかをチェックする必要がある。上記のアセットの生成点H(asset)は実際には、アセットを識別できないように、
H' = H(asset) + b・G
のようにランダムに選択されたブラインドファクターbを用いてブラインドされる。このようにブラインドされた状態でもアウトプットのコミットメントのアセット生成点がインプットで使用されている生成点と対応しているかチェックするための仕組みがAsset Surjection Proof(詳細は以前のConfidential Assetsの記事参照)。
キャッシュの実装の脆弱性
今回突かれたのは、上記の範囲証明のチェックのキャッシュについてのノードの実装ミスになる。
範囲証明のチェックを行うsecp256k1_rangeproof_verifyはコスト高な処理のため、Elementsでは一度検証に成功した範囲証明の結果をキャッシュし、同じ範囲証明の再検証をしないようにしている。問題は、この同じ範囲証明か?を判定するために使われるキャッシュキーの作り方にあった。
バグA
Elementsでは2018年の0.17.00.14.1時点で、キャッシュのキーは以下のように、salt(ノード起動時に初期化されるランダム値)と範囲証明とコミットメントのデータのハッシュ値で構成されるようになった。
void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) const { CSHA256 hasher = m_salted_hasher_range_proof; hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Finalize(entry.begin()); }
一方、範囲証明を検証するロジックであるVerifyRangeProofでは、これ以外に、アセットの生成点(tag)とscriptPubKey(extra_commit)にも依存している↓
secp256k1_rangeproof_verify(ctx, &min, &max, &commit, proof.data(), proof.size(), scriptPubKey ..., // extra_commit scriptPubKey.size(), &tag); // asset generator
つまり、検証側ではアセットとスクリプトに依存しているのに、キャッシュのキーはそれをカバーしていない。そのため、正規のアウトプットに対して検証およびキャッシュされた証明を、コミットメントは同じままアセットだけ差し替えたアウトプットに流用すると、キーが一致してキャッシュにヒットし、実行すれば失敗するはずの検証がスキップできる可能性がある。ただ、
- 同じコミットメント値を再利用し、
- 最初にキャッシュにのせるための証明は本物であること
といった制約から、実際にインフレーションまでもっていくのはハードルが高い。
バグB
2026年に行われたバグAを塞ぐ修正では、キーにasset_commitmentとscriptPubKeyが追加された(コミットc26d719)。
void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& asset_commitment, const CScript& scriptPubKey) const { CSHA256 hasher = m_salted_hasher_range_proof; hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Write(asset_commitment.data(), asset_commitment.size()).Write(scriptPubKey.data(), scriptPubKey.size()).Finalize(entry.begin()); }
つまり、キャッシュのキーは以下の連結データのハッシュ値
salt(32 byte) || proof(可変長) || commitment(33 byte) || asset_commitment(33 byte) || scriptPubKey(可変長)
問題となったのは、連結する際に区切りがなく、可変長データが存在すること。
実際のエクスプロイト
実際にオンチェーンで確認できたデータによると、攻撃者はまず細工したscriptPubKeyと正当な範囲証明を持つアウトプットを持つプライマートランザクションを作成し、キャッシュに正規のエントリーを乗せる。このときのキーは以下のような構成:
<salt> || <有効な範囲証明> || <有効な金額> || <L-BTC> || OP_RETURN <負の金額> <L-BTC> OP_RETURN
scriptPubKeyは 6a43 || C1 || X || 6a(6a=OP_RETURN、43=続く67バイトのpush)という構造で、C1(33 byteのコミットメント)、X(L-BTCのアセット生成点)、6a を意図的に並べてある。
次に、無効な範囲証明と巨大な負の値のOP_RETURNアウトプットを持つエクスプロイトトランザクションを作成する。このとき、無効な証明はその末尾がプライマーの<有効な金額> <L-BTC> OP_RETURNと一致するように作る。
<salt> || <有効な範囲証明> <有効な金額> <L-BTC> OP_RETURN || <負の金額> || <L-BTC> || OP_RETURN
すると、||の連結の位置は違うけど、連結後のデータは全く同じになる。
プライマートランザクションで、本物の範囲証明は検証をパスしてキャッシュされ、その後でエクスプロイトトランザクションが処理されると、キャッシュヒットにより範囲証明のチェックはスキップされる。エクスプロイトトランザクション内にさらにこの負の金額と同じだけの正の金額を同居させれば、pedersen_verify_tallyのバランスチェックもパスし、インフレーションが成功する*2。
生成された偽のL-BTCは、その後SideSwapのペグアウトサービスに正面から投入され、当然ながらSideSwapは偽物と本物を区別できず、正規のペグアウト認可でL-BTCをバーンし、「コンセンサス上有効な」引き出しに署名することになったと。
修正
9月9日にリリースされた修正版のElements v23.3.4では、キーの生成は以下のようにデータの長さをプレフィックスとして付加するように修正された(コミットb0a2752)*3。結果、上記のような細工はできなくなる。
void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& asset_commitment, const CScript& script_pub_key) const { HashWriter hasher = m_salted_hasher_range_proof; // We commit to both commitments and the scriptPubKey because these are // committed to by the rangeproof itself; a change in any of them would // invalidate the proof. Since these are exactly the arguments to // CachingRangeProofChecker::VerifyRangeProof (below), there is no // additional data that could affect the rangeproof's validity. // Serialization length-prefixes every field, including the variable-length // proof and script, so distinct argument tuples cannot share an encoding. hasher << proof << commitment << asset_commitment << script_pub_key; entry = hasher.GetSHA256(); }
また、合わせてキャッシュを完全に無効化する-norangeproofcache起動オプションも追加された模様。
今回の脆弱性はコンセンサス仕様のバグではなく、範囲証明の検証を高速化する最適化コードの実装バグ。しかしその検証キャッシュが「検証をスキップするか否か」を決める位置にあるため、影響はコンセンサスクリティカルなものになった。実際、バグBを含むバージョンのElementsを動かすノードがインフレーションブロック(4,050,336)を受理・確定させ、バグBを含まない公式版のノードはこれを拒否して4,050,335で停止するチェーンスプリットが発生。ここで資金流出が現実化したのは、ペグアウトのBTC送金を承認するwatchmanが、この受理側フォークを正当なチェーンとして扱い、バーンされた(偽の)L-BTCに対してメインチェーンのBTCを払い出したためと思われる。サイドチェーンのブロック受理では多数派が正しく拒否していたものの、メインチェーンのBTC送金はそれとは独立に、受理側に追随した連合の署名によって実行された。なお、個々の連合のエンティティが具体的にどのバイナリを動かしていたかは公表されておらず、確定にはBlockstreamの公式発表を待つ必要がある。
9/24にBlockstreamの公式発表が公開された。
これによると、事件の時点で15のfunctionaryはすべて同じ修正済み(バグBを含む)ビルドを動かしていた。したがってスプリットは「バグBを含むか否か」というバージョンの差ではなく、各ノードのキャッシュ状態の差によって生じた。攻撃時に、攻撃に使われたのと同じキャッシュエントリを既に保持していたノードは、範囲証明の検証をスキップしてブロックを受理した。一方、コールドキャッシュのノード(あるいはバグBの修正がまだ適用されておらずバグAのままだったノード)は、実際に範囲証明を検証して失敗し、ブロックを拒否した。攻撃者が、Liquid上に恒常的に現れるL-BTCのアセットタグとよく使われる宛先スクリプト(ペグアウトアドレスなど)を再利用してasset commitmentとscriptPubKeyの2フィールドを固定したため、キャッシュを汚染するための土台となる正規のエントリは、日常的なトランザクションによって絶えず生成される状態だった。
ここで資金流出が現実化したのは、ペグアウトのBTC送金を承認するwatchmanが、受理側のチェーン状態を正当なものとして扱い、バーンされた(偽の)L-BTCに対してメインチェーンのBTCを払い出したため。サイドチェーンのブロック受理では割れが生じていたものの、メインチェーンのBTC送金はそれとは独立に、インフレーションしたL-BTCを「コンセンサス上有効」と認識したfunctionaryの署名によって実行された。なお、Blockstreamはこの流出について、SideSwapのPAKもオンラインキーも侵害されておらず、インフレーションL-BTCが正規のペグアウト手順を通ったことによるものだと説明している。
参考
- Liquid Network Incident Analysis
- Liquid Hack September 2026
- Elements rangeproof cache consensus failure
- liquid-transaction-replay-monitor
*1:単純にBitcoinのみをアセットとして使用する場合は、このHは単一の固定値でよく、LiquidのConfidential Assetsの場合は、Bitcoin以外のアセットも扱えるようにするためにこのようなアセット毎の生成点を導入している。
*2:厳密には、プライマートランザクションがブロックに格納されて、そのブロックが各ノードに接続されるとプライマートランザクションのアウトプットの範囲証明はキャッシュから消える。この状況でエクスプロイトトランザクションをブロックに格納して各ノードに有効と判定させるためには、その直前に別のプライマートランザクションをmempoolに入れて再度キャッシュを汚染する必要がある。
*3:<<演算子は、各可変長データに対してCompactSize形式の長さのプレフィックスをつける