Liquid Network 3.2 亿美元安全事件技术分析
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 才知道怎么利用的。
下面从代码层面扒一下整个过程。

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,下次碰到就跳过验证。

问题出在 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_RETURN69 个字节,看起来是一段不可花费的 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。

0x04 攻击过程
整个攻击分三步。
第一步:种缓存(Block 4050335)
~12:30 UTC,攻击者在 block 4050335 里广播了两笔引物交易(71c93d43f411 和 271147107ec5)。
每笔交易有一个输出:
Asset: 显式 L-BTC
Commitment:
09d6c61583f5(C0,合法值)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 | 说明 |
|---|---|---|---|---|---|
L-BTC | 08360f95约 +4.18x10^18 sats | 4,174B 合法 | P2WPKH 攻击者地址 | 可花费的巨额正值 | |
L-BTC | 086f5d67约 -4.18x10^18 sats | 4,234B 无效 | 6a(OP_RETURN) | 不可花费的负值 | |
committed | - | 4,174B 合法 | - | 另一个机密输出 | |
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 命中,跳过验证,接受 |

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 |
9 月 1 日 | 合并到 master |
9 月 2 日 | cherry-pick 到 |
9 月 3-4 日 | PR #1599 公开提交到 |
9 月 6 日 ~12:40 | 攻击发生 |
9 月 6 日 19:20 | elements-23.3.x backport 才正式 merge( |
commit c26d719c29 坐了整整一个月没合并。合并后又公开提了 PR,12 个 commit 里面有一个叫 "Range proof cache binding"(原始的标题更直白:Fix caching bug in rangeproof caching)。diff 清清楚楚地展示了修复前 cache key 只有 proof + commitment 两个字段,修复后加了 asset_commitment 和 scriptPubKey。
攻击者只需要读这个 diff 就知道:
旧版本的 cache key 缺少上下文字段(Bug A 可利用)
新版本的拼接方式没有长度分隔符(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 分析