Background on Euler Finance
Euler Finance was a non-custodial lending protocol on Ethereum that allowed users to lend and borrow almost any ERC-20 token. What made Euler unique was its permissionless listing and reactive interest rate model.
Before the hack, Euler had accumulated over $200M in Total Value Locked (TVL) and was considered one of the leading DeFi lending protocols.
The Vulnerability
The exploit combined two issues in Euler's codebase:
🔓 Issue #1: Donation Function
The donateToReserves() function allowed users to donate eTokens to the protocol reserves without properly checking if the donor had sufficient collateral.
🔓 Issue #2: Soft Liquidation
When a user's health factor dropped below 1, anyone could liquidate them. However, the liquidation process didn't account for the artificially deflated eToken balance.
Root Cause
donateToReserves() reduced the user's eToken balance without a corresponding health check, allowing users to become artificially underwater.Attack Flow
Flash Loan DAI
Attacker borrowed 30M DAI from Aave V2 via flash loan
Deposit & Mint eTokens
Deposited 20M DAI into Euler, receiving eDAI tokens
Borrow with Leverage
Used mint() to create leveraged position (10x)
Donate to Reserves 💥
Called donateToReserves() with most of their eDAI, making themselves liquidatable
Self-Liquidate
Created a second account to liquidate the first, receiving the collateral at a discount
Withdraw & Repay
Withdrew DAI from Euler and repaid the flash loan with profit
Code Analysis
Let's examine the vulnerable donateToReserves() function:
1// Vulnerable donateToReserves function
2function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
3 (address underlying, AssetStorage storage assetStorage,
4 address proxyAddr, address msgSender) = CALLER();
5
6 // Get user's sub-account
7 address account = getSubAccount(msgSender, subAccountId);
8
9 // Update user's eToken balance - NO HEALTH CHECK! ❌
10 assetStorage.users[account].balance =
11 encodeAmount(
12 decodeAmount(assetStorage.users[account].balance) - amount
13 );
14
15 // Add to reserves
16 assetStorage.reserveBalance =
17 assetStorage.reserveBalance + amount;
18
19 // Emit event
20 emit Transfer(account, address(0), amount);
21
22 // Missing: checkLiquidity(account) ❌
23}What's Missing
checkLiquidity(account) call after reducing the user's balance to ensure they remain solvent.Here's what the fixed version looks like:
1// Fixed donateToReserves function
2function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
3 (address underlying, AssetStorage storage assetStorage,
4 address proxyAddr, address msgSender) = CALLER();
5
6 address account = getSubAccount(msgSender, subAccountId);
7
8 // Update balance
9 assetStorage.users[account].balance =
10 encodeAmount(
11 decodeAmount(assetStorage.users[account].balance) - amount
12 );
13
14 assetStorage.reserveBalance =
15 assetStorage.reserveBalance + amount;
16
17 emit Transfer(account, address(0), amount);
18
19 // ✅ Critical: Check liquidity after balance change
20 checkLiquidity(account);
21}Aftermath & Recovery
In a surprising turn of events, the attacker eventually returned the stolen funds:
Attack executed, $197M drained from protocol
Euler team offers $1M bounty for information leading to arrest
Attacker sends on-chain message apologizing
Attacker returns ~$89M in multiple transactions
Lessons Learned
✅ Always Check Invariants
After any state change that affects user balances, verify that system invariants (like health factors) still hold.
✅ Audit Donation Functions
Donation and reserve functions can be attack vectors if they don't properly validate the donor's resulting position.
✅ Flash Loan Testing
Always test protocols with flash loans in mind. Any function that can be called atomically should be tested for manipulation.
✅ Multiple Audits
Euler had been audited multiple times, but this bug was missed. Consider ongoing security reviews and bug bounties.