| 时间 | 2023 年 7 月 30 日 |
| 涉案 | 约 $7000 万 |
| 结局 | 大部分被白帽或攻击者归还 |
| 受影响 | 用 Vyper 0.2.15 / 0.2.16 / 0.3.0 编译的部分池子 |
| 类别 | ⚠️ C6 工具链与编译器 |
一、背景:源码里的锁
出事的几个 Curve 池,源码里明明白白写着重入保护:
@external
@payable
@nonreentrant('lock') # ⭐ 重入锁在这里
def add_liquidity(...):
...
@external
@nonreentrant('lock') # ⭐ 同一把锁
def remove_liquidity(...):
...
⭐ @nonreentrant('lock') 的语义应该是:所有标注了同一个锁名的函数互斥——其中任何一个在执行时,其余的都不能被进入。
源码完全正确。审计这份源码,一百遍也审不出问题。
二、漏洞在哪:编译器生成的锁不起作用
Vyper 的特定版本(0.2.15、0.2.16、0.3.0)在实现这个装饰器时存在缺陷,导致生成的字节码没有真正建立起互斥关系——本该共享同一把锁的函数,在编译产物里没有正确地互相排斥。
⭐ 于是:
源码层面:add_liquidity 和 remove_liquidity 互斥 ✅
字节码层面:⚠️ 它们【不互斥】
⟹ 攻击者可以在 remove_liquidity 把 ETH 打给他的那一刻,
回头调用 add_liquidity ——
⚠️ 而这正是 @nonreentrant 本该阻止的事。
⭐⭐ 这一篇在整个档案里的独特之处:
前面十九篇,只要你足够仔细地读源码,理论上都能发现问题。 这一篇不能。 源码是对的,缺陷在从源码到字节码的那一步。
三、攻击复盘
攻击的形状和第 1 篇的 The DAO 完全一样——因为锁失效之后,剩下的就是一个没有保护的先转账后记账:
① 攻击者调用 remove_liquidity 移除流动性
│
├─ 池子销毁 LP 代币,totalSupply ↓
├─ 池子把原生 ETH 打给攻击者 ─────┐
│ │ ⭐ 控制权移交
│ ▼
│ 攻击者的 fallback
│ └─ 调用 add_liquidity
│ ⚠️ 锁本该拦住这里,但没有
│ └─ 此时池子的 balances 还没更新
│ ⟹ 按【错误的比例】铸造 LP
│ ┌──
└─ 池子更新 balances ◀────────────┘
受影响的是若干个含原生 ETH 的池子(原生 ETH 转账才会触发 fallback,纯 ERC-20 池子不受影响)。
结局
⭐ 事后大部分资金被追回:
· 一部分被白帽 MEV 机器人抢先救出并归还
· 一部分被攻击者在谈判后归还
⚠️ 但这依赖的是链外的博弈,不是协议的保证 ——
和[第 6 篇](https://blog.ifcalm.org/posts/security/blockchain/06-poly-network/)、[第 18 篇](https://blog.ifcalm.org/posts/security/blockchain/18-euler-finance/)一样。
四、为什么没被发现
① 审计的对象是源码
⚠️ 一次典型的审计流程:
拿到 GitHub 仓库 ⟹ 读源码 ⟹ 分析逻辑 ⟹ 出报告
⭐ 而"这份源码被编译成了什么",
通常不在审计范围里。
⟹ 即使审计员逐字读了 @nonreentrant,
他也是在检查【意图】,不是在检查【产物】。
② 正向测试同样通过
测试重入保护的标准写法:
部署一个恶意合约,在回调里尝试重入 ⟹ 断言 revert ✅
⚠️ 如果项目【有】这个测试,本可以发现问题。
⭐ 但这类测试通常只针对"最明显的那个函数"写一次,
而漏洞在于【两个不同函数之间】的互斥失效。
⟹ 需要的是:对【每一对】共享同一把锁的函数
都做一次交叉重入测试。
⚠️ n 个函数就是 n² 对 —— 很少有人这么测。
③ 编译器版本是"环境",不是"代码"
⭐ 心智模型:
"编译器是工具,工具是可靠的。
我们关心的是自己写的代码。"
⚠️ 现实:
编译器是你【信任基础(TCB)】的一部分。
它和你的合约代码一样,是一段可能有 bug 的程序,
⭐ 而且它的 bug 会【静默地】改变你代码的语义。
⟹ 相比 Solidity,Vyper 的用户基数小得多,
⭐ 意味着同样的 bug 需要更长时间才会被发现 ——
这是一个必须被计入的选型成本。
④ 合约不可变,“知道了"也改不了
⚠️ Curve 的池子是不可升级的(这本身是一个安全选择)。
⟹ 即使编译器 bug 在披露的那一刻被知道,
已经部署的池子【无法被修补】。
⭐ 唯一能做的是"迁移流动性"——
而这需要每一个 LP 主动行动。
⟹ 这是[第 5 篇](https://blog.ifcalm.org/posts/security/blockchain/05-parity-multisig-2/)那个取舍的另一面:
⭐ 不可变性保护你不被开发者作恶,
也保护了漏洞不被修复。
五、防御
① 锁定并记录编译器版本
# ⭐ 精确到补丁版本,不要用范围
# @version 0.3.10
# foundry.toml —— 同样精确
solc_version = "0.8.24"
⭐ 并且把版本号写进部署记录:哪个合约、哪个地址、用哪个版本、哪个 commit、什么编译参数。出现编译器 CVE 时,你需要在几分钟内回答"我受影响吗”。
② 可复现构建 + 字节码校验
⭐ 部署后必须做的一步:
① 用记录的版本和参数重新编译源码
② 把产物和链上的字节码逐字节比对
③ ⚠️ 不一致 ⟹ 立刻停下来查
⟹ 这同时防住了三件事:
· 编译环境不一致
· ⭐ 部署脚本被篡改
· 有人部署了和仓库不同的代码
③ 交叉重入测试:每一对,不是每一个
// ⭐ 对所有共享同一把锁的函数,两两测试
function test_CrossFunctionReentrancy() public {
address[] memory fns = _allLockedFunctions();
for (uint i = 0; i < fns.length; i++) {
for (uint j = 0; j < fns.length; j++) {
// 在 fns[i] 的回调里尝试进入 fns[j]
_expectRevertOnReentry(fns[i], fns[j]); // ⭐ 必须全部 revert
}
}
}
⭐ 这个测试是本篇最直接的可执行产出:它不依赖你信任编译器,它直接验证了字节码的实际行为。
⭐一般原则:安全测试要验证【运行时行为】,不要验证【源码写了什么】。
④ 语言与工具链的选型也是安全决策
⭐ 选一个语言/框架时,除了语法和性能,还要看:
□ 用户基数有多大?(bug 被发现的速度 ∝ 用户数)
□ 编译器本身有没有测试套件?有没有形式化验证?
□ ⚠️ 历史上出过几次编译器级别的 CVE?
□ 有没有独立的第二实现可以交叉验证?
□ 出了问题,社区响应有多快?
⟹ ⭐ "更优雅的语言"和"更少人踩过的坑",
往往是同一件事的两面。
⑤ 纵深防御:不要只依赖一层
⭐ 如果 Curve 的池子在【源码层面】也遵循了 CEI
(先更新 balances 再转账),
⟹ 即使锁失效,攻击也不成立。
⟹ 一般化:
⚠️ 重入锁是【第二道】防线,CEI 才是第一道。
⭐ 只靠锁,等于把全部安全性押在
"这个装饰器的实现是对的"上。
⭐ 这一条是本篇最重要的实践结论:把 第 1 篇的"CEI 优先、锁是补充"从"最佳实践"升级为"必须"——因为锁可能因为你控制不了的原因失效。
六、⭐ 举一反三
核心命题:你的信任基础比你以为的大
⭐⭐ 你的合约的安全性,等于"你的代码 + 你的编译器 + 你的依赖库 + 你的部署流程"的安全性。
⚠️ 完整的信任基础清单:
□ 编译器(本篇)
□ ⭐ 依赖库(OpenZeppelin 也出过 CVE)
□ 部署脚本与 CI 环境
□ ⚠️ 前端与钱包([第 23 篇](https://blog.ifcalm.org/posts/security/blockchain/23-wazirx/)、[第 25 篇](https://blog.ifcalm.org/posts/security/blockchain/25-bybit/))
□ 你读区块链数据用的 RPC 节点
□ 验证合约用的区块浏览器
□ ⭐ 你的开发机器([第 25 篇](https://blog.ifcalm.org/posts/security/blockchain/25-bybit/))
□ 你的 npm / pip 依赖树
⟹ 每一项都能静默地改变你系统的行为。
供应链攻击的一般形态
⭐ 本案是"编译器无意的 bug",
⚠️ 而同样的位置也可以被【有意】利用:
· 一个被投毒的 npm 包,在构建时改写你的字节码
· 一个被篡改的部署脚本,把 owner 换成攻击者
· ⭐ 一个被入侵的开发者机器([第 25 篇](https://blog.ifcalm.org/posts/security/blockchain/25-bybit/),$14.6 亿)
· 一个恶意的 VS Code 插件
· CI 里一个被替换的 action
⟹ 防御方式相同:
⭐ 可复现构建 + 逐字节校验 + 最小依赖
关于"不可变性"的取舍
⭐ 把[第 5 篇](https://blog.ifcalm.org/posts/security/blockchain/05-parity-multisig-2/)和本篇放在一起:
可升级:⚠️ 升级权是最大的攻击面([第 4 篇](https://blog.ifcalm.org/posts/security/blockchain/04-parity-multisig-1/)、[第 25 篇](https://blog.ifcalm.org/posts/security/blockchain/25-bybit/))
不可变:⚠️ 出了 bug 无法修复(本篇、[第 5 篇](https://blog.ifcalm.org/posts/security/blockchain/05-parity-multisig-2/))
⟹ 两条路都有致命形态,没有"更安全"的那一条。
⭐ 折中方案(都有代价):
□ 不可变 + 一个只能【暂停/迁移】的开关
□ 可升级 + 长时间锁 + 多方多签
□ ⭐ 不可变 + 事先设计好的"迁移路径"和迁移激励
⚠️ 关键是:这个取舍必须被【显式做出并写下来】,而不是默认选一个。
七、本案小结
- 源码里的重入锁写得完全正确,逐行读挑不出毛病——问题是那几个 Vyper 版本生成的字节码里,本该共享一把锁的函数没有真正互斥。
- ⭐⭐ 这是整个档案里唯一一篇"读源码也发现不了"的事故。前十九篇只要足够仔细都能看出来,这一篇的缺陷在从源码到字节码的那一步。
- 锁失效之后,剩下的就是一个没有保护的先转账后记账——攻击形状和 The DAO 完全一样。只有含原生 ETH 的池子受影响(ERC-20 转账不触发 fallback)。
- ⚠️ 审计的对象是源码:审计员逐字读了
@nonreentrant,但他检查的是意图,不是产物。 - 正向测试也没抓住它:重入测试通常只针对"最明显的那个函数"写一次,而漏洞在两个不同函数之间的互斥失效——需要对每一对共享锁的函数做交叉测试,n 个函数是 n² 对。 -⭐ 编译器是你信任基础的一部分,它和你的合约一样是一段可能有 bug 的程序,而它的 bug 会静默地改变你代码的语义。用户基数小的语言,同样的 bug 需要更久才被发现——这是必须计入的选型成本。
- ⚠️ 合约不可变意味着"知道了也改不了":唯一能做的是迁移流动性,而这需要每个 LP 主动行动。不可变性保护你不被开发者作恶,也保护了漏洞不被修复。 -⭐ 最重要的实践结论:重入锁是第二道防线,CEI 才是第一道。 如果池子在源码层面也遵循 CEI,即使锁失效攻击也不成立。只靠锁,等于把全部安全性押在"这个装饰器的实现是对的"上。
- 安全测试要验证运行时行为,不要验证源码写了什么。 交叉重入测试直接验证字节码的实际行为,不依赖你信任编译器。
- 部署后必须做可复现构建 + 逐字节比对链上字节码——它同时防住编译环境不一致、部署脚本被篡改、部署了和仓库不同的代码。
思考题
- 为什么说"这是唯一一篇读源码也发现不了的事故"?审计员本可以做什么来发现它?
- 为什么只有含原生 ETH 的池子受影响?纯 ERC-20 的池子为什么安全?
- 写出"对每一对共享锁的函数做交叉重入测试"的伪代码。n 个函数需要多少个测试用例?
- 如果 Curve 的池子在源码层面遵循了 CEI,攻击还能成立吗?请逐步论证。
- 列出你项目完整的"信任基础"清单。其中有几项你能说出具体版本号?
- 可复现构建 + 字节码比对,能防住哪三类问题?请各举一个具体场景。
- “更优雅的语言"和"更少人踩过的坑"往往是同一件事的两面。请针对一个具体的语言选型做这个权衡分析。
- 合约不可变,发现编译器 bug 后唯一的出路是迁移。请设计一套"事先准备好的迁移路径”,包括如何激励 LP 迁移。
- 对比第 5 篇(不可变导致永久冻结)和本篇(不可变导致无法修复):这两起事故是否意味着应该选择可升级?请论证你的立场。
- 把"重入锁是第二道防线,CEI 是第一道"这条原则,推广到别的安全措施上。请再举两个"不能只依赖单层防御"的例子。