来源:慢雾科技
By : yudan @ 慢雾安全团队
前言
据链闻消息,2020 年 8 月 13 日,知名以太坊DeFi 项目 YAM 官方通过 Twitter 发文表明发现合约中存在漏洞,24 小时内价格暴跌 99% 。慢雾安全团队在收到情报后快速进行了相关的跟进及分析,以下是详细的技术细节。
发生了什么?
以上是YAM 官方对本次事件的简短说明(来源:https://medium.com/@yamfinance/save-yam-245598d81cec)。
简单来说就是官方在合约中发现负责调整供应量的函数发生了问题,这个问题导致多余的 YAM 代币放进了 YAM 的 reserves 合约中,并且如果不修正这个问题,将会导致 YAM 的后续治理变为不可能。同时,官方给出了此次漏洞的具体问题代码,如下:
从上图可知,由于编码不规范,YAM 合约在调整 totalSupply 的时候,本应将最后的结果除以 BASE 变量,但是在实际开发过程中却忽略了,导致 totoalSupply 计算不正确,比原来的值要大 10^18 倍。但是代币供应量问题和治理是怎么扯上关系呢?这需要我们针对代码做进一步的分析。
YAM 会变成怎样?
为了深入了解此次漏洞造成的影响,需要对 YAM 项目代码进行深入的了解。根据官方给出的问题代码及项目 Github 地址(https://github.com/yam-finance/yam-protocol),可以定位出调整供应量的rebase函数位于 YAMDelegator.sol 合约中,具体代码如下:
functionrebase(uint256epoch,uint256indexDelta,boolpositive)externalreturns(uint256){epoch;indexDelta;positive;delegateAndReturn();}
通过跟踪 rebase函数,发现 rebase函数最终调用了 delegateAndReturn 函数,代码如下:
functiondelegateAndReturn()privatereturns(bytesmemory){(boolsuccess,)=implementation.delegatecall(msg.data);assembly{letfree_mem_ptr:=mload(0x40)returndatacopy(free_mem_ptr,0,returndatasize)switchsuccesscase0{revert(free_mem_ptr,returndatasize)}default{return(free_mem_ptr,returndatasize)}}}
通过分析代码,可以发现 delegateAndReturn 函数最终使用 delegatecall 的方式调用了 implementation 地址中的逻辑,也就是说,这是一个可升级的合约模型。而真正的 rebase逻辑位于 YAM.sol 中, 继续跟进 rebase 函数的具体逻辑,如下:
functionrebase(uint256epoch,uint256indexDelta,boolpositive)externalonlyRebaserreturns(uint256){if(indexDelta==0){emitRebase(epoch,yamsScalingFactor,yamsScalingFactor);returntotalSupply;}uint256prevYamsScalingFactor=yamsScalingFactor;if(!positive){yamsScalingFactor=yamsScalingFactor.mul(BASE.sub(indexDelta)).div(BASE);}else{uint256newScalingFactor=yamsScalingFactor.mul(BASE.add(indexDelta)).div(BASE);if(newScalingFactor<_maxScalingFactor()){yamsScalingFactor=newScalingFactor;}else{yamsScalingFactor=_maxScalingFactor();}}//SlowMist//问题代码totalSupply=initSupply.mul(yamsScalingFactor);emitRebase(epoch,prevYamsScalingFactor,yamsScalingFactor);returntotalSupply;}}
通过分析最终的 rebase函数的逻辑,不难发现代码中根据 yamsScalingFactor 来对 totalSupply 进行调整,由于 yamsScalingFactor 是一个高精度的值,在调整完成后应当除以 BASE 来去除计算过程中的精度,获得正确的值。但是项目方在对 totalSupply 进行调整时,竟忘记了对计算结果进行调整,导致了 totalSupply 意外变大,计算出错误的结果。
分析到这里还没结束,要将漏洞和社区治理关联起来,需要对代码进行进一步的分析。通过观察rebase函数的修饰器,不难发现此处限定了只能是 rebaser 进行调用。而 rebaser 是 YAM 中用与实现供应量相关逻辑的合约,也就是说,是 rebaser 合约最终调用了 YAM.sol 合约中的rebase函数。通过跟踪相关代码,发现 rebaser 合约中对应供应量调整的逻辑为rebase函数,代码如下:
functionrebase()public{//EOAonlyrequire(msg.sender==tx.origin);//ensurerebasingatcorrecttime_inRebaseWindow();//Thiscomparisonalsoensuresthereisnoreentrancy.require(lastRebaseTimestampSec.add(minRebaseTimeIntervalSec)<now);//Snaptherebasetimetothestartofthiswindow.lastRebaseTimestampSec=now.sub(now.mod(minRebaseTimeIntervalSec)).add(rebaseWindowOffsetSec);epoch=epoch.add(1);//gettwapfromuniswapv2;uint256exchangeRate=getTWAP();//calculates%changetosupply(uint256offPegPerc,boolpositive)=computeOffPegPerc(exchangeRate);uint256indexDelta=offPegPerc;//ApplytheDampeningfactor.indexDelta=indexDelta.div(rebaseLag);YAMTokenInterfaceyam=YAMTokenInterface(yamAddress);if(positive){require(yam.yamsScalingFactor().mul(uint256(10**18).add(indexDelta)).div(10**18)<yam.maxScalingFactor(),"newscalingfactorwillbetoobig");}//SlowMist//取当前YAM代币的供应量uint256currSupply=yam.totalSupply();uint256mintAmount;//reduceindexDeltatoaccountforminting//SlowMist//计算要调整的供应量if(positive){uint256mintPerc=indexDelta.mul(rebaseMintPerc).div(10**18);indexDelta=indexDelta.sub(mintPerc);mintAmount=currSupply.mul(mintPerc).div(10**18);}//rebase//SlowMist//调用YAM的rebase逻辑uint256supplyAfterRebase=yam.rebase(epoch,indexDelta,positive);assert(yam.yamsScalingFactor()<=yam.maxScalingFactor());//performactionsafterrebase//SlowMist//进入调整逻辑afterRebase(mintAmount,offPegPerc);}
通过分析代码,可以发现函数在进行了一系列的检查后,首先获取了当前 YAM 的供应量,计算此次的铸币数量,然后再调用 YAM.sol 中的 rebase函数对 totalSupply 进行调整,也就是说 rebase 过后的对 totalSupply 的影响要在下一次调用 rebaser 合约的 rebase函数才会生效。最后 rebase函数调用了 afterRebase 函数。我们继续跟进 afterRebase 函数中的代码:
functionafterRebase(uint256mintAmount,uint256offPegPerc)internal{//updateuniswapUniswapPair(uniswap_pair).sync();//SlowMist//通过uniswap购买yCRV代币if(mintAmount>0){buyReserveAndTransfer(mintAmount,offPegPerc);}//callanyextrafunctions//SlowMist//社区管理调用for(uinti=0;i<transactions.length;i++){Transactionstoraget=transactions[i];if(t.enabled){boolresult=externalCall(t.destination,t.data);if(!result){emitTransactionFailed(t.destination,i,t.data);revert("TransactionFailed");}}}}
通过分析发现, afterRebase 函数主要的逻辑在 buyReserveAndTransfer 函数中,此函数用于将增发出来的代币的一部分用于到 Uniswap 中购买 yCRV 代币。跟踪 buyReserveAndTransfer 函数,代码如下:
functionbuyReserveAndTransfer(uint256mintAmount,uint256offPegPerc)internal{UniswapPairpair=UniswapPair(uniswap_pair);YAMTokenInterfaceyam=YAMTokenInterface(yamAddress);//getreserves(uint256token0Reserves,uint256token1Reserves,)=pair.getReserves();//checkifprotocolhasexcessyaminthereserveuint256excess=yam.balanceOf(reservesContract);//SlowMist//计算用于Uniswap中兑换的YAM数量uint256tokens_to_max_slippage=uniswapMaxSlippage(token0Reserves,token1Reserves,offPegPerc);UniVarsmemoryuniVars=UniVars({yamsToUni:tokens_to_max_slippage,//howmanyyamsuniswapneedsamountFromReserves:excess,//howmuchofyamsToUnicomesfromreservesmintToReserves:0//howmuchyamsprotocolmintstoreserves});//triestosellallmint+excess//fallsbacktosellingsomeofmintandallofexcess//ifallelsefails,sellsportionofexcess//uponpair.swap,`uniswapV2Call`iscalledbytheuniswappaircontractif(isToken0){if(tokens_to_max_slippage>mintAmount.add(excess)){//wealreadyhaveperformedasafemathcheckonmintAmount+excess//sowedontneedtocontinueusingitinthiscodepath//canhandlesellingallofreservesandmintuint256buyTokens=getAmountOut(mintAmount+excess,token0Reserves,token1Reserves);uniVars.yamsToUni=mintAmount+excess;uniVars.amountFromReserves=excess;//callswapusingentiremintamountandexcess;mint0toreservespair.swap(0,buyTokens,address(this),abi.encode(uniVars));}else{if(tokens_to_max_slippage>excess){//uniswapcanhandleentirereservesuint256buyTokens=getAmountOut(tokens_to_max_slippage,token0Reserves,token1Reserves);//swapuptoslippagelimit,takingentireyamreserves,andmintingpartoftotal//SlowMist//将多余代币铸给reserves合约uniVars.mintToReserves=mintAmount.sub((tokens_to_max_slippage-excess));//SlowMist//Uniswap代币交换pair.swap(0,buyTokens,address(this),abi.encode(uniVars));}else{//uniswapcanthandleallofexcessuint256buyTokens=getAmountOut(tokens_to_max_slippage,token0Reserves,token1Reserves);uniVars.amountFromReserves=tokens_to_max_slippage;uniVars.mintToReserves=mintAmount;//swapuptoslippagelimit,takingexcess-remainingExcessfromreserves,andmintingfullamount//toreservespair.swap(0,buyTokens,address(this),abi.encode(uniVars));}}}else{if(tokens_to_max_slippage>mintAmount.add(excess)){//canhandleallofreservesandmintuint256buyTokens=getAmountOut(mintAmount+excess,token1Reserves,token0Reserves);uniVars.yamsToUni=mintAmount+excess;uniVars.amountFromReserves=excess;//callswapusingentiremintamountandexcess;mint0toreservespair.swap(buyTokens,0,address(this),abi.encode(uniVars));}else{if(tokens_to_max_slippage>excess){//uniswapcanhandleentirereservesuint256buyTokens=getAmountOut(tokens_to_max_slippage,token1Reserves,token0Reserves);//swapuptoslippagelimit,takingentireyamreserves,andmintingpartoftotal//SlowMist//增发的多余的代币给reserves合约uniVars.mintToReserves=mintAmount.sub((tokens_to_max_slippage-excess));//swapuptoslippagelimit,takingentireyamreserves,andmintingpartoftotal//Slowist//在uniswap中进行兑换,并最终调用rebase合约的uniswapV2Call函数pair.swap(buyTokens,0,address(this),abi.encode(uniVars));}else{//uniswapcanthandleallofexcessuint256buyTokens=getAmountOut(tokens_to_max_slippage,token1Reserves,token0Reserves);uniVars.amountFromReserves=tokens_to_max_slippage;uniVars.mintToReserves=mintAmount;//swapuptoslippagelimit,takingexcess-remainingExcessfromreserves,andmintingfullamount//toreservespair.swap(buyTokens,0,address(this),abi.encode(uniVars));}}}}
通过对代码分析,buyReserveAndTransfer 首先会计算在 Uniswap 中用于兑换 yCRV 的 YAM 的数量,如果该数量少于 YAM 的铸币数量,则会将多余的增发的 YAM 币给 reserves 合约,这一步是通过 Uniswap 合约调用 rebase合约的 uniswapV2Call 函数实现的,具体的代码如下:
functionuniswapV2Call(addresssender,uint256amount0,uint256amount1,bytesmemorydata)public{//enforcethatitiscomingfromuniswaprequire(msg.sender==uniswap_pair,"badmsg.sender");//enforcethatthiscontractcalleduniswaprequire(sender==address(this),"badorigin");(UniVarsmemoryuniVars)=abi.decode(data,(UniVars));YAMTokenInterfaceyam=YAMTokenInterface(yamAddress);if(uniVars.amountFromReserves>0){//transferfromreservesandminttouniswapyam.transferFrom(reservesContract,uniswap_pair,uniVars.amountFromReserves);if(uniVars.amountFromReserves<uniVars.yamsToUni){//iftheamountfromreserves>yamsToUni,wehavefullypaidfortheyCRVtokens//thusthisnumberwouldbe0sononeedtomintyam.mint(uniswap_pair,uniVars.yamsToUni.sub(uniVars.amountFromReserves));}}else{//minttouniswapyam.mint(uniswap_pair,uniVars.yamsToUni);}//mintunsoldtomintAmount//SlowMist//将多余的YAM代币分发给reserves合约if(uniVars.mintToReserves>0){yam.mint(reservesContract,uniVars.mintToReserves);}//transferreservetokentoreservesif(isToken0){SafeERC20.safeTransfer(IERC20(reserveToken),reservesContract,amount1);emitTreasuryIncreased(amount1,uniVars.yamsToUni,uniVars.amountFromReserves,uniVars.mintToReserves);}else{SafeERC20.safeTransfer(IERC20(reserveToken),reservesContract,amount0);emitTreasuryIncreased(amount0,uniVars.yamsToUni,uniVars.amountFromReserves,uniVars.mintToReserves);}}
分析到这里,一个完整的 rebase流程就完成了,你可能看得很懵,我们用简单的流程图简化下:
也就是说,每次的rebase,如果有多余的 YAM 代币,这些代币将会流到 reserves 合约中,那这和社区治理的关系是什么呢?
通过分析项目代码,发现治理相关的逻辑在 YAMGovernorAlpha.sol 中,其中发起提案的函数为 propose,具体代码如下:
functionpropose(address[]memorytargets,uint[]memoryvalues,string[]memorysignatures,bytes[]memorycalldatas,stringmemorydescription)publicreturns(uint256){//SlowMist//校验提案发起者的票数占比require(yam.getPriorVotes(msg.sender,sub256(block.number,1))>proposalThreshold(),"GovernorAlpha::propose:proposervotesbelowproposalthreshold");require(targets.length==values.length&&targets.length==signatures.length&&targets.length==calldatas.length,"GovernorAlpha::propose:proposalfunctioninformationaritymismatch");require(targets.length!=0,"GovernorAlpha::propose:mustprovideactions");require(targets.length<=proposalMaxOperations(),"GovernorAlpha::propose:toomanyactions");uint256latestProposalId=latestProposalIds[msg.sender];if(latestProposalId!=0){ProposalStateproposersLatestProposalState=state(latestProposalId);require(proposersLatestProposalState!=ProposalState.Active,"GovernorAlpha::propose:oneliveproposalperproposer,foundanalreadyactiveproposal");require(proposersLatestProposalState!=ProposalState.Pending,"GovernorAlpha::propose:oneliveproposalperproposer,foundanalreadypendingproposal");}uint256startBlock=add256(block.number,votingDelay());uint256endBlock=add256(startBlock,votingPeriod());proposalCount++;ProposalmemorynewProposal=Proposal({id:proposalCount,proposer:msg.sender,eta:0,targets:targets,values:values,signatures:signatures,calldatas:calldatas,startBlock:startBlock,endBlock:endBlock,forVotes:0,againstVotes:0,canceled:false,executed:false});proposals[newProposal.id]=newProposal;latestProposalIds[newProposal.proposer]=newProposal.id;emitProposalCreated(newProposal.id,msg.sender,targets,values,signatures,calldatas,startBlock,endBlock,description);returnnewProposal.id;}
通过分析代码,可以发现在发起提案时,需要提案发起人拥有一定额度的票权利,这个值必须大于 proposalThreshold 计算得来的值,具体代码如下:
functionproposalThreshold()publicviewreturns(uint256){returnSafeMath.div(yam.initSupply(),100);}//1%ofYAM
也就是说提案发起人的票权必须大于 initSupply 的 1% 才能发起提案。那 initSupply 受什么影响呢?答案是 YAM 代币的 mint 函数,代码如下:
functionmint(addressto,uint256amount)externalonlyMinterreturns(bool){_mint(to,amount);returntrue;}function_mint(addressto,uint256amount)internal{//increasetotalSupplytotalSupply=totalSupply.add(amount);//getunderlyingvalueuint256yamValue=amount.mul(internalDecimals).div(yamsScalingFactor);//increaseinitSupplyinitSupply=initSupply.add(yamValue);//makesurethemintdidntpushmaxScalingFactortoolowrequire(yamsScalingFactor<=_maxScalingFactor(),"maxscalingfactortoolow");//addbalance_yamBalances[to]=_yamBalances[to].add(yamValue);//adddelegatestotheminter_moveDelegates(address(0),_delegates[to],yamValue);emitMint(to,amount);}
从代码可知,mint 函数在每次铸币时都会更新 initSupply 的值,而这个值是根据 amount 的值来计算的,也就是铸币的数量。
现在,我们已经分析完所有的流程了,剩下的就是把所有的分析串起来,看看这次的漏洞对 YAM 产生了什么影响,对上文的流程图做拓展,变成下面这样:
整个事件的分析如上图,由于rebase的时候取的是上一次的 totalSupply 的值,所以计算错误的 totalSupply 的值并不会立即通过 mint 作用到 initSupply 上,所以在下一次rebase前,社区仍有机会挽回这个错误,减少损失。但是一旦下一次rebase执行,整个失误将会变得无法挽回。
通过查询 Etherscan 上 YAM 代币合约的相关信息,可以看到 totalSupply 已经到了一个非常大的值,而 initSupply 还未受到影响。
前车之鉴
这次事件中官方已经给出了具体的修复方案,这里不再赘述。这次的事件充分暴露了未经审计 DeFi 合约中隐藏的巨大风险,虽然 YAM 开发者已经在 Github 中表明 YAM 合约的很多代码是参考了经过充分审计的 DeFi 项目如 Compound、Ampleforth、Synthetix 及 YEarn/YFI,但是仍无可避免地发生了意料之外的风险。
DeFi 项目 Yam Finance(YAM) 核心开发者 belmore 在推特上表示:“对不起,大家。我失败了。谢谢你们今天的大力支持。我太难过了。”,但是覆水已经难收,在此,慢雾安全团队给出如下建议:
1、由于 DeFi 合约的高度复杂性,任何 DeFi 项目都需在经过专业的安全团队充分审计后再进行上线,降低合约发生意外的风险。审计可联系慢雾安全团队(team@slowmist.com)
2、项目中去中心化治理应循序渐进,在项目开始阶段,需要设置适当的权限以防发生黑天鹅事件。