WATCHPUG2023-3-13 Euler Finance Incident Analysis

2023-3-13 Euler Finance Incident Analysis

Summary

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.

Root Cause

  1. Lack of health check in EToken.donateToReserves() allows users to make their account insolvent.

  2. 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.

Attack Vector

We analyzed the following attack transaction to elaborate on the attack vector: https://etherscan.io/tx/0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d

Step 1, Manufacture an insolvent account.

  1. Deposited 20M DAI into eDAI;
  2. Borrowed 200M eDAI using the 20M eDAI as collateral ("self-borrow");
  3. Repaid 10M DAI (to improve health score and borrow again);
  4. Borrowed another 200M eDAI;
  5. Donated 100M eDAI to the reserves, the collateral and liability became 320M eDAI and 390M dDAI.

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).

Step 2, Liquidate the insolvent account

  1. Called Liquidation.liquidate();
  2. RiskManager.computeLiquidity() was invoked to compute the liquidation discount;
  3. As the manufactured insolvent account was deep underwater, the effective liquidate discount was 20%;
  4. Recvied 317M eDAI by bearing a 254M dDAI debt (254/317 == 0.8);
  5. Withdrew 38.9M eDAI (the max amount possible), and left the debt unpaid.

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).

Code references

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;
    }
}