昨日,以太坊 DeFi 項目 YAM 官方在 Twitter 發文表明發現智能合約中存在漏洞,24小時內價格暴跌99%。慢霧安全團隊快速進行了相關的跟進及分析,本文將詳細說明技術細節。
(前情提要:挖蕃薯風波|Yam Finance 爆智能合約漏洞!Defi 項目未經審計釀隱憂?)
(事件背景:DeFi捷報》超越BCH!Chainlink成市值排名第五;Yam效應驅動,Maker大漲近 40%)
前言
2020年8月13日,知名以太坊DeFi項目YAM官方通過Twitter發文表明發現合約中存在漏洞,24小時內價格暴跌99%。
慢霧安全團隊在收到情報後快速進行了相關的跟進及分析,以下是詳細的技術細節。
發生了什麼?
簡單來說就是官方在合約中發現負責調整供應量的函數發生了問題,這個問題導致多餘的YAM代幣放進了YAM的儲備金合約中,並且如果不修正這個問題,將導致YAM的後續治理不會發生。
延伸閱讀:半天飆漲 1,500%!FTX 推出的 DeFi 產品 Serum (SRM),空投百萬獎勵效果驚人
延伸閱讀:DeFi 概念股|預言機項目ChainLink半年飆漲 7 倍、BAND 暴漲 40 倍!下個牛市風口在哪裡?
同時,官方宣布了此次擴展的具體問題程式碼,如下:
從上圖可知,由於編碼不規範,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;
}
通過分析程式碼,可以發現在發起初步時,需要進行發起人擁有一定額度的票務權利,這個值必須大於proposal閾值計算得來的值,具體程式碼如下:
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並未受到影響。
延伸閱讀:dForce AMA 總整理| 還原 Lendf.Me 遭駭始末,坦言考慮部分中心化機制
延伸閱讀:不到24小時!Balancer再遭閃電貸駭客攻擊,這次被盜走的是 COMP (DeFi)
前車之鑑
這次事件中充分暴露了有關審計DeFi合約中隱藏的巨大風險,儘管YAM開發者已經在Github中表明YAM合約的很多程式碼是參考了經過充分審核的DeFi項目如Compound,Ampleforth,Synthetix及YEarn / YFI,但仍無可避免地發生了意料之外的風險。
DeFi項目Yam Finance(YAM)核心開發者belmore在推特上表示:“對不起,大家。我失敗了。謝謝你們今天的大力支持。我太難過了。” 但是覆水已經難收。
在此,慢霧安全團隊提出如下建議:
1,由於DeFi合約的高度複雜性,任何DeFi項目都需在經過專業的安全團隊充分審計後再進行上線,降低合約發生意外的風險。審計可聯繫慢霧安全團隊(team@slowmist.com)
2,項目中去中心化治理應循序漸進,在項目開始階段,需要設置適當的權限以防發生黑天鵝事件。
📍相關報導📍
觀點|無價值治理代幣 YFI:開發者僅一人,一天瘋漲 40 倍。DeFi 泡沫何時破?
DeFi|從數據看MakerDAO 的「多質押品系統升級」與去中心化鏈上治理
推特混戰|Kraken 研究員 : 以太坊「未知的供給量」恐釀隱憂,2.0 上線後風險擴大
讓動區 Telegram 新聞頻道再次強大!!立即加入獲得第一手區塊鏈、加密貨幣新聞報導。
LINE 與 Messenger 不定期為大家服務