
最近Curve的Vyper编译语言出问题,搞的超过7000万美元被黑走,这事儿叫“重入攻击”。这篇文章就是想掰开揉碎了给您讲明白,到底咋回事。原文来自CyberPunkMetalHead在Medium发的《A Deep Dive Into How Curve Pool’s $70 Million Reentrancy Exploit Was Possible》,星球日报Odaily编译整理。
这次Curve池子出事,跟过去几年见的那些加密黑客不太一样。以前大多是合约代码写漏了、逻辑没兜住,可这次呢,合约本身写得挺稳当,毛病出在它用的语言底层——也就是Vyper编译器那儿。
说白了,Vyper是个给智能合约用的编程语言,长得像Python,专为以太坊虚拟机(EVM)设计。我特别好奇这漏洞咋冒出来的,就扒了好久源码和文档。
那几天新闻天天刷屏,数字越涨越高。好在现在总算压住了,但前前后后还是丢了七千多万美金。LlamaRisk事后一查,发现不光CurveDAO自己丢了约2470万,还有PEGD的pETH/ETH池:1100万;Metronome的msETH/ETH:340万;Alchemix的alETH/ETH:2260万……全栽在同一个坑里。
这个漏洞就叫重入错误,专门盯上Vyper的v0.2.15、v0.2.16和v0.3.0这几个版本。所以只要您项目里用了这几个版本的Vyper,哪怕代码再漂亮,也可能被盯上。
啥是重入(reentrancy)?
想搞懂钱咋丢的,咱先得知道重入是啥意思。
简单说,一个函数要是能在自己还没跑完的时候,又被别人从头再叫一遍,还不乱套,那它就叫可重入的。这种设计早年在硬件中断、递归调用里很常见。
要让函数可重入,得满足几个条件:
它不能碰全局或静态变量。这不是硬性规定,但真用了,函数中途被打断再重启,数据可能就对不上了。它也不能改自己的代码。不管啥时候被打断,都得能原样跑通。这理论上可行,但实际没人这么干。它还不能去调别的不可重入函数。顺便提一句,重入和线程安全不是一回事,虽然有点沾边。重入只管单个线程里反复进进出出,这概念比多任务操作系统还老呢。
来个例子您就明白了:
i = 5
def non_reentrant_function():
return i**5
def reentrant_function(number:int):
return number**5
non_reentrant_function这个函数没参数,直接拿全局变量i算5次方。所以每次调都返回5的5次方,也就是3125。
reentrant_function带个number参数,算的是传进来的数的5次方。您给2,它就返32;给3,就返243。灵活多了吧?
不过现实里,好多智能合约函数都做不到可重入,因为它们老得查钱包余额这类全局状态。
锁(Lock)是干啥的?
锁嘛,说白了就是个同步工具,让某个程序暂时“霸占”某块资源,不让别人插手。
最基础的锁叫二进制信号量,谁抢到谁独享。还有更高级的锁,允许多个人同时读数据。但锁用不好容易死锁——俩程序互相卡着对方,谁也动不了;或者活锁——一直忙活却没进展。
编程语言后台其实常用锁来协调多个子程序的状态变化。像C#和Vyper这种语言,干脆让您在代码里直接写锁。
@nonreentrant(‘lock’)def func():assert not self.locked, “locked”self.locked = True# Do stuff# Release the lock after finishing doing stuffraw_call(msg.sender, b””, value=0)self.locked = False# More code here
上面这段代码的意思是:我们怕msg.sender是个恶意合约,趁咱们函数还在跑,偷偷又调一遍。所以加了个锁,等所有活干完再解锁。要是raw_call下面还有代码,又没锁保护,那攻击者真就能钻空子,反复调用前面那段。
所以Vyper里的@nonreentrant(‘lock’)装饰器,本来就是防这种反复调用的。
可这次怪就怪在这儿——Curve和其他受害项目的合约代码本身没问题,该加的锁都加了,合约写得也挺牢靠。问题出在Vyper语言处理这个锁的方式上。
由于编译器没把锁翻译对,结果看着有锁,其实形同虚设。开发者部署时觉得万无一失,结果攻击者轻轻松松就绕过去了,合约行为完全失控。
您看下面这个真正被黑的合约片段,注意那个@nonreentrant(‘lock’)修饰符没?按理说它该拦住重入,可实际上根本没拦住。攻击者就在函数返回前,一遍遍调remove_liquidity(),跟刷票似的。
@nonreentrant(‘lock’)def remove_liquidity(_burn_amount: uint256,_min_amounts: uint256[N_COINS],_receiver: address = msg.sender) -> uint256[N_COINS]:“””@notice Withdraw coins from the pool@dev Withdrawal amounts are based on current deposit ratios@param _burn_amount Quantity of LP tokens to burn in the withdrawal@param _min_amounts Minimum amounts of underlying coins to receive@param _receiver Address that receives the withdrawn coins@return List of amounts of coins that were withdrawn“””total_supply: uint256 = self.totalSupplyamounts: uint256[N_COINS] = empty(uint256[N_COINS])for i in range(N_COINS):old_balance: uint256 = self.balances[i]value: uint256 = old_balance * _burn_amount / total_supplyassert value >= _min_amounts[i], “Withdrawal resulted in fewer coins than expected”self.balances[i] = old_balance – valueamounts[i] = valueif i == 0:raw_call(_receiver, b””, value=value)else:response: Bytes[32] = raw_call(self.coins[1],concat(method_id(“transfer(address,uint256)”),convert(_receiver, bytes32),convert(value, bytes32),),max_outsize=32,)if len(response) > 0:assert convert(response, bool)total_supply -= _burn_amountself.balanceOf[msg.sender] -= _burn_amountself.totalSupply = total_supplylog Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)return amounts
这漏洞到底是咋被用上的?
咱们知道了重入就是反复调用函数,可它咋就真把钱提走了?Curve那7000万又是怎么飞的?
关键就看这句:self.balanceOf[msg.sender] -= _burn_amount。它本该在提现前扣掉用户手里的LP代币,可实际执行顺序是——先转钱,再扣余额!
于是黑客写个恶意合约,等转账指令一发,立马又调一次提现。这时候余额还没扣呢,系统一看:“哟,您账户里还有这么多LP,行,再给您提!”就这样循环往复,直到池子被掏空。
举个糙点的例子:池子里有10个eth。黑客先存1个eth进去,再立刻提现。系统检查:“您有1个eth,OK,转走!”但余额还没减。黑客马上又提一次,系统再查:“您还有1个eth”,继续转……就这么来回刷,直到一分不剩。
Vyper官方已经修了这个问题,0.3.0之后的版本基本安全了。如果您是开发者,或者团队里用Vyper写合约,真得赶紧把编译器升到最新版,别图省事还抱着老版本用。不然哪天钱包一空,哭都找不着调儿。
顺带说一句,这事儿也提醒咱们普通用户:选DeFi项目不光要看TVL高不高、APY漂不漂亮,还得瞅瞅它用啥语言写的、编译器版本新不新。有时候技术底座的小裂缝,真能吞掉几千万美金。




















