Reproduced Exploit
Yearn / iEarn yToken Exploit — APR-Oracle Routing + bZx Donation Inflates Price-Per-Share for ~$11.5M
The legacy iEarn/yearn yTokens (yUSDT, yDAI, yUSDC, yTUSD) auto-route deposits to whichever lending venue currently offers the highest APR, chosen by IEarnAPRWithPool.recommend(). The vault values each depositor's shares with getPricePerFullShare(), derived from the vault's balances across its venu…
Loss
~$11.5M — the PoC ends with 1,964,642.66 USDC + 1,780,391.61 DAI + 1,369,200.11 yTUSD (≈$5.1M net after repay…
Chain
Ethereum
Category
Oracle Manipulation
Date
Apr 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-04-YearnFinance_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/YearnFinance_exp.sol.
Vulnerability classes: vuln/oracle/price-manipulation · vuln/logic/incorrect-order-of-operations · vuln/governance/flash-loan-attack
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the main DeFiHackLabs repo contains several unrelated PoCs that do not compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: yUSDT (the iEarn/yearn vault) and IEarnAPRWithPool (the venue-selection
recommend()oracle).
Key info#
| Loss | ~$11.5M — the PoC ends with 1,964,642.66 USDC + 1,780,391.61 DAI + 1,369,200.11 yTUSD (≈$5.1M net after repaying the flash loans). |
| Vulnerable contract | iEarn/yearn yUSDT vault — 0x83f798e925BcD4017Eb265844FDDAbb448f1707D, via IEarnAPRWithPool.recommend() — 0xdD6d648C991f7d47454354f4Ef326b04025a48A8. |
| Victim pool | the yUSDT vault's funds deposited into bZx (bZxiUSDC), whose price-per-share the attacker inflated and then drained. |
| Attacker EOA / contract | PoC's ContractTest stands in for the attacker. |
| Attack txs | 0x055cec4f…31e0328 · 0xd55e43c1…759ecda95d |
| Chain / block / date | Ethereum mainnet / fork block 17,036,774 / April 13, 2023 |
| Compiler | iEarn yToken: v0.5.x (the legacy yearn v1/iEarn style); IEarnAPRWithPool v0.5.x. |
| Bug class | Price/oracle manipulation — (1) manipulable APR oracle controls vault deposit routing, (2) getPricePerFullShare is inflation-vulnerable to a direct donation of the vault's borrowed venue token (bZx). |
TL;DR#
The legacy iEarn/yearn yTokens (yUSDT, yDAI, yUSDC, yTUSD) auto-route deposits to whichever lending venue currently offers the highest APR, chosen by IEarnAPRWithPool.recommend(). The vault values each depositor's shares with getPricePerFullShare(), derived from the vault's balances across its venues (balance() + balanceAave() + …).
Two flaws compose:
- The APR oracle is manipulable.
recommend()reads Aave V1 borrow rates to score the Aave venue. By repaying the tiny USDT debts of hundreds of Aave V1 borrowers (the PoC lists ~400 addresses), the attacker drives the Aave utilization to ~0, which sends the Aave APR to 0 and pushesrecommend()to select bZx as the best venue instead (logged: APR3272452489817760→0). - The share price is donation-inflatable once funds are in bZx. With yUSDT routing into bZx, the attacker
yUSDT.deposit(900k USDT)(now booked as bZx exposure), then separately mints bZxiUSDC and transfers it directly to the yUSDT vault. Because the vault counts its bZx token balance towardgetPricePerFullShare, the donation inflates the per-share price from ~1.0 to 1.26176 (output.txt:8). The attacker thenwithdraws using this inflated price and receives more USDT than it deposited — the excess pulled from every other yUSDT depositor's share.
The attacker converts the winnings through Curve (USDT↔DAI/USDC/yTUSD) and repays the Balancer flash loan, netting ~$5.1M.
Background — the iEarn/yToken model#
iEarn (predecessor of yearn v1) yTokens are single-asset wrappers that deposit the underlying into the best-yielding lending venue and auto-compound. For USDT the venues are Compound (cUSDT), Aave V1 (aUSDT), and bZx (bZxiUSDC, via a USDT↔USDC swap). The PoC-relevant fork-block facts from the trace:
| Parameter | Value |
|---|---|
recommend(USDT) APR before the Aave-repay step | 3,272,452,489,817,760 (Aave best) (output.txt:6) |
recommend(USDT) APR after repaying Aave V1 USDT debts | 0 (Aave zeroed → bZx selected) (output.txt:7) |
| yUSDT deposit by attacker | 900,000 USDT (YUSDT_DEPOSIT_USDT_AMOUNT) |
| bZxiUSDC minted & donated to yUSDT | sized from yUSDT.balanceAave() * tokenPrice() * 114/100 |
yUSDT getPricePerFullShare after donation | 1.261760812004316802 (output.txt:8) |
| Balancer flash-loan | 5M DAI + 5M USDC + 2M USDT |
| Number of Aave V1 USDT debtors repaid | ~400 (the aaveV1UsdtDebtUsers array) |
The vulnerable code#
1. recommend() selects the venue from APR (the manipulable oracle)#
// IEarnAPRWithPool.recommend(_token) returns (choice, capr, iapr, aapr, dapr)
// computes APRs for Compound(c), iToken(i), Aave(a), dYdX(d) venues
// the Aave APR is derived from Aave V1 utilization/borrow rates.
// By repaying Aave V1 USDT borrows, the attacker drives Aave utilization -> 0,
// so aapr -> 0 and the max APR venue shifts to bZx.
(sources/IEarnAPRWithPool_dD6d64/)
The trace shows the APR drop directly: 3272452489817760 → 0 (output.txt:6-7).
2. getPricePerFullShare counts donated venue tokens#
// yToken.getPricePerFullShare() ≈ (balance() + balanceAave() + ...) * 1e18 / totalSupply
// balanceAave()/balance() read the vault's TOKEN balances in each venue.
// ⚠️ a direct ERC20 transfer of bZxiUSDC to the yUSDT vault increments
// its bZx balance WITHOUT minting shares → inflates pricePerFullShare.
function getPricePerFullShare() external view returns (uint256);
3. The attack sequence uses these two together#
// from the PoC:
LendingPool.repay(...); // (1) zero out Aave V1 USDT debts -> bZx becomes best APR
yUSDT.deposit(YUSDT_DEPOSIT_USDT_AMOUNT); // (2) vault now routes into bZx
uint256 amount = yUSDT.balanceAave() * bZxiUSDC.tokenPrice() / 1e18 * 114 / 100;
uint256 mintAmount = bZxiUSDC.mint(address(this), amount);
bZxiUSDC.transfer(address(yUSDT), mintAmount); // (3) ⚠️ DONATE bZxiUSDC -> inflate pricePerShare to 1.2617
uint256 withdrawAmount = ((yUSDT.balanceAave() + yUSDT.balance()) * 1e18) / (sharePrice) + 1;
yUSDT.withdraw(withdrawAmount); // (4) withdraw using inflated share price -> more USDT out
(test/YearnFinance_exp.sol:475-491)
Root cause — why it's exploitable#
- External/controllable input drives a security-relevant decision. The deposit destination (which venue holds the vault's funds) is chosen by
recommend()from live market APRs, which any user can move by repaying Aave V1 borrows. The vault never validates that the chosen venue is safe against the share-price computation. - Share price is a function of live token balances, not accounting.
getPricePerFullSharetrusts that the vault's bZx/Aave token balances only change via legitimate deposit/withdraw. A plaintransferof the venue token to the vault breaks that assumption, letting anyone inflate the per-share value. - No donation guard / per-share isolation. Unlike modern yearn (which uses per-share
pricePerSharecapped to realized gains and ignores direct transfers), iEarn credits donated tokens to all shares — so the attacker subsidizes existing depositors briefly, then withdraws its own shares at the inflated price, extracting the subsidy from everyone else. - Composability amplifies it. Flash-loaned capital (Balancer) funds both the Aave-repay manipulation and the bZx mint, so the attack needs zero upfront capital.
Preconditions#
- A Balancer flash-loan (multi-token) for working capital (5M DAI + 5M USDC + 2M USDT here).
- A list of Aave V1 accounts with small USDT debts (publicly enumerable on-chain; the PoC hardcodes ~400).
- Sufficient bZx liquidity to mint
bZxiUSDCfor the donation. - The iEarn
yUSDTvault accepting bZx as a venue (the legacy config at this block).
Attack walkthrough (with on-chain numbers from the trace)#
All figures from output.txt.
| # | Step | recommend(USDT) APR | yUSDT price/share | Effect |
|---|---|---|---|---|
| 0 | Balancer flash-loan 5M DAI + 5M USDC + 2M USDT | — | ~1.0 | Working capital. |
| 1 | Curve swaps to amass USDT; read recommend | 3,272,452,489,817,760 (Aave best) | ~1.0 | Baseline: vault deposits into Aave. |
| 2 | repay() — repay ~400 Aave V1 USDT debts (amount*101/100) | 0 | ~1.0 | Aave utilization→0 ⇒ APR→0 ⇒ recommend now picks bZx. |
| 3 | yUSDT.deposit(900,000 USDT) | 0 | ~1.0 | Vault deposits into bZx; attacker holds yUSDT shares. |
| 4 | Mint bZxiUSDC (≈ balanceAave()*tokenPrice()*114%) and bZxiUSDC.transfer(yUSDT, …) | — | 1.261760812004316802 | ⚠️ Donation inflates pricePerShare ~26%. |
| 5 | yUSDT.withdraw(withdrawAmount) computed from the inflated price | — | — | Attacker receives more USDT than deposited (≈ +26% on 900k, plus the donated bZx value). |
| 6 | yUSDT.rebalance() + tiny deposit to refresh; convert via Curve (USDT→DAI/USDC/yTUSD) | — | — | Realize gains in 3 stables. |
| 7 | Repay Balancer flash-loan (5M DAI + 5M USDC + 2M USDT) | — | — | Net remainder = profit. |
Profit/loss accounting (post-flash-loan-repay, from output.txt:9-11)#
| Asset | Attacker ending balance |
|---|---|
| USDC | 1,964,642.66 |
| DAI | 1,780,391.61 |
| yTUSD | 1,369,200.11 |
| Total (≈) | ~$5.11M net (the on-chain incident ~$11.5M gross across both txs) |
The profit is funded by the existing yUSDT depositors, whose shares are now backed by fewer real assets (the vault paid out USDT at an inflated per-share rate).
Diagrams#
Sequence of the attack#
Why the donation inflates the attacker's withdrawal#
Why each magic number#
- ~400 Aave V1 USDT debtors (
aaveV1UsdtDebtUsers): these accounts hold tiny USDT borrows; repaying them all (at101%to clear accrued interest) drives Aave V1 USDT utilization — and thus the Aave APR inrecommend()— to ~0, forcing bZx to be selected. The list is enumerable on-chain. 900,000 USDTdeposit (YUSDT_DEPOSIT_USDT_AMOUNT): sized to create a large enough bZx position in the vault that the subsequent donation meaningfully movesgetPricePerFullShare, while staying within flash-loan headroom.bZxiUSDCmint amount =balanceAave() * tokenPrice() / 1e18 * 114/100: ~114% of the vault's bZx exposure, enough to lift the per-share price ~26% (to 1.2617). The 14% overshoot is tuning headroom.withdrawAmount = (balanceAave()+balance())*1e18/sharePrice + 1: the exact share count to redeem the attacker's entire position at the inflated price; the+1avoids truncation leaving dust shares.yUSDT.rebalance()+deposit(10_000_000_000)+transfer(yUSDT,1): refreshes the vault's venue accounting so the leftover bZx value is correctly swept, then re-cycles a dust deposit to extract residual value before the Curve conversions.
Remediation#
- Don't let share price depend on raw token balances. Track deposits in accounting (shares minted = amount / pricePerShare at deposit), and compute
pricePerSharefrom realized gains only, ignoring direct transfers. Modern yearn v1/v2 already does this. - Sanity-bound the venue selection.
recommend()should not flip routing based on transient, manipulable utilization; weight APRs with a TWAP or require governance to change venues. - Treat direct transfers as donations to existing depositors, not to the attacker. A withdrawal immediately after a donation should not let the donor extract more than it put in — cap per-user withdrawals to their deposited amount + accrued yield.
- Isolate per-venue exposure. Cap how much the vault can route into a single (especially low-liquidity) venue like bZx, limiting blast radius.
- Don't auto-route to venues whose share tokens are themselves donation-inflatable without an integration audit of
tokenPrice()/balanceOf(vault)semantics.
How to reproduce#
The PoC lives in a standalone Foundry project:
_shared/run_poc.sh 2023-04-YearnFinance_exp --mt testExploit -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required for the fork at block 17,036,774 (April 13, 2023).
foundry.tomluseshttps://ethereum-rpc.publicnode.com...; pruned RPCs fail withmissing trie node. - Result:
[PASS] testExploit().
Expected tail (copied from output.txt):
[PASS] testExploit() (gas: 33276456)
Logs:
[INFO] Before helping aaveV1 users repay their USDT debts, APR value: 3272452489817760
[INFO] After helping aaveV1 users repay their USDT debts, APR value: 0
[INFO] Transfer bZxUSDC, increase the price per share to: 1.261760812004316802
[End] Attacker USDC balance after exploit: 1964642.660229
[End] Attacker DAI balance after exploit: 1780391.608901518090374458
[End] Attacker YTUSD balance after exploit: 1369200.114625136402796880
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: SlowMist Hacked — https://hacked.slowmist.io/ (Yearn / iEarn yToken, Ethereum, ~$11.5M, April 13 2023). cmichel: https://twitter.com/cmichelio/status/1646422861219807233.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-04-YearnFinance_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
YearnFinance_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Yearn / iEarn yToken Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.