DeFi YAM,一行代码如何蒸发数亿美元?
摘要: 2020年8月13日,知名以太坊DeFi项目YAM官方通过Twitter发文表明发现合约中存在漏洞,24小时内价格暴跌99%。慢雾安全团队在收到情报后快速进行了相关的跟进及分析,以下是详细的技术细节。
作者:yudan @慢雾安全团队
前言
据链闻消息,2020年8月13日,知名以太坊DeFi项目YAM官方通过Twitter发文表明发现合约中存在漏洞,24小时内价格暴跌99%。慢雾安全团队在收到情报后快速进行了相关的跟进及分析,以下是详细的技术细节。
发生了什么?
以上是YAM官方对本次事件的替换说明(来源:
https://medium.com/@yamfinance/save-yam-245598d81cec)。
简单来说就是官方在合约中发现负责调整供应量的函数发生了问题,这个问题导致多余的YAM代币放进了YAM的储备金合约中,并且如果不修正这个问题,将会导致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函数最终调用了委托AndReturn函数,代码如下:
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) }
}
}
通过分析代码,可以发现委托和返回函数最终使用委托调用的方式调用了实现的地址中的逻辑,即,这是一个可升级的合约模型。而真正的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合约中对应的供应量调整的逻辑为rebase函数,代码是rebaser合约最终调用了YAM.sol合约中的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币给储备硬币,这一步是通过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代币,这些代币将会流到reserve合约中,那这和社区治理的关系是什么呢?
通过分析项目代码,发现治理相关的逻辑在YAMGovernorAlpha.sol中,其中发起发起的函数为proposal,具体代码如下:
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
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的值,而这个值是根据金额的值来计算的,也就是铸币的数量。
现在,我们已经分析完所有的流程了,剩下的就是把所有的分析串起来,看看这次的突破对YAM产生了什么影响,对上文的流程图做扩展,变成下面这样:
整个事件的分析如上图,由于rebase的时候取的是上一次的totalSupply的值,所以计算错误的totalSupply的值并不会立即通过mint作用到initSupply上,所以在下一次rebase前,社区拥有机会挽回这个错误,减少损失。但是一旦下一次底垫中执行,整个失误将会变得无法挽回。
通过查询Etherscan上YAM代币合约的相关信息,可以看到totalSupply已经到了一个非常大的值,而initSupply并未受到影响。
前车之鉴
此事件的事件充分暴露了有关审计DeFi合约中隐藏的巨大风险,虽然YAM开发者已经在Github中表明YAM合约的很多代码是参考了经过充分审核的DeFi项目如Compound,Ampleforth,Synthetix及YEarn / YFI,但仍无可避免地发生了意料之外的风险。
DeFi项目Yam Finance(YAM)核心开发者belmore在推特上表示:“对不起,大家。我失败了。谢谢你们今天的大力支持。我太难过了。”,但是覆水已经难收,在此,慢雾安全团队提出如下建议:
1,由于DeFi合约的高度复杂性,任何DeFi项目都需在经过专业的安全团队充分审计后再进行上线,降低合约发生意外的风险。审计可联系慢雾安全团队。
2,项目中去中心化治理应循序渐进,在项目开始阶段,需要设置适当的权限以防发生黑天鹅事件。
文章来源:慢雾安全团队
评论(0)
Oh! no
您是否确认要删除该条评论吗?