Euler 攻击事件分析
好久没写博客了,今天忽然出了个大事,euler被黑了1.7亿。本身就是熊市,euler也算是比较大型的协议,这次被黑这么多金额属实出乎意料,最主要的是去年实习的时候我还看过这个协议。。本着求知的态度(甩锅)分析了这个攻击事件一晚上,和群里的小伙伴一边交谈一边理清了这次的攻击事件。
Euler介绍
这个协议代码还是很清奇的,所有的功能都是模块化的,当时看的时候就觉得惊为天人,第一次遇到这种写法。
Euler本质上属于抵押借贷协议,与其他协议不同的一点是它有个self borrow的逻辑,允许自己给自己铸造等量的债务token(DToken)和资产凭证token(EToken)。
/// @notice Mint eTokens and a corresponding amount of dTokens ("self-borrow")
/// @param subAccountId 0 for primary, 1-255 for a sub-account
/// @param amount In underlying units
function mint(uint subAccountId, uint amount) external nonReentrant {
(address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
address account = getSubAccount(msgSender, subAccountId);
updateAverageLiquidity(account);
emit RequestMint(account, amount);
AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
amount = decodeExternalAmount(assetCache, amount);
uint amountInternal = underlyingAmountToBalanceRoundUp(assetCache, amount);
amount = balanceToUnderlyingAmount(assetCache, amountInternal);
// Mint ETokens
increaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal);
// Mint DTokens
increaseBorrow(assetStorage, assetCache, assetStorage.dTokenAddress, account, amount);
checkLiquidity(account);
logAssetStatus(assetCache);
}
在2022年7月的代码更新中增加了一个donate的功能,允许用户向池子捐钱:
/// @notice Donate eTokens to the reserves
/// @param subAccountId 0 for primary, 1-255 for a sub-account
/// @param amount In internal book-keeping units (as returned from balanceOf).
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
(address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
address account = getSubAccount(msgSender, subAccountId);
updateAverageLiquidity(account);
emit RequestDonate(account, amount);
AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
uint origBalance = assetStorage.users[account].balance;
uint newBalance;
if (amount == type(uint).max) {
amount = origBalance;
newBalance = 0;
} else {
require(origBalance >= amount, "e/insufficient-balance");
unchecked { newBalance = origBalance - amount; }
}
assetStorage.users[account].balance = encodeAmount(newBalance);
assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);
emit Withdraw(assetCache.underlying, account, amount);
emitViaProxy_Transfer(proxyAddr, account, address(0), amount);
logAssetStatus(assetCache);
}
漏洞点
问题就出在上面两个函数中,正常来说这种抵押借贷协议的资产凭证代币转账都需要检查健康因子,保证协议的稳定不出现坏账,Euler在EToken转账功能的实现中都考虑了这个问题,比如下面的transferFrom函数:
function transferFrom(address from, address to, uint amount) public nonReentrant returns (bool) {
(address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
if (from == address(0)) from = msgSender;
require(from != to, "e/self-transfer");
updateAverageLiquidity(from);
updateAverageLiquidity(to);
emit RequestTransferEToken(from, to, amount);
if (amount == 0) return true;
if (!isSubAccountOf(msgSender, from) && assetStorage.eTokenAllowance[from][msgSender] != type(uint).max) {
require(assetStorage.eTokenAllowance[from][msgSender] >= amount, "e/insufficient-allowance");
unchecked { assetStorage.eTokenAllowance[from][msgSender] -= amount; }
emitViaProxy_Approval(proxyAddr, from, msgSender, assetStorage.eTokenAllowance[from][msgSender]);
}
transferBalance(assetStorage, assetCache, proxyAddr, from, to, amount);
checkLiquidity(from);
// Depositing a token to an account with pre-existing debt in that token creates a self-collateralized loan
// which may result in borrow isolation violation if other tokens are also borrowed on the account
if (assetStorage.users[to].owed != 0) checkLiquidity(to);
logAssetStatus(assetCache);
return true;
}
但是,在donate函数中却没有调用checkLiquidity(account)。想来也没问题,正常转账验证liquidity是怕用户把钱转走变成坏账,但是你donate的话钱还在协议里面,你把自己搞坏账了等着清算就行,协议本身不损失啥,所以不做检查有情可原。
但是结合了mint函数,也就是self borrow机制,这个问题就出来了,攻击者也就是利用了这两个功能实施的攻击。
试想一下,如果一个用户将存在协议中的钱大部分都捐给了协议造成自己的资不抵债,那么这个用户属于是自己想不开,获利的就是清算者。
但是如果清算者和造成坏账的用户是同一人呢?那么常规逻辑看,这个用户脑子有病,清算也不可能获得比你债务或者抵押物更高的收益。
但是如果结合了self borrow函数,事情就不一样了。
攻击分析
攻击步骤
只拿一次攻击交易作为分析:https://phalcon.blocksec.com/tx/eth/0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d
攻击者实施攻击的步骤如下:
- 闪电贷从aave借出30mDAI
- deposit 10m DAI获得 10m eDAI
- self borrow 200m DAI,此时攻击者拥有 200m dDAI 和 210m eDAI
- repay 10m DAI,此时攻击者拥有 190mdDAI 和200m eDAI
- self borrow 200m DAI,此时攻击者拥有 390dDAI 和 400m eDAI
- donate 100m eDAI,此时攻击者拥有 390 dDAI 和 300m eDAI,攻击者 borrow合约达到了被清算条件
- liquidator合约进行清算,清算borrower 259m dDAI,获得317m eDAI
- 清算完成后,攻击者资产状况良好,可以进行提款,Euler资金池中有38m DAI
- 提款 38M DAI,还钱,获利8m
分析
整个的漏洞问题是什么?
答:超额抵押协议没有考虑到相同资产资不抵债的情况,正常情况下,传统的抵押借贷协议当债务达到抵押物价值80%左右的情况下就可以被清算,否则如果债务和抵押物比率达到100或以上就会出现坏账情况,一般情况下协议都不会允许这种情况发生,当达到清算标准时,链上套利机器人就会出手进行清算套利。
成因是什么?
答:没有考虑到self borrow 和 donate结合能够达到自己将自己的债务破坏到一个非常不良的状况。
为什么能获利?
答:一般来说清算获利一定会大于清算所承担债务,但是这个比率通常不会很大,比如获利101,承担债务100这种比值,因为有许多的清算方在相互竞争。
Euler采用的清算方式是承担债务和收益,然后查看清算方的债务状况:
function executeLiquidation(LiquidationLocals memory liqLocs, uint desiredRepay, uint minYield) private {
require(desiredRepay <= liqLocs.liqOpp.repay, "e/liq/excessive-repay-amount");
uint repay;
{
AssetStorage storage underlyingAssetStorage = eTokenLookup[underlyingLookup[liqLocs.underlying].eTokenAddress];
AssetCache memory underlyingAssetCache = loadAssetCache(liqLocs.underlying, underlyingAssetStorage);
if (desiredRepay == liqLocs.liqOpp.repay) repay = liqLocs.repayPreFees;
else repay = desiredRepay * (1e18 * 1e18 / (1e18 + UNDERLYING_RESERVES_FEE)) / 1e18;
{
uint repayExtra = desiredRepay - repay;
// Liquidator takes on violator's debt:
transferBorrow(underlyingAssetStorage, underlyingAssetCache, underlyingAssetStorage.dTokenAddress, liqLocs.violator, liqLocs.liquidator, repay);
// Extra debt is minted and assigned to liquidator:
increaseBorrow(underlyingAssetStorage, underlyingAssetCache, underlyingAssetStorage.dTokenAddress, liqLocs.liquidator, repayExtra);
// The underlying's reserve is credited to compensate for this extra debt:
{
uint poolAssets = underlyingAssetCache.poolSize + (underlyingAssetCache.totalBorrows / INTERNAL_DEBT_PRECISION);
uint newTotalBalances = poolAssets * underlyingAssetCache.totalBalances / (poolAssets - repayExtra);
increaseReserves(underlyingAssetStorage, underlyingAssetCache, newTotalBalances - underlyingAssetCache.totalBalances);
}
}
logAssetStatus(underlyingAssetCache);
}
uint yield;
{
AssetStorage storage collateralAssetStorage = eTokenLookup[underlyingLookup[liqLocs.collateral].eTokenAddress];
AssetCache memory collateralAssetCache = loadAssetCache(liqLocs.collateral, collateralAssetStorage);
yield = repay * liqLocs.liqOpp.conversionRate / 1e18;
require(yield >= minYield, "e/liq/min-yield");
// Liquidator gets violator's collateral:
address eTokenAddress = underlyingLookup[collateralAssetCache.underlying].eTokenAddress;
transferBalance(collateralAssetStorage, collateralAssetCache, eTokenAddress, liqLocs.violator, liqLocs.liquidator, underlyingAmountToBalance(collateralAssetCache, yield));
logAssetStatus(collateralAssetCache);
}
// Since liquidator is taking on new debt, liquidity must be checked:
checkLiquidity(liqLocs.liquidator);
emitLiquidationLog(liqLocs, repay, yield);
}
所以正常情况下,清算方需要拥有一定数额的抵押物才能进行清算。
本次攻击通过selfborrow和donate将自己的抵押物债务比降低到了一个小于1的数值,造成了清算获利直接就能cover抵押ltv,所以清算时,清算合约不需要提前向协议充钱。
结语
这种漏洞如何防范?
这种洞属于两个业务逻辑的冲突,比较稀奇古怪,单单针对于此次事件建议在donate函数中引入checkLiquidity检查。对于其他的抵押借贷协议建议查看在加杠杆的时候做好使用资金的边界检查。
ps:由于看这个项目的时候是在去年四月,donate功能是在7月一次更新中加入,当时我还考虑了这个self borrow的问题:
不禁在想如果当时就有donate函数,我能看出来吗hhhh