October 20, 2026
|
Exploit Postmortem
Cover image for "ether.fi Liquid's $38K Loss and How Olympix Would Have Prevented It"

ether.fi Liquid's $38K Loss and How Olympix Would Have Prevented It

Date: September 11, 2026

Chain: Ethereum

Loss: 15.45 ETH, about $38K, taken from 11 wallets

Class: missing caller check on a settlement function that pulls tokens from a caller-chosen address

According to security researcher kankodu, one holder of ether.fi's liquidETH vault token had used Veda's AtomicQueue more than a year earlier and left an approval behind, letting the queue spend their tokens. About three months before September 2026, the same holder switched on EIP-7702 smart account features in Coinbase Wallet, which gave their address code. On September 11, 2026, at 07:20:11 UTC, those two unrelated decisions met a third piece of old code, and 12.37 liquidETH left that wallet in a transaction its owner never signed.

Ten other wallets lost tokens in the same transaction, 14.45 liquidETH and 7.05 USDC in all, which the attacker sold for 15.45 ETH, about $38K at the time. It was possible because AtomicQueue's solve() function let any caller name any address as the solver, and then paid out of that address's approval as if the solver had agreed to it.

A Queue Built for Patient Sellers

liquidETH is the share token of ether.fi's Liquid ETH vault, and AtomicQueue is a Veda contract that handled withdrawals of it. The queue, deployed on Ethereum at 0xD45884B592E316eB816199615A95C182F75dea07 and verified on Sourcify in April 2024, works like a limit order book for those shares. A holder calls updateAtomicRequest() to say how much of one token they'll offer, which token they want back, the price they'll accept and a deadline. The verified source describes a request as "effectively the same as placing a limit order."

Third parties called solvers fill those orders. A solver calls solve() with a list of users, and the queue runs in three steps. It pulls each user's offered tokens and sends them to the solver, then calls finishSolve() on the solver so it can do whatever it needs to source the payment, and finally pulls the wanted tokens from the solver and pays each user. For that last step to work, a solver approves the queue to spend its want token ahead of time. A user who offered liquidETH also had to approve the queue to spend liquidETH, and that approval doesn't expire unless the user revokes it.

ether.fi CEO Mike Silagadze described the contract in a reply to SlowMist's alert as an old Veda contract that a small number of users had approved. Old as it was, it still held standing approvals, and anyone who had approved it was a potential solver as far as the code was concerned.

The Solver Nobody Asked

From the verified AtomicQueue source on Blockscout, these are the lines in solve() that decide who pays, with the loop bodies in between trimmed:

    function solve(
        ERC20 offer,
        ERC20 want,
        address[] calldata users,
        bytes calldata runData,
        address solver
    ) external nonReentrant {
        ...
            offer.safeTransferFrom(users[i], solver, request.offerAmount);
        ...
        IAtomicSolver(solver).finishSolve(runData, msg.sender, offer, want, assetsToOffer, assetsForWant);
        ...
                want.safeTransferFrom(solver, users[i], assetsToUser);

The solver arrives as a plain parameter, and nothing in the function compares it to msg.sender, checks a signature, or looks it up in a list of approved solvers. The first transfer sends the users' offered tokens to that address, the callback asks it to finish, and the last transfer takes the want token from it. Whoever calls solve() decides whose approval pays for the trade.

The callback was the only thing standing between that design and a drain. According to security researcher kankodu, calling finishSolve() on an ordinary wallet with no code simply fails, which reverts the whole solve. The queue treated a successful callback as the solver agreeing to the trade, and for years that assumption held, because most addresses holding a stale approval were plain wallets.

EIP-7702 changed that. It lets an ordinary address delegate to smart account code, and the transaction trace shows the queue's finishSolve() call on each victim running delegated code from a Coinbase Smart Wallet implementation. That code accepted a call it didn't recognize and returned without error, so the queue read silence as consent. Kankodu described the result as three holes lining up: a callback pattern that assumes a successful call means intent, a stale approval, and a wallet that does nothing when called with an unknown function.

The lesson for builders sits in that last transfer. When a function moves tokens out of an address, the code has to prove that address asked for it, and a callback that returns without reverting proves nothing, because the code at the other end might not even know the function exists.

One Constructor, Eleven Wallets

The attacker's wallet, 0xa5CC6e490Bce9185fA47b421f2EaC677A83B64Ea, received 0.08513 ETH from Tornado Cash at 07:06:59 UTC. Thirteen minutes later it sent a single contract creation transaction, and the whole attack ran inside the constructors of the contracts it deployed. ExVul described the outer contract as self-destructing.

The deployed contract created a worthless token of its own and, acting as a queue user, placed a request offering that token and wanting liquidETH. According to ExVul, the attacker set the price so that each request paid out exactly the victim's approved balance. Then it called solve() with itself as the only user and a victim as the solver. The queue moved the fake token to the victim, called finishSolve() on the victim's smart account, got no error back, and pulled the victim's liquidETH through the victim's own approval to pay the attacker's request.

It repeated that for nine liquidETH holders. The largest single loss was 12.37 liquidETH, followed by 1.87 liquidETH, and the remaining seven wallets lost amounts between 0.11 and 0.0006 liquidETH. Two more wallets had approved the queue for USDC and lost 5.69 USDC and 1.36 USDC the same way. In total, 14.45 liquidETH and 7.05 USDC moved to the attack contract.

Still inside the same transaction, the contract sold the liquidETH through three Uniswap V4 swaps for 2.47, 6.49 and 6.48 ETH, and swapped the USDC on Uniswap V3 for 0.00285 WETH, which it unwrapped. The attacker's wallet received 15.45 ETH when the transaction ended.

Into Tornado Cash Within Minutes

The money didn't stay long. At 07:23:11 UTC, three minutes after the exploit, the attacker deposited 10 ETH into the Tornado Cash router, followed by five deposits of 1 ETH and five of 0.1 ETH, with the last one landing at 07:27:59 UTC. By then 15.5 ETH had gone into the mixer, and the wallet kept only a small remainder.

SlowMist said it reached out to the ether.fi team privately before publishing its alert, which named the attacker, the vulnerable AtomicQueue and the transaction. Silagadze replied that the issue was resolved and that impacted users would be reimbursed. Nucleus, which ran its own AtomicQueue deployment for deprecated vaults, posted the same day that it had pushed a fix protecting funds held as its vault tokens and asked users to revoke approvals to its queue. Researcher pashov pointed holders to Revoke.cash, since every wallet still approving an old queue was exposed until it revoked.

How Olympix Catches a Payer Chosen by the Caller

Olympix reads a contract the way an attacker would, as states, transitions and entry points, and takes in the context around the code to infer the rules the system depends on but never writes down. In AtomicQueue, the state that matters is whose tokens can move, and solve() is the entry point that moves them. The engine maps every path through it and uses signals to decide which paths are worth attacking. Olympix's static analysis surfaces the signal here: an external function that calls transferFrom on an address taken straight from its arguments, with nothing tying that address to msg.sender, a signature or an allowlist, and an external callback as the only check in between.

The invariant for this queue fits in one line: tokens should only leave a solver's address when that solver chose to fill the trade. BugPoCer takes that rule and tries to break it, running Counterexample-Guided Invariant Refutation from the queue outward, executing every feasible path into solve() until one moves tokens from an address that never called it. The path that breaks it needs only a fresh caller, a request for a token the caller controls, and a solver address that has approved the queue and whose code returns from finishSolve() without reverting.

The counterexample BugPoCer hands back is that path made concrete: the attacker's updateAtomicRequest() call, a solve() naming a stranger as solver, the callback that returns empty, and the want token arriving in the attacker's account out of the stranger's approval, along with the minimal conditions that trigger it and a generated Forge test that replays it. That's the September 11 transaction, produced in development and at the merge that introduced solve(), long before 2026.

Olympix has flagged this class before in other codebases, where a function trusted a caller-supplied address as if it had authorized the action. Because Olympix runs automatically in development, at every merge and before release, a team gets a standing view of findings like this across its whole codebase, including contracts that are older but still hold approvals.

Approvals Outlive the Code That Earned Them

AtomicQueue's solve() worked for years because the wallets it trusted happened to fail loudly, and the moment some of them stopped failing, the missing check became a way into their approvals. Any function that moves tokens out of an address it was handed needs proof that the address wanted it, and that proof can be checked long before deployment. To see how Olympix traces every path where your contracts spend someone else's approval and returns a runnable counterexample for each one that breaks, book a technical walkthrough.

On-Chain References

Protocol: ether.fi Liquid (Veda AtomicQueue)

Chain: Ethereum

Date: September 11, 2026, 07:20:11 UTC

Loss: 14.45 liquidETH and 7.05 USDC, sold for 15.45 ETH

Attacker EOA: 0xa5CC...64Ea

Attack contract: 0x7f5A...DEC1

Attack helper contract: 0x679c...663E

Attacker's fake offer token: 0xD9B5...3f64

Vulnerable AtomicQueue: 0xD458...ea07

liquidETH token: 0xf0bb...416C

Largest victim (12.37 liquidETH): 0x69da...47D4

Funding tx (Tornado Cash): 0xaac2...c7dd

Exploit tx: 0x7cbe...599b

Block: 25952624

First Tornado Cash deposit (10 ETH): 0xa9f2...ea9a

Sources

SlowMist: TI Alert on the ether.fi AtomicQueue exploit

ExVul: Alert and root cause of the liquidETH AtomicQueue drain

kankodu: How three holes lined up in the EtherFi helper contract hack

Mike Silagadze: Reply confirming the old Veda contract and reimbursement

Nucleus: Notice on the AtomicQueue vulnerability

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.