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[i];      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 計算得來的值,具體程式碼如下:

functionproposalThreshold()publicviewreturns(uint256){  returnSafeMath.div(yam.initSupply(),100);}//1%ofYAM

也就是說提案發起人的票權必須大於 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 專案都需在經過專業的安全團隊充分審計後再進行上線,降低合約發生意外的風險。審計可聯絡慢霧安全團隊([email protected]

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

免責聲明:

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

推荐阅读

;