Liquid Network 3.2 亿美元安全事件技术分析

· 2026-09-08 22:51 · 6 阅读

tonghuaroot 2026-09-08 22:51 韩国

2026年9月6日,Liquid Network因Elements的rangeproof缓存key未绑定asset generator和scriptPubKey上下文,被攻击者伪造L-BTC并通过peg-out提走约4,000 BTC。修复补丁的字段拼接缺少长度分隔符又引入了碰撞风险,且公开PR的diff直接暴露了利用方式。

9 月 6 号下午 Liquid Network 被人提走了大约 4,000 BTC,接近 3.2 亿美元。攻击者在链上留了句 "We are a white hat. Contact us on-chain.",后来还了 3,400 BTC,扣下 598.5 BTC 没还。

这个漏洞的根因我觉得挺有意思的,不是什么复杂的密码学攻击,本质上是一个缓存的 key 构造错误。而且修复 patch 本身又引入了新 bug,公开 PR 的标题直接写着 "Fix caching bug in rangeproof caching",攻击者大概率就是看了这个 diff 才知道怎么利用的。

下面从代码层面扒一下整个过程。

攻击全流程:从种缓存到提走真 BTC
攻击全流程:从种缓存到提走真 BTC

0x01 背景:Liquid 的 Confidential Transaction

Liquid 是 Blockstream 做的 Bitcoin 侧链,底层用的开源软件叫 Elements。跟 Bitcoin 主链不同,Liquid 的交易金额是隐藏的,用的是 Confidential Transactions 方案。

简单说就是交易金额不再是明文,而是用 Pedersen Commitment 表示:

C = v * G + r * H

其中 v 是金额,G 是资产对应的 generator,r 是 blinding factor,H 是固定的 Pedersen base point。外人只能看到 commitment C,看不到 v 是多少。

但这里有个问题:Pedersen Commitment 本身不限制 v 的范围。如果没有额外约束,攻击者可以构造一个 v 为负数的 commitment,凭空增发资产(正值输出和负值输出相消,数学上交易是平衡的,但实际上正值那部分是凭空出来的)。

所以每个 confidential 输出都必须附带一个 rangeproof,证明 v 在合法范围 [0, 2^64) 内。rangeproof 的验证绑定了三个上下文参数:value commitment、asset generator 和 scriptPubKey。

0x02 Bug A:缓存 key 少了两个字段

rangeproof 验证有计算开销,Elements 做了缓存优化。验证通过的 proof 把结果存进 cache,下次碰到就跳过验证。

Bug A 和 Bug B:两个 cache key 构造问题
Bug A 和 Bug B:两个 cache key 构造问题

问题出在 cache key 怎么算的。看一下修复前的 ComputeEntryRangeProof

// src/script/sigcache.cpp (修复前)voidComputeEntryRangeProof(
    uint256& entry,
conststd::vector<unsignedchar>& proof,
conststd::vector<unsignedchar>& commitment)
{
    CSHA256 hasher;
    hasher.Write(proof.data(), proof.size())
          .Write(commitment.data(), commitment.size())
          .Finalize(entry.begin());
}

cache key = SHA256(proof + commitment),就两个字段。

但 secp256k1_rangeproof_verify 实际上验证的是 proof 在 (commitment, asset_generator, scriptPubKey) 三元组下的合法性。cache key 少了 asset_generator 和 scriptPubKey

后果是:一个 proof 在某个 (asset, script) 上下文里验证通过后被缓存。之后在完全不同的 (asset, script) 上下文里出现相同的 proof + commitment,cache 命中,验证直接跳过。

这个 bug 从 2019 年 3 月缓存功能引入就一直存在,跨了 99 个 release 版本。

调用的地方在 CachingRangeProofChecker::VerifyRangeProof 里:

// 修复前的调用
rangeProofCache.ComputeEntryRangeProof(
    entry, vchRangeProof, vchValueCommitment);

少传了两个参数,就这么简单。

0x03 修复和 Bug B:拼接无分隔符

8 月 3 号有人写了修复 commit c26d719c29,把 asset_commitment 和 scriptPubKey 也加进了 cache key:

// src/script/sigcache.cpp (修复后)voidComputeEntryRangeProof(
    uint256& entry,
conststd::vector<unsignedchar>& proof,
conststd::vector<unsignedchar>& commitment,
conststd::vector<unsignedchar>& asset_commitment,
const CScript& scriptPubKey)
{
    CSHA256 hasher;
    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());
}

看起来对了。四个字段全进了 hash。

但注意 CSHA256::Write 就是把字节流往 hasher 里追加,没有写长度前缀也没有分隔符。实际上 hash 的输入是:

proof || commitment || asset_generator || scriptPubKey

四段字节直接拼接。中间的 commitment(33 字节)和 asset_generator(33 字节)是定长的,但两头的 proof 和 scriptPubKey 是变长的。

如果攻击者能构造两组不同的四元组 (P, C, A, S),使得拼接后的字节流完全一致,那 SHA-256 就会碰撞,cache key 相同。

具体做法:把攻击组的 C1 和 X(asset generator)嵌入引物组的 scriptPubKey 里。引物组的 script 设为:

S0 = 6a 43 || C1 || X || 6a
     ^^^^^              ^^^^
   OP_RETURN+PUSH67    OP_RETURN

69 个字节,看起来是一段不可花费的 OP_RETURN 数据,实际上藏着攻击组需要的 commitment 和 generator。

然后两组的拼接字节流:

引物组: [P0 (4,166B)] || [C0 (33B)] || [X (33B)] || [S0 (69B)]
      = 4,301 字节
攻击组: [P1 (4,234B)] || [C1 (33B)] || [X (33B)] || [S1 (1B)]
      = 4,301 字节
其中 P1 = P0 || C0 || X || 6a 43
     S1 = 6a

引物组的 proof 短,script 长(塞了 C1 和 X)。攻击组的 proof 长(把引物 script 里的东西挪到了 proof 尾部),script 短(bare OP_RETURN)。总长度一样,字节流完全相同,SHA-256 碰撞。

两组的 SHA-256 都是 82b0b8cc9c01a

Cache key 碰撞构造:引物组和攻击组拼接后字节流完全相同
Cache key 碰撞构造:引物组和攻击组拼接后字节流完全相同

0x04 攻击过程

整个攻击分三步。

第一步:种缓存(Block 4050335)

~12:30 UTC,攻击者在 block 4050335 里广播了两笔引物交易(71c93d43f411 和 271147107ec5)。

每笔交易有一个输出:

  • Asset: 显式 L-BTC

  • Commitment: 09d6c61583f5C0,合法值)

  • Rangeproof: 4,166 字节(P0),密码学上合法(min_value=0, max_value=2^52-1)

  • Script: 6a 43 || C1 || X || 6a(69 字节,不可花费的 OP_RETURN)

第一笔交易的 rangeproof 被真正验证,通过后存入 cache。第二笔的相同 proof 命中 cache,确认机制生效。

此时 functionary 节点的 cache 里有了 key 82b0b8cc9c01a 对应的条目。

那个 X 是 L-BTC 的 asset generator 的精确字节序列化,可以从 L-BTC 的 asset ID 6f0279e9526d 通过 secp256k1_generator_generate() 的 Shallue-van de Woestijne 映射确定性导出,任何人都能算出来。

第二步:伪造 L-BTC(Block 4050336)

~12:40 UTC,攻击交易 f24a4b17183f 进入 block 4050336。四个输出:

Output

Asset

Value

Proof

Script

说明

#0

L-BTC

08360f95

 约 +4.18x10^18 sats

4,174B 合法

P2WPKH 攻击者地址

可花费的巨额正值

#1

L-BTC

086f5d67

 约 -4.18x10^18 sats

4,234B 无效

6a

 (OP_RETURN)

不可花费的负值

#2

committed

-

4,174B 合法

-

另一个机密输出

#3

58 sats

明文

无需 proof

-

手续费

Output #1 的 rangeproof P1 密码学上是无效的,在任何上下文下验证都不会通过。但它的 cache key 跟引物交易碰撞了:

P1 = P0 || C0 || X || 6a 43   (4,234 字节)

拼上 C1 || X || 6a 之后,总字节流与引物组完全一致。cache 命中,secp256k1_rangeproof_verify 从未被调用。

Pedersen Commitment 的代数性质保证输入输出的 commitment 之和相等。Output #0 的巨额正值和 Output #1 的巨额负值相消,交易在数学上平衡。但 Output #0 的正值是凭空出来的,攻击者可以花它。

Output #1 用 bare OP_RETURN 锁定,永远不可花费,不会影响后续交易。

第三步:Peg-out 提真 BTC

~12:44 和 ~12:49 UTC,攻击者用 sendtomainchain 发起两笔 peg-out:2.65 BTC 和 3,996 BTC。

Liquid 的 peg-out 机制是:用户在 Liquid 上销毁 L-BTC,Federation 的 15 个 functionary 在确认足够 Liquid 区块后,用 11-of-15 多签在 Bitcoin 主链上付款。

14:25:13 UTC,Federation 的 batched 付款交易 8db751a6b140 在 Bitcoin 主链确认(block 965783)。攻击者收到真 BTC。

从种缓存到收到钱,不到两个小时。

0x05 网络分裂

这次攻击造成了 Liquid 网络的共识分裂,而且分裂的原因很诡异:同一个 block,不同节点的处理结果取决于节点本地 cache 的状态。

软件版本

缓存状态

对 block 4050336 的处理

修复前 (<=23.3.3)

任意

实际验证 P1,失败,拒绝

修复版

未被种缓存

实际验证 P1,失败,拒绝

修复版

被引物种了缓存

cache 命中,跳过验证,接受

共识分裂:接受方 vs 拒绝方
共识分裂:接受方 vs 拒绝方

Functionary 节点恰好全跑的是未发布的修复版代码(elements-23.3.4rc2 级别),而且被引物交易成功种了缓存。所以 functionary 接受了这个 block,继续出块,处理了 peg-out 请求并在 Bitcoin 主链上付了款。

跑已发布版本(<=23.3.3)的节点全部拒绝了这个 block,tip 冻结在 block 4050335。有一条公开的节点日志显示,一个 elements-23.3.4rc1 节点在 block 4050336 报了 "ConnectBlock e1d9a2aa failed, block-validation-failed"。

这个分裂在区块数据层面是不可见的。两个节点看到完全相同的 block 数据,但因为本地 cache 状态不同,得出不同的验证结论。block 本身不包含任何信息能让你从外部判断哪边是对的。

另外 cache 的实现里有个细节:mempool 验证用 Get(entry, erase=false) 不删缓存条目,但 block validation 用 Get(entry, erase=true) 会删。所以接受了攻击 block 的节点,cache 条目被删了,之后如果重启或 reorg 需要重新验证这个 block,就会失败。这些节点必须手动 invalidateblock 才能回到正确的链。

0x06 补丁变成了利用说明

这是整件事最让人无语的部分。

补丁从写好到攻击发生的时间线
补丁从写好到攻击发生的时间线

日期

事件

8 月 3 日

修复 commit c26d719c29 写好

9 月 1 日

合并到 master

9 月 2 日

cherry-pick 到 elements-23.x(commit 6253d7e103

9 月 3-4 日

PR #1599 公开提交到 elements-23.3.x,标题 "Cherry picks from 23.x into 23.3.x",说明写着 "in preparation for 23.3.4rc2"

9 月 6 日 ~12:40

攻击发生

9 月 6 日 19:20

elements-23.3.x

 backport 才正式 merge(3b3f01eac9

commit c26d719c29 坐了整整一个月没合并。合并后又公开提了 PR,12 个 commit 里面有一个叫 "Range proof cache binding"(原始的标题更直白:Fix caching bug in rangeproof caching)。diff 清清楚楚地展示了修复前 cache key 只有 proof + commitment 两个字段,修复后加了 asset_commitment 和 scriptPubKey

攻击者只需要读这个 diff 就知道:

  1. 旧版本的 cache key 缺少上下文字段(Bug A 可利用)

  2. 新版本的拼接方式没有长度分隔符(Bug B 可利用)

更离谱的是 functionary 节点在 PR 公开之前就部署了未发布的修复版代码。用 git tag --contains 查修复相关的三个 commit,没有任何一个 release tag 包含它们。签名节点跑的是未发布的 rc 版本,没有做过对抗性审查,而这个未发布的修复本身就有新 bug(Bug B)。

0x07 剩余风险

截止 9 月 7 日,两个 bug 都还在:

Bug A 存在于所有已发布的版本(<=23.3.3, <=23.4.0rc3)。任何跑 release 版本的节点仍然可以用上下文缺失的方式攻击。

Bug B 存在于所有包含修复的构建。没有任何 branch 上有后续的分隔符修复。同样的碰撞攻击可以在修复版节点上重放,只需要用新的引物交易重新种缓存。

原文里写得很直白:

Bug A is live in every released version; Bug B is live in every build of the fix; and the patch leaves the fragile cache-as-consensus design in place...no delimiting follow-up exists in any branch.

网络仍然可被利用。

0x08 11-of-15 多签没有用

最后说一下 Liquid 的信任模型。Federation 钱包用的是 11-of-15 多签,87 个成员组织,15 个轮值 functionary 负责签名,需要至少 11 个同意才能动钱。

这次攻击一把密钥都没偷。Peg-out 请求在 Liquid 链上是合法的(cache 命中后验证通过),functionary 按正常流程签了字、付了款。多签机制在这里完全没有起到防护作用。

多签保护的是 "没有授权的人不能动钱"。但这次的情况是 "授权流程本身被骗了",functionary 认为请求是合法的,所以自愿签了字。

对于侧链来说,共识层的代码正确性比密钥管理更关键。密钥没丢一把,3.2 亿美元照样没了。

参考:

  • Technical Analysis (gist)

  • Elements PR #1599

  • Elements commit c26d719c29

  • CoinDesk 报道

  • Bitcoin Foundation 分析

跳转微信打开