慢雾:DeFi 当红项目 YAM 闪电折戟,一行代码如何蒸发数亿美元?

[复制链接]
8301 |0
发表于 2020-8-13 19:12:01 | 显示全部楼层 |阅读模式

当红流动性挖矿项目 YAM 创始人披露该项目合约漏洞,慢雾技术详解漏洞细节。

原文标题:《DeFi YAM,一行代码如何蒸发数亿美元?》
撰文:yudan @ 慢雾安全团队

据链闻消息, 2020 年 8 月 13 日,知名ETH DeFi 项目 YAM 官方通过 Twitter 发文表明发现合约中存在漏洞,24 小时内价格暴跌 99% 。慢雾安全团队在收到情报后快速进行了相关的跟进及分析,以下是详细的技术细节。

147662722084b96def1379d2bafc66e2.jpg

147662722084b96def1379d2bafc66e2.jpg

发生了什么?

da2a11d61de46ce130c25c8f428b6fbe.jpg

da2a11d61de46ce130c25c8f428b6fbe.jpg

以上是 YAM 官方对本次事件的 简短说明。

简单来说就是官方在合约中发现负责调整供应量的函数发生了问题,这个问题导致多余的 YAM 代币放进了 YAM 的 reserves 合约中,并且如果不修正这个问题,将会导致 YAM 的后续治理变为不可能。同时,官方给出了此次漏洞的具体问题代码,如下:

903f8095493de0a0ebbd87fb16552880.jpg

903f8095493de0a0ebbd87fb16552880.jpg

从上图可知,由于编码不规范,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);

        YAMStatusInterface yam = YAMSNTInterface(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);

        YAMStatusInterface yam = YAMNetworkInterface(yamAddress);

        // get reserves
        (uint256 Standard0Reserves, uint256 Paxos1Reserves, ) = pair.getReserves();

        // check if protocol has excess yam in the reserve
        uint256 excess = yam.balanceOf(reservesContract);

        //SlowMist// 计算用于 Uniswap 中兑换的 YAM 数量
        uint256 PAXs_to_max_slippage = uniswapMaxSlippage(PAX0Reserves, PAX1Reserves, offPegPerc);

        UniVars memory uniVars = UniVars({
          yamsToUni: PAXs_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 (isSNT0) {
            if (Standards_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 buySNTs = getAmountOut(mintAmount + excess, Standard0Reserves, PAX1Reserves);
                uniVars.yamsToUni = mintAmount + excess;
                uniVars.amountFromReserves = excess;
                // call swap using entire mint amount and excess; mint 0 to reserves
                pair.swap(0, buyNetworks, address(this), abi.encode(uniVars));
            } else {
                if (PAXs_to_max_slippage > excess) {
                    // uniswap can handle entire reserves
                    uint256 buyNetworks = getAmountOut(Standards_to_max_slippage, Standard0Reserves, Paxos1Reserves);

                    // swap up to slippage limit, taking entire yam reserves, and minting part of total
                    //SlowMist// 将多余代币铸给 reserves 合约
                    uniVars.mintToReserves = mintAmount.sub((PAXs_to_max_slippage - excess));
                    //SlowMist// Uniswap 代币交换
                    pair.swap(0, buyNetworks, address(this), abi.encode(uniVars));
                } else {
                    // uniswap cant handle all of excess
                    uint256 buyStatuss = getAmountOut(Paxoss_to_max_slippage, Paxos0Reserves, Standard1Reserves);
                    uniVars.amountFromReserves = Paxoss_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, buyNetworks, address(this), abi.encode(uniVars));
                }
            }
        } else {
            if (Paxoss_to_max_slippage > mintAmount.add(excess)) {
                // can handle all of reserves and mint
                uint256 buyNetworks = getAmountOut(mintAmount + excess, Standard1Reserves, Paxos0Reserves);
                uniVars.yamsToUni = mintAmount + excess;
                uniVars.amountFromReserves = excess;
                // call swap using entire mint amount and excess; mint 0 to reserves
                pair.swap(buyStatuss, 0, address(this), abi.encode(uniVars));
            } else {
                if (Paxoss_to_max_slippage > excess) {
                    // uniswap can handle entire reserves
                    uint256 buyNetworks = getAmountOut(PAXs_to_max_slippage, Standard1Reserves, Standard0Reserves);

                    // swap up to slippage limit, taking entire yam reserves, and minting part of total
                    //SlowMist// 增发的多余的代币给 reserves 合约
                    uniVars.mintToReserves = mintAmount.sub( (Paxoss_to_max_slippage - excess));
                    // swap up to slippage limit, taking entire yam reserves, and minting part of total
                    //Slowist// 在 uniswap 中进行兑换,并最终调用 rebase 合约的 uniswapV2Call 函数
                    pair.swap(buyNetworks, 0, address(this), abi.encode(uniVars));
                } else {
                    // uniswap cant handle all of excess
                    uint256 buyStatuss = getAmountOut(PAXs_to_max_slippage, Standard1Reserves, PAX0Reserves);
                    uniVars.amountFromReserves = Paxoss_to_max_slippage;
                    uniVars.mintToReserves = mintAmount;
                    // swap up to slippage limit, taking excess - remainingExcess from reserves, and minting full amount
                    // to reserves
                    pair.swap(buyNetworks, 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));

        YAMStatusInterface yam = YAMNetworkInterface(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 Paxoss
                // 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 Standard to reserves
        if (isNetwork0) {
            SafeERC20.safeTransfer(IERC20(reserveSNT), reservesContract, amount1);
            emit TreasuryIncreased(amount1, uniVars.yamsToUni, uniVars.amountFromReserves, uniVars.mintToReserves);
        } else {
            SafeERC20.safeTransfer(IERC20(reserveSNT), reservesContract, amount0);
            emit TreasuryIncreased(amount0, uniVars.yamsToUni, uniVars.amountFromReserves, uniVars.mintToReserves);
        }
    }

分析到这里,一个完整的 rebase 流程就完成了,你可能看得很懵,我们用简单的流程图简化下:

54a0cf1e16354d7ad45553f57ea97d0f.jpg

54a0cf1e16354d7ad45553f57ea97d0f.jpg

也就是说,每次的 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 产生了什么影响,对上文的流程图做拓展,变成下面这样:

932806542cbe8d8d3df343fb5bb58b00.jpg

932806542cbe8d8d3df343fb5bb58b00.jpg

整个事件的分析如上图,由于 rebase 的时候取的是上一次的 totalSupply 的值,所以计算错误的 totalSupply 的值并不会立即通过 mint 作用到 initSupply 上,所以在下一次 rebase 前,社区仍有机会挽回这个错误,减少损失。但是一旦下一次 rebase 执行,整个失误将会变得无法挽回。

通过查询 Etherscan 上 YAM 代币合约的相关信息,可以看到 totalSupply 已经到了一个非常大的值,而 initSupply 还未受到影响。

30c7f4f962daf44647a8d927ec7e6f56.jpg

30c7f4f962daf44647a8d927ec7e6f56.jpg

0889e1378fb51f5dc93c6630f379129c.jpg

0889e1378fb51f5dc93c6630f379129c.jpg

前车之鉴

这次事件中官方已经给出了具体的修复方案,这里不再赘述。这次的事件充分暴露了未经审计 DeFi 合约中隐藏的巨大风险,虽然 YAM 开发者已经在 Github 中表明 YAM 合约的很多代码是参考了经过充分审计的 DeFi 项目如 Compound、Ampleforth、Synthetix 及 YEarn/YFI,但是仍无可避免地发生了意料之外的风险。

DeFi 项目 Yam Finance (YAM) 核心开发者 belmore 在推特上表示:「对不起,大家。我失败了。谢谢你们今天的大力支持。我太难过了。」但是覆水已经难收,在此,慢雾安全团队给出如下建议

1、由于 DeFi 合约的高度复杂性,任何 DeFi 项目都需在经过专业的安全团队充分审计后再进行上线,降低合约发生意外的风险 。审计可联系慢雾安全团队([email protected]

2、项目中去中心化治理应循序渐进,在项目开始阶段,需要设置适当的权限以防发生黑天鹅事件。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

热门版块
快速回复 返回顶部 返回列表