Euler Finance Exploit: A $197 Million DeFi Lesson

On March 13, 2023, Euler Finance lost $197 million in roughly 15 minutes. The attack was technically simple—one missing solvency check in a donation function—but it took a brilliant exploit chain to turn that omission into the largest DeFi hack of the year.

The attacker later returned everything. That doesn’t make the vulnerability any less instructive.

The Missing Check

Euler was a non-custodial lending protocol. Users deposited assets as collateral (receiving eTokens) and borrowed against them (accumulating dTokens as debt). Like any lending protocol, Euler maintained a central invariant: a user’s collateral value must always exceed their debt by a required factor. Breach this, and the protocol’s liquidation engine kicks in.

The donateToReserves function let users voluntarily send their eToken balance to the protocol’s reserve. It reduced the caller’s collateral. But it never checked whether that reduction left the user insolvent:

function donateToReserves(uint subAccountId, uint amount) external {
    AssetStorage storage assetStorage = ...;
    address account = getSubAccount(msg.sender, subAccountId);

    uint256 balance = assetStorage.users[account].balance;
    require(balance >= amount, "Insufficient balance");

    assetStorage.users[account].balance = balance - amount;
    assetStorage.reserveBalance += amount;

    // No health check here. The position could now be insolvent.
}

Compare this to withdraw, borrow, and transferFrom, each of which called checkLiquidity before completing. The donation path was the odd one out—added without the check that every other balance-modifying path carried.

The Attack Chain

The attacker ran this sequence across multiple asset pools. Here is the DAI variant:

1. Flash loan. Borrow 30 million DAI from Aave.

2. Deposit and leverage up. Deposit 20 million DAI into Euler, receive ~20 million eDAI. Then call mint to take a leveraged position: 200 million eDAI in collateral, 200 million dDAI in debt. The protocol’s mint function permits this because the position is perfectly balanced at creation.

3. Partial repayment. Repay 10 million dDAI using the remaining flash loan funds. Position: 200M eDAI, 190M dDAI.

4. Donate to reserves. Donate 100 million eDAI. Position is now: 100M eDAI, 190M dDAI. The account is deeply insolvent—debt exceeds collateral by 90 million DAI-equivalent. The protocol does not revert.

5. Self-liquidation. Because the position is underwater, the attacker (acting as their own liquidator from a second contract) calls liquidate. The liquidation engine pays out a bonus to the liquidator for closing bad debt. The attacker extracts the liquidation discount from the protocol’s own reserves.

6. Withdraw and repay. Collect the profit, repay Aave, keep the spread.

Running this across DAI, USDC, WBTC, and stETH pools, the attacker cleared roughly $197 million total before repaying the flash loans.

Why Self-Liquidation Works

Most developers think of liquidation as a protection mechanism—it is, for the protocol. But when an attacker deliberately engineers an insolvent position, the liquidation bonus becomes the profit mechanism. The protocol’s reserves and other users’ liquidity absorb the loss.

The attack required no market price manipulation, no oracle games. The entire exploit relied on one missing require statement.

The Fix

The correction is straightforward. Every function that modifies a user’s collateral balance needs to verify the resulting position remains healthy:

function donateToReserves(uint subAccountId, uint amount) external {
    // ... existing balance update logic ...

    // Health check must follow any collateral reduction
    require(
        checkLiquidity(account) >= 0,
        "Donation would cause insolvency"
    );
}

A modifier pattern makes the intent clearer and harder to omit when adding future functions:

modifier ensureSolvency(address account) {
    _;
    require(isHealthy(account), "Operation causes insolvency");
}

The broader lesson is about consistency. If nine out of ten balance-modifying functions check health, the tenth one is a vulnerability waiting to be found. Security properties need to be applied uniformly, not function-by-function.

Aftermath

Negotiations between the Euler team and the attacker played out publicly through on-chain messages. The attacker—later reported to be a 20-year-old based in Argentina—returned all funds in stages over two weeks. A small amount (~$200K worth of DAI) was accidentally routed to an address linked to North Korean hackers and never recovered.

Euler paused the protocol immediately after the attack, worked with security firms and law enforcement, and eventually relaunched. The post-mortem was one of the more transparent published that year.

Invariant Testing as Prevention

This class of bug is exactly what Foundry’s invariant testing is designed to catch. An invariant test asserting that no account can be insolvent at the end of any transaction would have flagged donateToReserves during development:

function invariant_noInsolventUsers() public {
    for (uint i = 0; i < users.length; i++) {
        assertGe(
            euler.checkLiquidity(users[i]),
            0,
            "Insolvent user found"
        );
    }
}

Fuzzing would have found the donation path in minutes. The vulnerability existed for months before anyone exploited it.


References