作者:Johan & Lisa
编辑:77
9 月 6 日 13:52:10 UTC,Liquid 的 4,050,335 号区块里出现两笔结构相同的交易。一分钟后,4,050,336 号区块确认了第三笔,约 3,998.5 枚 L-BTC 进入 UTXO 集,背后没有任何一笔 peg-in。再过几分钟,这些并不存在的资产开始被归拢、消费,随后经联邦 peg-out 通道兑成了比特币主网上的真 BTC。
次日,3,400 BTC 退回了联邦 peg 钱包。剩下的 598.5 BTC 留在攻击者手里,到发稿时还在被反复自我转账,每笔交易附带一段 OP_RETURN 明文,向联邦索要一成"赏金"。谈判至今仍在链上公开进行。
我们把 4,050,335 前后的区块逐笔核对了一遍,Bitcoin 侧的资金流用 MistTrack 跟到了每一跳。这篇文章讲两件事,攻击者在技术上做了什么,以及一段 2016 年就进代码库的性能优化,为什么十年后能兑走四千枚比特币。
攻击概览
字段 | 详情 |
攻击类型 | 共识层缓存键碰撞(rangeproof 验证缓存投毒) |
受害项目 | Liquid Network(Elements 代码库,23.3.4 之前的全部版本,含 8 月 3 日补丁后的构建) |
攻击者地址 | ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w(Liquid 侧,共 91 笔交易、247 个输出全部花完) |
涉事合约 | 无(纯共识层交易级攻击) |
获利金额 | 凭空铸造约 3,998.5 L-BTC 并 peg-out 为真 BTC,返还 3,400 后实留 598.5 BTC,约占 15% |
所在链 | Liquid Network → Bitcoin 主网 |
交易数量 | Liquid 侧核心 5 笔(setup 两笔、铸造一笔、归拢消费两笔),另有 peg-out 与 Bitcoin 主网支付若干 |
Liquid 的钱是怎么管的
L-BTC 的发行只有一条正路。把 BTC 交进联邦管理的多签钱包,Liquid 侧才铸造等量的 L-BTC,反向走 peg-out 换回 BTC。这条一比一的锚定由机密交易机制看守。每个输出的金额不直接写在链上,藏进一个 Pedersen 承诺里;每个承诺必须附带一张 rangeproof,证明金额非负、没有越界。验证交易时,节点把输入和输出的承诺点分别求和,两边对得上,这笔账才算平。
只要有一个输出能塞进假 rangeproof,承诺里就可以藏一个负数,或者一个天文数字,凭空造币在数学上就成立了。所以 rangeproof 必须每一次都真验。
麻烦在于它贵。一张 rangeproof 通常四千多字节,验证是实打实的椭圆曲线运算,每个输出都要做一遍。2016 年 7 月 12 日,一笔标题为 Rangeproof caching 的提交(66ab07bd1b)进了 Elements 代码库,思路沿用比特币的签名缓存。验过的证明不再重验,"验证成功"这个结果存进一张缓存表,下次同样的输入直接放行。
这类优化在密码学代码里很常见。这次的全部问题,也出在它身上。
缓存键缺了什么
先看 23.3.3 发布版里的这段代码。
// src/script/sigcache.cpp,23.3.3 及更早版本voidComputeEntryRangeProof(uint256& entry,conststd::vector<unsignedchar>& proof,conststd::vector<unsignedchar>& commitment){CSHA256 hasher = m_salted_hasher_range_proof;hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Finalize(entry.begin());}
缓存键就是 proof 和 commitment 裸拼进同一条 SHA-256 流。proof 是变长字段,长的有四千多字节。这条键里没有资产,没有脚本,也没有任何字段边界信息。
今年 8 月 3 日的修复提交(c26d719)把资产承诺和锁定脚本补了进去,变成四个字段。方向没错,原先同一个证明确实可以配上不同的资产和脚本反复"复用"。补丁仍然把四个字段裸拼进同一条流。proof 和 scriptPubKey 都是变长字段,中间的边界可以从这里挪到那里。
9 月 8 日的加固提交(94000967f6)改用带长度前缀的序列化哈希。提交信息里有一段话说得很直白。
This serializes each field with a length prefix, so distinct argument tuples with byte-identical raw concatenations no longer collide to the same cache key. A cache entry is a positive verification result; a collision would let an attacker bypass verification.
缓存里存的是"验证通过"。键撞了,就等于替一个从没验过的东西作了保。同一笔提交还发现 surjection proof 的缓存键里连输入资产标签都没绑,一并修掉了。
命中即免验
放行发生在 CachingRangeProofChecker::VerifyRangeProof 的开头。
uint256 entry;rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment,vchAssetCommitment, scriptPubKey);if (rangeProofCache.Get(entry, !store)) {returntrue; // 命中即放行}// 未命中才往下走// secp256k1_rangeproof_verify 和 min_value == 0 检查全在后面
命中直接返回 true。后面的 secp256k1_rangeproof_verify、min_value 检查,一行都不会执行。
缓存的写入和清除各有一条规则,这是攻击时序的关键。交易进内存池时按可写入模式校验,验过的结果存进缓存,供将来进块时复用。区块连接时则相反,只查询、不写入,查到一次就打上可回收的标记。validation.cpp 第 2340 行的注释把后半条规则写得很清楚,原文是 Don't cache results if we're actually connecting blocks (still consult the cache, though)。
缓存条目是消耗品。攻击者要保证的只有一件事,趁目标节点还留着这条"已验证"记录的窗口,把携带碰撞载荷的交易递过去。
每个节点的缓存键还带一段启动时生成的随机盐。盐救不了它。盐加在所有字段的最前面,两条流本身就相同,加不加盐都相同,碰撞于是在每个节点上独立成立。
链上的八分钟
攻击从 13:52:10 开始,区块 4,050,335,两笔结构相同的 setup 交易被打进同一个区块。每笔带一个 OP_RETURN 输出,这个输出本身干干净净。资产是明文 L-BTC(6f0279e9…),金额承诺 C0(09d6c615…)附带一张诚实合法的 rangeproof P0,节点按正常流程验过。载荷藏在它的锁定脚本里。
6a43 ‖ C1 ‖ X ‖ 6a0x43 是 67,正好把后面 67 个字节当作数据推入。C1(
086f5d67…)是攻击输出准备使用的假金额承诺。X(0a0a488de489…)是一个盲化形式的资产承诺,单看字节读不出对应哪种资产。我们用仓库里的 secp256k1 实算过 L-BTC 的明文生成元,是 0ab963d4 开头,和 X 不是同一个点。这两个值作为字面数据躺进脚本,验证时只被当作普通数据。验完,每个节点的缓存里多了一条记录,键是这四个字段的裸拼接流。
中间还有一段 primer 的工夫。区块连接会把缓存条目用掉,攻击者就拿同样载荷的交易在内存池里反复播种,把各节点的缓存重新填上。具体哪几笔是 primer,从区块数据里反推不出来,它们可能根本没进块。两笔 setup 挤在同一个区块,大概也是为了条目被消耗后能马上补一手。
13:53:10,区块 4,050,336,铸造交易出现。它有一个核心输出,四个字段这样填。proof 放 P0 再跟 68 个字节的尾巴,尾巴内容是 C0、A、6a43,A 是 setup 输出自身的资产承诺。commitment 放 C1。asset_commitment 放 X。scriptPubKey 只剩一个字节,6a。
proof commitment asset scriptPubKeysetup 输出 P0 C0 A 6a43 ‖ C1 ‖ X ‖ 6a铸造输出 P0 ‖ C0 ‖ A ‖ 6a43 C1 X 6a
四个字段两两不同,把它们依次裸拼进 SHA-256,两条流逐字节相同。
节点验到这个输出,算键,命中,返回 true。secp256k1_rangeproof_verify 从未碰过这张证明。C1 背后的金额要多大有多大,再由负值承诺的输出把 Pedersen 求和抵平,约 3,998.5 枚 L-BTC 就这样进了 UTXO 集。
13:54:10 和 14:00:10,区块 4,050,337 和 4,050,343,两笔交易把相关 UTXO 归拢花出。peg-out 请求由联邦照常授权,等值真 BTC 经 Bitcoin 主网付出,收款地址 bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte。从第一笔 setup 落块到资金开始归拢,前后八分钟。
返还与链上谈判
第二天,3,400 BTC 被转回联邦 peg 钱包 bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr。剩下的 598.5 BTC 留在原地,9 月 8 日到 10 日在 966,052 至 966,199 号区块间被反复自我花销,每笔附带 OP_RETURN 明文。其中一句写着 "You SHALL pay 10% using your own money as bug bounty",落款留了 Telegram 账号 @MRBOND_1。留言里以白帽自居,要求联邦自掏腰包,按 10% 支付一笔正式赏金。谈判至今仍在链上公开进行。
MistTrack 资金追踪
在明确事件基本经过后,我们进一步对攻击者在 Liquid 和 Bitcoin 主网上的资金流向进行了追踪,并使用 MistTrack 对 Bitcoin 侧关键地址进行分析,再与链上数据交叉核验。
Liquid 侧,攻击者使用地址 ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w 完成相关资产的归拢和消费。该地址共涉及 91 笔交易、247 个输出,目前已全部花费。由于 MistTrack 目前尚未覆盖 Liquid 链,因此 Liquid 侧从 L-BTC 铸造、UTXO 消费到后续 peg-out 的资金流,主要以区块浏览器数据逐笔核验。

(https://blockstream.info/liquid/address/ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w)
在 Bitcoin 主网一侧,约 3,998.5 BTC 的 peg-out 资金最终进入地址 bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte。MistTrack 对该地址的风险评分为 100/100,评级为 Severe,标签包括 Malicious Address / Involved Theft Activity,并与 Theft 类非法活动实体一跳直连,关联度为 100%。

(https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte)
从资金来源来看,该地址收到的 BTC 并不存在对应的正常资金本金,其来源可以追溯至前述 L-BTC 的铸造及后续 peg-out,攻击者实际承担的主要是相关交易手续费。
随后,这笔资金分成两部分。其中 3,400 BTC 于次日转入联邦 peg 钱包 bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr。通过 MistTrack 交易调查进行核验时,该地址也是 bc1ql4mfu… 转出目标中金额最高的地址,对应转账金额为 3,400 BTC,与链上数据一致。

(https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte)
剩余约 598.5 BTC 则仍由攻击者控制。9 月 8 日至 10 日期间,这部分资金在 Bitcoin 区块 966,052 至 966,199 之间持续发生自我转账,并通过 OP_RETURN 留言与联邦进行公开谈判。其中一条留言写有 “You SHALL pay 10% using your own money as bug bounty”,并留下 Telegram 账号 @MRBOND_1。目前相关谈判仍可在链上观察到。

(https://www.oklink.com/bitcoin/address/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte)
综合目前的链上数据,攻击者从约 3,998.5 BTC 的 peg-out 资金中返还了 3,400 BTC,剩余约 598.5 BTC 仍处于其控制范围内,约占 peg-out 金额的 15%。后续我们将继续监控相关地址及资金变化。
结语
这段代码在仓库里待了十年,被无数节点跑了无数遍。8 月 3 日修过一次,9 月 6 日还是被打了。第一版补丁把缺的字段补进了哈希,漏掉字段边界,五周后边界被精确利用。9 月 8 日发布的 Elements 23.3.4 除了把键长度前缀化,还加了 -norangeproofcache 急停开关,运维可以不放行任何缓存结果,直接关掉这张表。
这次的教训可以压得很短。密码学验证路径上的正向结果缓存,命中即免验,它的键必须按密码学原语的标准对待。验证函数读进去的每个字段、每个字段的边界,都要无歧义地进哈希,长度前缀就是干这个的。缓存、记忆化、并行化这类性能优化,只要落在验证路径上,审计时就应当与被优化的密码学原语同等待遇。
慢雾安全团队建议项目方在部署前做一次完整的外部安全审计。共识层的审计清单里,验证缓存的键完整性应当列为必检项。
往期回顾
美国 OFAC、DOJ 联手打击新币担保,超 5200 万美元加密资产遭限制
消失的负债 —— Notional Finance 被黑分析
威胁情报|iOS Safari DarkSword 窃取钱包资产

慢雾导航
慢雾科技官网
https://www.slowmist.com/
慢雾区官网
https://slowmist.io/
慢雾 GitHub
https://github.com/slowmist
Telegram
https://t.me/slowmistteam
https://twitter.com/@slowmist_team
Medium
https://medium.com/@slowmist
知识星球
https://t.zsxq.com/Q3zNvvF