EToken
's design enables users to increase their debt by minting EToken and using the new EToken as collateral directly ("self-borrowing"). This means that even if there is no liquidity remaining, the user can still leverage up.
To ensure proper collateralization, the contract examines the user's health status during transferFrom()
. However, the EToken.donateToReserves()
function allows users to donate EToken to the protocol without checking their health status. This enables users to make their own accounts insolvent.
Moreover, Euler's liquidation process includes a liquidation discount. When the liquidator initiates liquidation on an insolvent account, a discount is applied based on the healthScore
of that account. The lower the healthScore, the greater the discount.
If the insolvent account is deep underwater, the liquidator may get collateral without paying any of the debt.
Lack of health check in EToken.donateToReserves()
allows users to make their account insolvent.
The design of using healthScore
to provide discounts in liquidate()
allows anyone to liquidate an insolvent account without repaying any of the debt and still receive the collateral and can later redeem it into cash.
We analyzed the following attack transaction to elaborate on the attack vector: https://etherscan.io/tx/0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d
In this step, the attacker first utilized the "self-borrow" feature to create an account with a huge amount of collateral and liability (420M eDAI and 390M dDAI). They then proceeded to flash loan 30M DAI.
Then, the attacker donated 100M eDAI to the reserves and turned the account into an insolvent account with a shortage of 70M bad debt (320M eDAI - 390M dDAI).
Liquidation.liquidate()
;RiskManager.computeLiquidity()
was invoked to compute the liquidation discount;This liquidation process moved the debt and collateral from the insolvent account to the liquidator's account without reducing any debt.
The system's overall debt amount and collateral amount remain unchanged.
However, as the liquidation discount makes the liquidator receive a larger amount of collateral by only bearing a smaller amount of debt, the liquidation results in a net loss for the system.
This step turned the insolvent account with 70M bad debt into 60M of profit (317M eDAI - 254M dDAI).
https://github.com/euler-xyz/euler-contracts/blob/master/contracts/modules/EToken.sol#L359-L386
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);
}
https://github.com/euler-xyz/euler-contracts/blob/master/contracts/modules/Liquidation.sol#L80-L103
liqOpp.healthScore = collateralValue * 1e18 / liabilityValue;
if (collateralValue >= liabilityValue) {
return; // no violation
}
// At this point healthScore must be < 1 since collateral < liability
// Compute discount
{
uint baseDiscount = UNDERLYING_RESERVES_FEE + (1e18 - liqOpp.healthScore);
uint discountBooster = computeDiscountBooster(liqLocs.liquidator, liabilityValue);
uint discount = baseDiscount * discountBooster / 1e18;
if (discount > (baseDiscount + MAXIMUM_BOOSTER_DISCOUNT)) discount = baseDiscount + MAXIMUM_BOOSTER_DISCOUNT;
if (discount > MAXIMUM_DISCOUNT) discount = MAXIMUM_DISCOUNT;
liqOpp.baseDiscount = baseDiscount;
liqOpp.discount = discount;
liqOpp.conversionRate = liqLocs.underlyingPrice * 1e18 / liqLocs.collateralPrice * 1e18 / (1e18 - discount);
}
https://github.com/euler-xyz/euler-contracts/blob/master/contracts/modules/Liquidation.sol#L139-L151
// Limit yield to borrower's available collateral, and reduce repay if necessary
// This can happen when borrower has multiple collaterals and seizing all of this one won't bring the violator back to solvency
liqOpp.yield = liqOpp.repay * liqOpp.conversionRate / 1e18;
{
uint collateralBalance = balanceToUnderlyingAmount(collateralAssetCache, collateralAssetStorage.users[liqLocs.violator].balance);
if (collateralBalance < liqOpp.yield) {
liqOpp.repay = collateralBalance * 1e18 / liqOpp.conversionRate;
liqOpp.yield = collateralBalance;
}
}