Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
Euler Finance Hack Replayed: the One Missing Health Check

Euler Finance Hack Replayed: the One Missing Health Check

15 min
On this page▾

TL;DR

  • On 13 March 2023, Euler Finance lost about $197M. Every call the attacker made was a public Euler function, or an Aave flash loan, and none of them reverted.
  • The bug was one missing line. Every Euler function that could weaken an account, like withdraw and mint, ended with checkLiquidity(account). donateToReserves lowered the donor's collateral and never ran that check.
  • donateToReserves was not part of the original code. It arrived later, as part of a fix for an earlier bug report.
  • The attacker donated their own collateral until their account was underwater, then liquidated it from a second contract. Euler's liquidation discount grows as the health factor falls, so the attacker bought their own bad position at the maximum 20% discount. In the DAI market alone, that emptied the pool: 38.9M DAI.
  • We rebuilt Euler's DAI market in Foundry and replayed the attack locally. Our figures land within 0.1% of the real transaction. With the one missing line added, the attack reverts at step six.
  • The check for your own protocol: every function that lowers collateral or raises debt ends with the health check, including the small ones added later as a fix.

Video · 5 minPlays from YouTube (privacy-enhanced mode) when you press play.

Watch the replay

The video (5:11) runs the attack and the fix on screen, with Euler's own code and the real test output. This article covers the same ground in text, with the code and the numbers side by side.

What happened in the Euler Finance hack?

The Euler Finance hack was a $197M exploit of the Euler v1 lending protocol on Ethereum, on 13 March 2023 (CertiK and BlockSec post-mortems). Every call the attacker made was one of Euler's public user functions, plus Aave's flash loan, and none of them reverted. The order was the attack: a flash loan, a deposit, two leveraged mints, a repayment, then a donation of their own collateral to the protocol's reserves, a liquidation of themselves, and a withdrawal.
The $197M is the total for the whole incident. This article follows one market, DAI, the way the video does, because the attack transaction for that market is public and the numbers can be checked line by line: transaction 0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d on Ethereum mainnet.
For how flash loans turn small flaws like this one into nine-figure losses across many protocols, see our flash loan attacks breakdown. This article stays on Euler, and on the one line.

How Euler v1 accounted for collateral and debt

Euler v1 tracked every position with two tokens per asset:
  1. Deposit DAI, get eDAI. eDAI is your collateral.
  2. Borrow, get dDAI. dDAI is your debt.
  3. mint gives you both at once. Euler's own code calls it a "self-borrow": you receive eTokens and the matching dTokens in one call, borrowing against yourself.
  4. Health = your collateral, weighted, divided by your debt. The weighting is a collateral factor; for a self-borrow in the same asset, Euler v1 used 0.95 (Constants.sol).
  5. Below 1, anyone can liquidate you.
Self-borrowing is what made the attack large. With mint, an account can stack up hundreds of millions of collateral and debt that sit almost exactly on top of each other, staying just above a health of 1.

The missing line: withdraw versus donateToReserves

Every function that weakens an account ends the same way

Here is withdraw, lines 180 to 200 of Euler v1's contracts/modules/EToken.sol (from the euler-xyz/euler-contracts repository, read 8 Nov 2026):
1function withdraw(uint subAccountId, uint amount) external nonReentrant {
2 (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
3 address account = getSubAccount(msgSender, subAccountId);
4
5 updateAverageLiquidity(account);
6 emit RequestWithdraw(account, amount);
7
8 AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
9
10 uint amountInternal;
11 (amount, amountInternal) = withdrawAmounts(assetStorage, assetCache, account, amount);
12 require(assetCache.poolSize >= amount, "e/insufficient-pool-size");
13
14 pushTokens(assetCache, msgSender, amount);
15
16 decreaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal);
17
18 checkLiquidity(account);
19
20 logAssetStatus(assetCache);
21}
It sends you the tokens, lowers your balance, and then runs checkLiquidity(account). If your health is now below 1, the whole transaction reverts. mint ends with the same line.

donateToReserves lowers your balance and stops

Here is donateToReserves, lines 359 to 386 of the same file:
1function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
2 (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
3 address account = getSubAccount(msgSender, subAccountId);
4
5 updateAverageLiquidity(account);
6 emit RequestDonate(account, amount);
7
8 AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
9
10 uint origBalance = assetStorage.users[account].balance;
11 uint newBalance;
12
13 if (amount == type(uint).max) {
14 amount = origBalance;
15 newBalance = 0;
16 } else {
17 require(origBalance >= amount, "e/insufficient-balance");
18 unchecked { newBalance = origBalance - amount; }
19 }
20
21 assetStorage.users[account].balance = encodeAmount(newBalance);
22 assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);
23
24 emit Withdraw(assetCache.underlying, account, amount);
25 emitViaProxy_Transfer(proxyAddr, account, address(0), amount);
26
27 logAssetStatus(assetCache);
28}
It lets you give your eDAI to the protocol's reserves. It lowers your collateral balance, just like withdraw. Read it to the end: there is no checkLiquidity. Your debt stays where it was, so after a large enough donation your account is underwater, and nothing stops the transaction.

It arrived later, as a fix

donateToReserves was not in the original code. According to the whitehat who reported that earlier bug, the fix for that report is what introduced it, and Euler's CEO told The Block the change had been audited and approved through governance (The Block). The lesson is about process as much as code: a small function added after launch, as part of a fix, sat next to code that already had the right pattern and did not follow it. Our post-audit security piece covers why a fix needs the same review as the launch.

The Euler attack, step by step

These are the nine steps of the DAI attack, from the attack transaction as CertiK and BlockSec analysed it:
StepWhat the attacker didThe attacking account after it
1Flash loan of 30M DAI from Aave(outside Euler)
2Deposit 20M DAI into Eulerhealthy
3mint 200M: 200M of dDAI debt and 195.6M eDAIhealthy, checkLiquidity passes
4Repay 10M of the debthealthy
5mint 200M againhealthy, checkLiquidity passes
6Donate 100M eDAI to the reservesunderwater, nothing checked
7A second attacker contract liquidates the firstliquidated at a 20% discount
8The second contract withdraws 38.9M DAI, everything left in the pool(pool empty)
9Repay the flash loan(outside Euler)
After step six, the attacking account held about 310M eDAI of collateral against 390M dDAI of debt (BlockSec: "310M eDAI vs. 390M dDAI"). It was underwater on purpose, and no function had checked.
In step seven, the liquidator contract took all of that eDAI, about 310.93M by CertiK's figures, and only about 259.32M of the debt. It then withdrew the 38.9M DAI the pool held and repaid Aave. The rest of the debt was left in the protocol as bad debt.

Why the attack paid: a self-liquidation at the maximum discount

The donation created the bad debt. The liquidation discount turned it into profit.
Euler's liquidation discount grows the sicker the account is. In Euler v1's Liquidation.sol (lines 91 to 98), the base discount is the reserves fee, 2%, plus (1 - health), capped at a maximum of 20%. A small booster of up to 2.5% sits in between; the 20% cap applies either way.
That is good design on its own. The worse a position gets, the bigger the reward, and the faster liquidators show up to close it.
The attacker controlled both sides. They made their own account as sick as they wanted with the donation, then bought it from themselves, from a second contract, at the maximum discount. In our rebuild, health after the donation is 0.77: 2% plus 23% is 25%, so the discount hits the 20% cap. CertiK's analysis confirms the real liquidation "gave a 20% discount". That is a self-liquidation: the attacker is the borrower and the liquidator at once, and the discount that was meant to pay a stranger for cleaning up risk paid the attacker for creating it.

Replaying the Euler exploit in Foundry

What we rebuilt

We rebuilt Euler's DAI market as a minimal standalone Foundry project and ran the attack as a test. Nothing touches a live network: the test runs on Foundry's local in-memory chain.
The rebuild uses the same nine steps, the same amounts and the same starting pool as the real transaction: 8,904,507 DAI in Euler before the deposit, CertiK's figure. It uses Euler v1's own constants: the 0.95 self-collateral factor (Constants.sol), the 2% reserves fee and the 20% maximum discount (Liquidation.sol).
It is simplified, and the numbers below should be read that way: one asset, a fixed eToken rate of 1.0225 DAI per eDAI, no interest and no oracle. The code shown above is Euler's own EToken.sol, byte for byte; the test runs against our rebuild of it.

The output

This is the real output of forge test --match-test test_replay_theHack -vv against the rebuild:
1[PASS] test_replay_theHack() (gas: 3757892)
2Logs:
3 Euler DAI pool before: 8,904,507 DAI
4 [1] Flash loan 30,000,000 DAI
5 [2] Violator deposits 20,000,000 DAI
6 -> 19,559,902 eDAI
7 [3] Violator mints (borrows from itself) 200,000,000 dDAI of debt
8 collateral 215,158,924 eDAI | debt 200,000,000 dDAI | health 1.04
9 [4] Violator repays 10,000,000 DAI
10 [5] Violator mints again 200,000,000 dDAI of debt
11 collateral 410,757,946 eDAI | debt 390,000,000 dDAI | health 1.02
12 [6] Violator donates to reserves 100,000,000 eDAI
13 collateral 310,757,946 eDAI | debt 390,000,000 dDAI | health 0.77
14 [7] Liquidator liquidates the violator, 20% discount
15 gets 310,757,946 eDAI, takes on 259,283,999 dDAI
16 [8] Liquidator withdraws the whole pool 38,904,507 DAI
17 [9] Flash loan repaid 30,027,000 DAI
18 attacker keeps 8,877,507 DAI
19 Euler DAI pool after: 0 DAI
Steps one to five keep health above 1 (1.04, then 1.02 in the rebuild), so every check passes. Step six drops it to 0.77 with no check. Step seven pays the 20% discount. The pool goes from 38,904,507 DAI to zero.

How close the rebuild is to the real transaction

FigureReal transaction (CertiK)Our rebuildDifference
eDAI from the first mint195.6M195,599,022same to the digit shown
eDAI the liquidator receives310,930,612310,757,946−0.06%
dDAI the liquidator takes on259,319,058259,283,999−0.01%
DAI withdrawn from the pool38,904,507 (8,904,507 + 20M + 10M)38,904,507exact
The attacker's profit from this one pool in the rebuild is 8,877,507 DAI: the 38,904,507 DAI withdrawn, minus the 30M flash loan and Aave V2's 0.09% fee (27,000 DAI). That figure is our rebuild's; CertiK and BlockSec do not state a DAI-pool profit, so treat it as "about 8.9M".

The fix: one line, the same line withdraw already had

The fix the post-mortems point to is the line withdraw and mint already had. CertiK: "checkLiquidity() is missing". BlockSec names "the lack of insolvency check" in donateToReserves. At the end of the donation, check the donor:
1 assetStorage.users[account].balance = encodeAmount(newBalance);
2 assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);
3
4 emit Withdraw(assetCache.underlying, account, amount);
5 emitViaProxy_Transfer(proxyAddr, account, address(0), amount);
6
7 checkLiquidity(account); // the line withdraw already had
8
9 logAssetStatus(assetCache);
10}
This is the fix as the post-mortems describe it, not a patch Euler shipped: Euler v1 was not re-deployed with it.
On the fixed rebuild, the same test with the same attacker gives this (forge test --match-test test_fullAttack_withTheFix_reverts -vv):
1[PASS] test_fullAttack_withTheFix_reverts() (gas: 3672549)
2Logs:
3 [1] Flash loan 30,000,000 DAI
4 [2] Violator deposits 20,000,000 DAI
5 -> 19,559,902 eDAI
6 [3] Violator mints (borrows from itself) 200,000,000 dDAI of debt
7 collateral 215,158,924 eDAI | debt 200,000,000 dDAI | health 1.04
8 [4] Violator repays 10,000,000 DAI
9 [5] Violator mints again 200,000,000 dDAI of debt
10 collateral 410,757,946 eDAI | debt 390,000,000 dDAI | health 1.02
11 [6] Violator donates to reserves -> reverted: e/collateral-violation, and the whole attack with it
12 Euler DAI pool after the attempt: 8,904,507 DAI
Steps one to five pass. Step six reverts with e/collateral-violation, the whole transaction reverts with it, and the pool keeps all of its 8,904,507 DAI.

How to check your own lending protocol

The Euler bug is easy to see once you know where it is. The hard part is that it was one function among many that did the right thing. Here is the check we would run on any lending protocol:
  1. List every function that can lower someone's collateral or raise their debt. withdraw and borrow, but also the quiet ones: a donation, a transfer of collateral tokens, a rewards claim, anything added later as a small fix.
  2. Make sure every one of them ends with the health check. Not most of them. Every one, including the ones that look like they only help the protocol.
  3. Ask whether a user can make their own position worse on purpose, then liquidate it themselves. If an account can choose its own health, it can choose its own discount.
  4. Look at how your liquidation discount behaves. If it grows as health falls, question three matters even more, because the sickest account pays the best.
  5. Give every change after launch the same review as the launch. donateToReserves was a small addition in a fix.
An invariant test is one way to catch this class of bug without knowing where it is: after any sequence of user calls, no account that started healthy should end below a health of 1 unless prices moved. Our guide to Foundry invariant testing shows how to write one, and five ways a lending vault ends up with bad debt covers the other side of the same risk: collateral valued at a price that is not real.
The checks from this article are in our free lending security checklist.

Sources

All checked 8 Nov 2026.

FAQ

1. How was Euler Finance hacked?
The attacker used only Euler v1's own public functions, plus a 30M DAI flash loan from Aave. They built a large leveraged position with mint, then called donateToReserves to give 100M eDAI of their own collateral to the protocol. That function lowered their collateral without checking their account's health, so the account went underwater: about 310M eDAI against 390M dDAI. A second contract then liquidated it at Euler's maximum 20% discount, withdrew the 38.9M DAI left in the pool, and repaid the flash loan. The whole incident cost Euler about $197M on 13 March 2023.
2. What was the bug in donateToReserves?
donateToReserves lowered the caller's eToken balance, their collateral, but did not end with checkLiquidity(account), the health check that withdraw and mint both run. Every other Euler function that could weaken an account reverted if it left the account below a health of 1. This one did not, so a user could push their own account underwater on purpose. The fix the post-mortems point to is that one missing line.
3. Why did a self-liquidation make the attacker money?
Euler's liquidation discount grows as an account's health falls: 2% plus (1 − health), capped at 20%. That rewards liquidators for closing the riskiest positions quickly. But the attacker controlled both the unhealthy account and the liquidator. By donating collateral, they chose their own health, low enough to reach the 20% cap, and then bought their own position at that discount. The liquidator received about 310.93M eDAI while taking on only about 259.32M of debt, enough to withdraw the whole DAI pool and repay the flash loan with DAI left over.
4. How much did Euler lose, and how much came from the DAI pool?
About $197M in total, according to CertiK and BlockSec. The DAI attack transaction withdrew 38.9M DAI, all that was left in Euler's DAI pool. In our rebuild of that market, the attacker keeps 8,877,507 DAI after repaying the 30M flash loan and Aave's 0.09% fee; that profit figure is from our rebuild, not from the post-mortems.
5. How do I check my lending protocol for the same bug?
List every function that can lower a user's collateral or raise their debt, including small ones added later as fixes, and confirm each one ends with the health check. Then ask whether a user can make their own position worse on purpose and liquidate it themselves, especially if your liquidation discount grows as health falls. An invariant test that runs random sequences of user calls and asserts no healthy account ends below a health of 1 will catch a missing check like Euler's.

Glossary

TermDefinition
Health FactorA lending position's risk-adjusted collateral divided by its debt; below 1, the position can be liquidated.
Liquidation DiscountThe share of a position's collateral value a liquidator keeps as a reward for repaying its debt.
Self-LiquidationLiquidating a position you control from another account you also control, to collect the liquidation reward yourself.

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
ERC-4626 inflation attack: how it works and how to stop it
Attack vectorsJul 2

ERC-4626 inflation attack: how it works and how to stop it

20 min read
Your Vault Prices Collateral at $1: 5 Ways to Bad Debt
Attack vectorsNov 6

Your Vault Prices Collateral at $1: 5 Ways to Bad Debt

17 min read
Flash loan attacks: anatomy of nine-figure DeFi exploits
Attack vectorsApr 29

Flash loan attacks: anatomy of nine-figure DeFi exploits

37 min read