DeFi YAM,一行程式碼如何蒸發數億美元?

買賣虛擬貨幣

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 合約中,具體程式碼如下:

function rebase(        

uint256 epoch,        

uint256 indexdelta,        

bool positive    )        

external        

returns (uint256)    

{        

epoch; indexdelta; positive;        

delegateandreturn();    

}

透過跟蹤 rebase 函式,發現 rebase 函式最終呼叫了 delegateandreturn 函式,程式碼如下:

function delegateandreturn() private returns (bytes memory) {        

(bool success, ) = implementation.delegatecall(msg.data);        

assembly {            

let free_mem_ptr := mload(0x40)           

 returndatacopy(free_mem_ptr, 0, returndatasize)            

switch success            

case 0 { revert(free_mem_ptr, returndatasize) }            

default { return(free_mem_ptr, returndatasize) }        

}    

}

透過分析程式碼,可以發現 delegateandreturn 函式最終使用 delegatecall 的方式呼叫了 implementation 地址中的邏輯,也就是說,這是一個可升級的合約模型。而真正的 rebase 邏輯位於 yam.sol 中, 繼續跟進 rebase 函式的具體邏輯,如下:

function rebase(        

uint256 epoch,        

uint256 indexdelta,        

bool positive)        

external        

onlyrebaser        

returns (uint256)    

{        

if (indexdelta == 0) {          

emit rebase(epoch, yamsscalingfactor, yamsscalingfactor);          

return totalsupply;        

}        

uint256 prevyamsscalingfactor = yamsscalingfactor;       

if (!positive) {           

yamsscalingfactor = yamsscalingfactor.mul(base.sub(indexdelta)).div(base);        

} else {            

uint256 newscalingfactor = yamsscalingfactor.mul(base.add(indexdelta)).div(base);            

if (newscalingfactor < _maxscalingfactor()) {                

yamsscalingfactor = newscalingfactor;            

else {              

yamsscalingfactor = _maxscalingfactor();            

}        

}        

//slowmist// 問題程式碼        

totalsupply = initsupply.mul(yamsscalingfactor);        

emit rebase(epoch, prevyamsscalingfactor, yamsscalingfactor);        

return totalsupply;    

}

}

透過分析最終的 rebase 函式的邏輯,不難發現程式碼中根據 yamsscalingfactor 來對 totalsupply 進行調整,由於 yamsscalingfactor 是一個高精度的值,在調整完成後應當除以 base 來去除計算過程中的精度,獲得正確的值。但是專案方在對 totalsupply 進行調整時,竟忘記了對計算結果進行調整,導致了 totalsupply 意外變大,計算出錯誤的結果。

分析到這裡還沒結束,要將漏洞和社羣治理關聯起來,需要對程式碼進行進一步的分析。透過觀察 rebase 函式的修飾器,不難發現此處限定了只能是 rebaser 進行呼叫。而 rebaser 是 yam 中用與實現供應量相關邏輯的合約,也就是說,是 rebaser 合約最終呼叫了 yam.sol 合約中的 rebase 函式。透過跟蹤相關程式碼,發現 rebaser 合約中對應供應量調整的邏輯為 rebase 函式,程式碼如下:

function rebase()        

public    

{        

// eoa only        

require(msg.sender == tx.origin);        

// ensure rebasing at correct time       

 _inrebasewindow();        

// this comparison also ensures there is no reentrancy.        

require(lastrebasetimestampsec.add(minrebasetimeintervalsec) < now);        

// snap the rebase time to the start of this window.        

lastrebasetimestampsec = now.sub(

now.mod(minrebasetimeintervalsec)).add(rebasewindowoffsetsec);        

epoch = epoch.add(1);       

// get twap from uniswap v2;        

uint256 exchangerate = gettwap();        

// calculates % change to supply        

(uint256 offpegperc, bool positive) = computeoffpegperc(exchangerate);        

uint256 indexdelta = offpegperc;        

// apply the dampening factor.        

indexdelta = indexdelta.div(rebaselag);        

yamtokeninterface yam = yamtokeninterface(yamaddress);        

if (positive) {           

require(yam.yamsscalingfactor().mul(uint256(10**18).add(indexdelta)).div(10**18) < 

yam.maxscalingfactor(), "new scaling factor will be too big");        

}        

//slowmist// 取當前 yam 代幣的供應量        

uint256 currsupply = yam.totalsupply();        

uint256 mintamount;        

// reduce indexdelta to account for minting        

//slowmist// 計算要調整的供應量        

if (positive) {            

uint256 mintperc = indexdelta.mul(rebasemintperc).div(10**18);            

indexdelta = indexdelta.sub(mintperc);            

mintamount = currsupply.mul(mintperc).div(10**18);        

}        

// rebase        

//slowmist// 呼叫 yam 的rebase 邏輯        

uint256 supplyafterrebase = yam.rebase(epoch, indexdelta, positive);       

assert(yam.yamsscalingfactor() <= yam.maxscalingfactor());        

// perform actions after rebase        

//slowmist// 進入調整邏輯        

afterrebase(mintamount, offpegperc);    }

透過分析程式碼,可以發現函式在進行了一系列的檢查後,首先獲取了當前 yam 的供應量,計算此次的鑄幣數量,然後再呼叫 yam.sol 中的 rebase 函式對 totalsupply 進行調整,也就是說 rebase 過後的對 totalsupply 的影響要在下一次呼叫 rebaser 合約的 rebase 函式才會生效。最後 rebase 函式呼叫了 afterrebase 函式。我們繼續跟進 afterrebase 函式中的程式碼:

function afterrebase(        

uint256 mintamount,        

uint256 offpegperc)        

internal    

{        

// update uniswap        

uniswappair(uniswap_pair).sync();        

//slowmist// 透過 uniswap 購買 ycrv 代幣         

if (mintamount > 0) {            

buyreserveandtransfer(                

mintamount,                

offpegperc            

);        

}        

// call any extra functions        

//slowmist// 社羣管理呼叫        

for (uint i = 0; i < transactions.length; i++) {            

transaction storage t = transactions;            

if (t.enabled) {                

bool result =                    

externalcall(t.destination, t.data);               

 if (!result) {                    

emit transactionfailed(t.destination, i, t.data);                   

 revert("transaction failed");                

}           

 }       

 }   

 }

透過分析發現, afterrebase 函式主要的邏輯在 buyreserveandtransfer 函式中,此函式用於將增發出來的代幣的一部分用於到 uniswap 中購買 ycrv 代幣。跟蹤 buyreserveandtransfer 函式,程式碼如下:

function buyreserveandtransfer(        

uint256 mintamount,        

uint256 offpegperc    

)        

internal    

{        

uniswappair pair = uniswappair(uniswap_pair);        

yamtokeninterface yam = yamtokeninterface(yamaddress);        

// get reserves        

(uint256 token0reserves, uint256 token1reserves, ) = pair.getreserves();        

// check if protocol has excess yam in the reserve       

 uint256 excess = yam.balanceof(reservescontract);        

//slowmist// 計算用於 uniswap 中兌換的 yam 數量        

uint256 tokens_to_max_slippage = uniswapmaxslippage(token0reserves, token1reserves, offpegperc);        

univars memory univars = univars({          

yamstouni: tokens_to_max_slippage, // how many yams uniswap needs          

amountfromreserves: excess, // how much of yamstouni comes from reserves         

minttoreserves: 0 // how much yams protocol mints to reserves        

});        

// tries to sell all mint + excess        

// falls back to selling some of mint and all of excess        

// if all else fails, sells portion of excess        

// upon pair.swap, `uniswapv2call` is called by the uniswap pair contract        

if (istoken0) {            

if (tokens_to_max_slippage > mintamount.add(excess)) {               

 // we already have performed a safemath check on mintamount+excess                

// so we dont need to continue using it in this code path                

// can handle selling all of reserves and mint                

uint256 buytokens = getamountout(mintamount + excess, token0reserves, token1reserves);                

univars.yamstouni = mintamount + excess;               

 univars.amountfromreserves = excess;                

// call swap using entire mint amount and excess; mint 0 to reserves                

pair.swap(0, buytokens, address(this), abi.encode(univars));            

} else {                

if (tokens_to_max_slippage > excess) {                    

// uniswap can handle entire reserves                   

uint256 buytokens = getamountout(tokens_to_max_slippage, token0reserves, token1reserves);                    

// swap up to slippage limit, taking entire yam reserves, and minting part of total                    //slowmist// 將多餘代幣鑄給 reserves 合約                    

univars.minttoreserves = mintamount.sub((tokens_to_max_slippage - excess));                    //slowmist// uniswap代幣交換                    

pair.swap(0, buytokens, address(this), abi.encode(univars));                

} else {                    

// uniswap cant handle all of excess                    

uint256 buytokens = getamountout(tokens_to_max_slippage, token0reserves, token1reserves);                    

univars.amountfromreserves = tokens_to_max_slippage;                    

univars.minttoreserves = mintamount;                    

// swap up to slippage limit, taking excess - remainingexcess from reserves, and minting full amount                    

// to reserves                    

pair.swap(0, buytokens, address(this), abi.encode(univars));               

 }           

 }       

 } else {            

if (tokens_to_max_slippage > mintamount.add(excess)) {                

// can handle all of reserves and mint                

uint256 buytokens = getamountout(mintamount + excess, token1reserves, token0reserves);                

univars.yamstouni = mintamount + excess;                

univars.amountfromreserves = excess;               

 // call swap using entire mint amount and excess; mint 0 to reserves                

pair.swap(buytokens, 0, address(this), abi.encode(univars));            

} else {                

if (tokens_to_max_slippage > excess) {                    

// uniswap can handle entire reserves                    

uint256 buytokens = getamountout(tokens_to_max_slippage, token1reserves, token0reserves);                    

// swap up to slippage limit, taking entire yam reserves, and minting part of total                    

//slowmist// 增發的多餘的代幣給 reserves 合約                    

univars.minttoreserves = mintamount.sub( (tokens_to_max_slippage - excess));                    

// swap up to slippage limit, taking entire yam reserves, and minting part of total                    

//slowist// 在 uniswap 中進行兌換,並最終呼叫 rebase 合約的 uniswapv2call 函式                    

pair.swap(buytokens, 0, address(this), abi.encode(univars));                

} else {                    

// uniswap cant handle all of excess                    

uint256 buytokens = getamountout(tokens_to_max_slippage, token1reserves, token0reserves);                   

 univars.amountfromreserves = tokens_to_max_slippage;                   

 univars.minttoreserves = mintamount;                   

 // swap up to slippage limit, taking excess - remainingexcess from reserves, and minting full amount                    

// to reserves                    

pair.swap(buytokens, 0, address(this), abi.encode(univars));               

 }           

 }       

 }   

 }

透過對程式碼分析,buyreserveandtransfer 首先會計算在 uniswap 中用於兌換 ycrv 的 yam 的數量,如果該數量少於 yam 的鑄幣數量,則會將多餘的增發的 yam 幣給 reserves 合約,這一步是透過 uniswap 合約呼叫 rebase 合約的 uniswapv2call 函式實現的,具體的程式碼如下:

function uniswapv2call(        

address sender,        

uint256 amount0,       

 uint256 amount1,        

bytes memory data)        

public{        

// enforce that it is coming from uniswap        

require(msg.sender == uniswap_pair, "bad msg.sender");        

// enforce that this contract called uniswap        

require(sender == address(this), "bad origin");        

(univars memory univars) = abi.decode(data, (univars));        

yamtokeninterface yam = yamtokeninterface(yamaddress);        

if (univars.amountfromreserves > 0) {            

// transfer from reserves and mint to uniswap            

yam.transferfrom(reservescontract, uniswap_pair, univars.amountfromreserves);           

 if (univars.amountfromreserves < univars.yamstouni) {                

// if the amount from reserves > yamstouni, we have fully paid for the ycrv tokens                

// thus this number would be 0 so no need to mint                

yam.mint(uniswap_pair, univars.yamstouni.sub(univars.amountfromreserves));            

}        

} else {            

// mint to uniswap            

yam.mint(uniswap_pair, univars.yamstouni);        

}        

// mint unsold to mintamount       

 //slowmist// 將多餘的 yam 代幣分發給 reserves 合約        

if (univars.minttoreserves > 0) {            

yam.mint(reservescontract, univars.minttoreserves);        

}       

 // transfer reserve token to reserves        

if (istoken0) {            

safeerc20.safetransfer(ierc20(reservetoken), reservescontract, amount1);            

emit treasuryincreased(amount1, univars.yamstouni, univars.amountfromreserves, univars.minttoreserves);        

} else {            

safeerc20.safetransfer(ierc20(reservetoken), reservescontract, amount0);            

emit treasuryincreased(amount0, univars.yamstouni, univars.amountfromreserves, univars.minttoreserves);        

}    }

分析到這裡,一個完整的 rebase 流程就完成了,你可能看得很懵,我們用簡單的流程圖簡化下:

也就是說,每次的 rebase,如果有多餘的 yam 代幣,這些代幣將會流到 reserves 合約中,那這和社羣治理的關係是什麼呢?

透過分析專案程式碼,發現治理相關的邏輯在 yamgovernoralpha.sol 中,其中發起提案的函式為 propose,具體程式碼如下:

function propose(        

address[] memory targets,        

uint[] memory values,        

string[] memory signatures,        

bytes[] memory calldatas,        

string memory description)        

public        

returns (uint256){   

//slowmist// 校驗提案發起者的票數佔比        

require(yam.getpriorvotes(msg.sender, sub256(block.number, 1)) > proposalthreshold(), "governoralpha::propose: proposer votes below proposal threshold");        

require(targets.length == values.length && targets.length == signatures.length && targets.length == calldatas.length, "governoralpha::propose: proposal function information arity mismatch");        

require(targets.length != 0, "governoralpha::propose: must provide actions");        

require(targets.length <= proposalmaxoperations(), "governoralpha::propose: too many actions");        

uint256 latestproposalid = latestproposalids[msg.sender];       

 if (latestproposalid != 0) {          

proposalstate proposerslatestproposalstate = state(latestproposalid);          

require(proposerslatestproposalstate != proposalstate.active, "governoralpha::propose: one live proposal per proposer, found an already active proposal");          

require(proposerslatestproposalstate != proposalstate.pending, "governoralpha::propose: one live proposal per proposer, found an already pending proposal");        

}        

uint256 startblock = add256(block.number, votingdelay());       

 uint256 endblock = add256(startblock, votingperiod());        

proposalcount++;       

 proposal memory newproposal = 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;      

 emit proposalcreated(            

newproposal.id,           

 msg.sender,            

targets,          

  values,       

     signatures,            

calldatas,           

 startblock,        

   endblock,          

  description        

);        

return newproposal.id;    }

透過分析程式碼,可以發現在發起提案時,需要提案發起人擁有一定額度的票權利,這個值必須大於 proposalthreshold 計算得來的值,具體程式碼如下:

function proposalthreshold() public view returns (uint256) {   return safemath.div(yam.initsupply(), 100); } // 1% of yam

也就是說提案發起人的票權必須大於 initsupply 的 1% 才能發起提案。那 initsupply 受什麼影響呢?答案是 yam 代幣的 mint 函式,程式碼如下:

function mint(address to, uint256 amount)        

external        

onlyminter        

returns (bool){        

_mint(to, amount);       

 return true;    

}   

 function _mint(address to, uint256 amount)        

internal{      

// increase totalsupply      

totalsupply = totalsupply.add(amount);      

// get underlying value      

uint256 yamvalue = amount.mul(internaldecimals).div(yamsscalingfactor);     

 // increase initsupply      

initsupply = initsupply.add(yamvalue);     

 // make sure the mint didnt push maxscalingfactor too low     

 require(yamsscalingfactor <= _maxscalingfactor(), "max scaling factor too low");      

// add balance      

_yambalances[to] = _yambalances[to].add(yamvalue);     

 // add delegates to the minter     

 _movedelegates(address(0), _delegates[to], yamvalue);      

emit mint(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 專案都需在經過專業的安全團隊充分審計後再進行上線,降低合約發生意外的風險 。

2、專案中去中心化治理應循序漸進,在專案開始階段,需要設定適當的許可權以防發生黑天鵝事件。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;