Ethereum
Price manipulation

Zenji's $7.1K Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
November 8, 2026

At 16:40:23 UTC on November 8, 2026, a single Ethereum transaction borrowed 7,237,500 USDT from Morpho, shoved all of it into the crvUSD/USDT Curve pool, and then asked two Zenji vaults to rebalance. Both vaults looked at the pool the attacker had just bent out of shape, concluded their positions were out of balance, and sold their Curve LP for a fraction of what it had been worth a block earlier. The vaults handed over 10,234.15 LP tokens and got 3,432.38 crvUSD back. One block before, the same pool would have paid 10,527.70 crvUSD for that LP. The attacker swapped back, repaid the loan, and walked off with 4,951.40 USDT.

The shortfall the vaults absorbed comes to 7,095.32 crvUSD, about $7,077 at the time. That's the loss this post-mortem counts, because it's what Zenji's depositors actually gave up. The attacker's own profit was smaller, and the rest of the value stayed in the pool with its other liquidity providers.

A Leveraged Vault With an Open Door

Zenji wraps a leveraged yield loop in an ERC-4626 vault. Zenji's README lays out the design: users deposit collateral such as wstETH or WBTC, the vault borrows a stablecoin against it at a target loan-to-value, and that stablecoin goes single-sided into the crvUSD/USDT Curve pool. The LP token is staked in a Stake DAO reward gauge to earn CRV emissions and trading fees. For the LlamaLend versions of the vault, the borrowed asset is crvUSD, so the vault holds a crvUSD debt on one side and a crvUSD-denominated LP position on the other.

Leverage needs upkeep, and Zenji made that upkeep public. According to the README, a permissionless rebalance() pulls leverage back toward target whenever the LTV or the ratio of strategy value to debt drifts more than 3% from where it should be. Keepers who call it are paid from accrued fees, with a default bounty of 20%. The README also describes Chainlink price checks for collateral, with staleness and round completeness checks, plus a custom wstETH oracle. The collateral side, in other words, had careful pricing. The strategy side, the Curve LP, didn't.

The Pool Graded Its Own Homework

To know whether its ratio had drifted, the vault had to put a number on its LP position. According to Defimon's alert, it took that number from the crvUSD/USDT pool itself, using get_virtual_price and calc_withdraw_one_coin, and it reused the same pool quote as the minimum output when it unwound. That's the whole bug. The same pool supplied the reading that triggered the rebalance, the price the rebalance traded at, and the floor meant to stop a bad trade.

calc_withdraw_one_coin answers a narrow question: if you burned this many LP tokens right now and took only crvUSD out, how much would you get. In a balanced pool that's a fair proxy for value. In a pool where someone has just bought up most of the crvUSD, the answer collapses, because crvUSD is suddenly scarce and a single-sided crvUSD exit has to pay for that scarcity. A vault that values its LP this way sees its strategy value drop the moment the pool tilts, even though nothing about its real position has changed.

Once the reading drops, the rest follows. Strategy value falls below debt by more than the 3% band, so rebalance() decides to deleverage. To deleverage, it withdraws LP single-sided into crvUSD, which is exactly the trade the tilted pool prices worst. And the slippage floor, computed from that same live quote, agrees that the terrible price is acceptable. A floor built from the price being manipulated moves with it, so it never fires.

Inside the Transaction

The attacker's wallet deployed two contracts in the same transaction, an attack contract and a helper. The helper received 7,237,500 USDT from Morpho as a flash loan and sold every unit into the crvUSD/USDT pool, taking out 7,138,299.91 crvUSD. Before that swap, the pool held about 1.83M USDT and 7.19M crvUSD, so the trade more than quadrupled its USDT side and stripped out nearly all of its crvUSD.

The attack contract then called rebalance() on ZenjiWstEthLlamaUsdtStakeDao. The vault first ran its usual harvest, swapping a few dollars of CRV rewards into crvUSD and adding it to the pool. Then it read its now-depressed strategy value, decided to deleverage, and pulled 763.29 LP out of the Stake DAO gauge. Burned single-sided, that LP returned 267.08 crvUSD. At the pre-attack block, the pool would have paid 785.19 crvUSD for it. The vault sent 283.05 crvUSD to the LlamaLend controller through its loan manager to repay debt and freed 0.2086 wstETH of collateral.

The same thing happened to ZenjiWbtcLlamaUsdtStakeDao, which Defimon named as a second exposed vault, and on-chain it took the larger hit. It withdrew 9,470.85 LP and received 3,165.30 crvUSD, against a pre-attack quote of 9,742.51 crvUSD. Its loan manager repaid 3,277.06 crvUSD of debt and released 0.1010 WBTC.

With both vaults unwound, the helper sold its 7,138,299.91 crvUSD back into the pool and received 7,242,451.40 USDT. It repaid Morpho's 7,237,500 USDT and sent the remaining 4,951.40 USDT to the attacker's wallet. Gas came to about 0.0139 ETH.

Counting What Went Missing

There are three numbers floating around this incident, and they measure different things. The attacker kept 4,951.40 USDT. Defimon put the drain from the Curve pool at about $8.36K, a figure that lines up with the pool's gross outflow in the transaction, much of which was the vaults' own LP withdrawal. The number that captures what Zenji's depositors lost is the gap between what their LP was worth before the swap and what it fetched during it.

That gap is 518.11 crvUSD for the wstETH vault and 6,577.21 crvUSD for the WBTC vault, 7,095.32 crvUSD in total, or about $7,077 with crvUSD trading just under a dollar. The attacker captured most of it. The remainder stayed in the pool with its other liquidity providers, who held a slightly larger share of a pool that had just bought the vaults' LP cheaply. The collateral and debt accounting stayed intact. Each vault simply came out of the transaction smaller and less levered than it had any reason to be.

As of November 13, 2026, Zenji hadn't published a post-mortem, and no recovery or bounty offer had been announced in public sources.

How Olympix Would Have Caught the Self-Referencing Price

Olympix reads a contract as states, transitions and entry points, and it uses the surrounding context, docs and design notes included, to infer the invariants a system relies on without writing them down. Zenji's README states the intent plainly: rebalance() exists to keep leverage healthy. The implied invariant is that a call to rebalance() should never leave the vault holding less value than it started with, and that no single caller should be able to decide when it fires and at what price.

Had the team run Olympix on these vaults, static analysis would have surfaced the signal right at the entry point. A function anyone can call reads its trigger condition, its execution price and its minimum output from one external pool, and that pool's state can be changed by the same caller in the same transaction. That signal narrows the search from every path through the vault to the handful that start with someone moving the pool.

BugPoCer works from the system outward, starting from the invariant and searching for a sequence of state changes that breaks it. Here it would have returned a short counterexample: a large swap into the crvUSD/USDT pool, a call to rebalance(), and a single-sided LP exit at a fraction of its pre-swap value, followed by the reverse swap. The output would have included the minimal conditions, roughly how much pool imbalance tips the strategy ratio out of its 3% band, and a generated Forge test that replays the drain. That would have been the November 8 transaction, produced in development, while the fix was still a code change.

The fix itself is well understood. Value the LP with a price the caller can't move inside one block, such as an oracle or a time-weighted reading, and anchor the slippage floor to that same independent price so a distorted pool makes rebalance() revert instead of trade. It's the same trap of checking slippage against the very price under attack that cost BeatSwap $77.5K in September.

Keep Upkeep Public, Keep Prices Private

Permissionless keepers are a sound design choice, and Zenji's instinct to let anyone call rebalance() made the vaults more resilient in normal markets. The trouble starts when an open function also trusts inputs that its caller controls. Any vault that values a position by asking the pool it would exit through is handing that valuation to whoever can move the pool for one block, and flash loans make that anyone with a contract.

Before shipping a public rebalance, harvest or settle path, list every number it reads and ask who can change each one in the same transaction. If the answer includes the caller, that number shouldn't decide whether to trade or what price is acceptable. If you're running leveraged vaults with open upkeep and want to see which of your price reads an attacker could steer, book a technical walkthrough.

On-Chain References

Exploit tx: 0x79fa...d701

Attacker wallet: 0x2216...24f9

Attack contract: 0xeea5...e60f

Attack helper contract: 0xc597...bad2

Victim vault (ZenjiWstEthLlamaUsdtStakeDao): 0x23f1...dd2c

Victim vault (ZenjiWbtcLlamaUsdtStakeDao): 0x18e2...3f74

crvUSD/USDT Curve pool: 0x390f...7bf4

Flash loan source (Morpho): 0xbbbb...ffcb

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.