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,下次遇到相同的 proof 就直接跳过验证。

问题就出在 cache key 的计算方式上。看一下修复前的 ComputeEntryRangeProof:
// src/script/sigcache.cpp (修复前)
void ComputeEntryRangeProof(
uint256& entry,
const std::vector<unsigned char>& proof,
const std::vector<unsigned char>& 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 (修复后)
void 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)
{
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。

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 |
说明 |
| #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 命中,跳过验证,接受 |

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 就能得出两个结论:
- 旧版本的 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 分析