找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4740

积分

0

好友

616

主题
发表于 昨天 17:47 | 查看: 11| 回复: 0

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 才摸清了利用路径。

下面从代码层面把整个过程捋一遍。

Liquid Network 攻击全流程:从 cache 种值到 peg-out 提走 4000 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,下次遇到相同的 proof 就直接跳过验证。

修复前后 cache key 构造对比:Bug A 上下文缺失与 Bug B 拼接碰撞

问题就出在 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_generatorscriptPubKey

后果很直观:一个 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_commitmentscriptPubKey 也加进了 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 字节)是定长的,但两端的 proofscriptPubKey 是变长的。

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

具体做法是:把攻击组的 C1X(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 碰撞构造:引物组和攻击组拼接后字节流完全相同

0x04 攻击过程

整个攻击分三步走。

第一步:种缓存(Block 4050335)

大约 12:30 UTC,攻击者在 block 4050335 里广播了两笔引物交易(71c93d43f411271147107ec5)。

每笔交易有一个输出:

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

Liquid 共识分裂:functionary 接受方与普通节点拒绝方的流程对比

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_commitmentscriptPubKey

攻击者只需要读这个 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 分析



上一篇:Linux 与 Windows 11 性能实测:AI 推理、视频编码和日常任务谁更快?
下一篇:DDD 落地实战:支付系统重构如何划清一致性边界
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-10 16:57 , Processed in 1.597086 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表