明确一件事:这次被盗的根子不在Curve合约本身
先把结论摆在前面:2023年7月底Curve多个资金池被抽走超过7000万美元,事后复盘会发现,受害项目的业务合约代码几乎都挑不出毛病——nonreentrant锁写了,余额校验有了,函数权限也没漏。问题出在这些合约脚下的编译器Vyper身上。受影响版本是v0.2.15、v0.2.16和v0.3.0,凡是拿这三个版本编译并部署的池子,锁机制都可能失效。换句话说,开发者写的防御代码没有错,是编译器把它翻译成了错误的字节码,让攻击者找到了可乘之机。

按LlamaRisk的事后统计,损失分布大致如下:Alchemix的alETH/ETH池约2260万美元,Curve DAO自身约2470万美元,PEGD的pETH/ETH约1100万美元,Metronome的msETH/ETH约340万美元。四个池子加起来正好跨过7000万门槛。这些数字每天都在变,攻击者后来还发生了返还和MEV抢跑者之间的博弈,但对学习者来说,关键不是盯数字,而是搞懂这次攻击为什么能绕开重入锁。
重入攻击与nonreentrant锁分别在做什么
@nonreentrant('lock')
def func():
assert not self.locked, "locked"
self.locked = True
# 业务逻辑
raw_call(msg.sender, b"", value=0)
self.locked = False
先分清两个概念。重入指的是一个函数还没执行完,就被外部再次调用进入。传统编程里,可重入函数不能依赖全局变量、不能改自己的代码、不能调用非可重入函数,比如一个 `def reentrant_function(number: int): return number**5`,输入什么就算什么,不碰外部状态,这就是天然的幂等。但智能合约的转账函数几乎必然读写余额这类全局状态,所以它们天然不可重入,必须靠锁来保护。
锁的原理不复杂。最基础的是二进制信号量,谁先拿到锁谁独占,别人在外面等。Vyper把这件事做成了装饰器:函数头上挂一个 `@nonreentrant('lock')`,进入时把 `self.locked` 置为True,退出前再置回False。官方文档里的示例大致是这样:
只要锁生效,即便 `msg.sender` 是个恶意合约,在 `raw_call` 回调时想再次进入 `func`,就会被 `assert not self.locked` 挡下来。2023年出问题的地方在于,Vyper某些版本在编译阶段把这段锁逻辑给优化掉了或生成顺序错乱,导致运行时的storage槽根本没被正确读写。开发者源码层面看到的锁还在,链上跑的字节码里锁已经名存实亡。
攻击者具体怎么走这一步
拥堵发生前,攻击者做的事情可以拆成四步。第一,锁定目标池子,确认它由受影响的Vyper版本编译,这一步可以通过区块浏览器查看合约的编译器元数据完成,Vyper把版本信息写进了字节码尾部。第二,部署一个恶意合约,该合约在收到ETH回调时,重新调用目标池的取款或兑换函数。第三,由于池子里的nonreentrant实际上没起作用,第二次调用通过了本应拦截的检查。第四,在余额状态更新之前,攻击者已经把资金转走,重复几次就把池子抽空。
这里有个容易被忽略的细节:这次攻击并不需要攻击者找到某种新奇的函数入口。Curve池的公开函数本来就是给用户调用的,存取款、兑换、移除流动性,全都是合法入口。攻击者只是在一个函数执行到一半时插进去,利用锁的失效重复执行了同一段逻辑。所以它不是常规意义上的溢出或权限绕过,而是状态时序被撕开了一道口子。
判断一个DeFi项目有没有踩这个坑,可以看两点。看合约部署时间,2023年7月30日之前部署且用Vyper 0.2.15/0.2.16/0.3.0编译的,风险最高。看合约有没有实际调用外部地址,凡是把控制权交给 `msg.sender` 或任意地址的函数,都在重入的可利用面上。
照着做的检查步骤:从源码到链上确认
如果你手上有Vyper合约,或者想验证某个池子是否安全,可以按下面的顺序走一遍。第一步,在本地或浏览器里打开合约源码,翻到函数定义处,确认带 `@nonreentrant` 装饰器的函数有哪些,记下这些函数的名字。第二步,找到项目仓库里的编译配置,通常在 `hardhat.config`、`foundry.toml` 或部署脚本里,看它指定的Vyper版本。第三步,去区块浏览器打开该合约地址,切到Contract标签,查看Compiler Version字段,和源码里声明的版本对一下。如果落在0.2.15、0.2.16、0.3.0这三个版本内,基本可以判定存在编译层面的重入风险。第四步,对高危函数做一次实际调用测试,在测试网部署后用一个会回调的合约去调用它,观察锁有没有挡住第二次进入。第五步,如果发现风险,升级编译器到修复后的版本重新编译,或者在业务层再加一道自己的互斥变量作为兜底。
检查过程中最常犯的错,是只看源码里的装饰器就下结论。装饰器只是给编译器的指令,编译器怎么做才是链上实际执行的依据。另一个常见疏漏是只检查Curve本身的池子,忘了同版本Vyper编译的其他协议,2023年那次连累的四个项目就是活生生的例子。
事后要盯的几个核对点
攻击平息之后,事情并没有完全结束。一是确认你关注的池子有没有升级合约或迁移到新版本,仅仅在公告里说“已修复”不够,要看链上合约地址有没有变、字节码有没有重新部署。二是核对资金流向,LlamaRisk等机构给出的损失数字和链上实际转出量之间可能有出入,因为攻击者、抢跑者和返还者会把同一笔资金反复计数,别直接拿新闻标题当账本。三是关注Vyper官方仓库的修复提交和后续版本说明,0.3.1之后的版本对锁的处理做了调整,把编译器版本固定到修复后的版本是当前较稳妥的做法。
你现在可以做的两件事:打开区块浏览器,查一下自己持有的DeFi头寸所在合约的Compiler Version字段,看看是否还挂在三个受影响版本上;如果发现是,去项目官方渠道确认有没有发布新合约地址,有的话尽快迁移。这两步不需要任何工具,几分钟就能查完,比看一堆行情讨论实在得多。