How Curve hacked Curve

In March, a sUSD incentivized pool was launched with Synthetix. The trial was overwhelmingly successful as, while having smaller value in the pool, the pool was providing a much deeper liquidity than sETH/ETH Uniswap pool.

However, on April 20th (was it only a week ago?), we (Angel and I) have found that there is a possibility for the contract could potentially be slowly drained. In fact, we've drained 0.1 cent from it (and returned back).  So what was the issue?

In order to understand what happened, we need to explain how the old sUSD pool worked. That's actually a cool one. The pool was using ySUSD (sUSD lent through iearn) and Y tokens - tokens of Y pool. This way, composability of Curve was tested: one can trade stablecoins against tokens representing Curve pools very efficiently. Brilliant!

One of the issues with the pool was that it had a fairly high ETH gas usage (about 2 million gas per exchange). That was acceptable by whales though.

So while going through the code, we've spotted two places. First, adding a pool token instead of a stablecoin can use "virtual price" (value per pool token) as a lending rate:

Looks pretty innocent, isn't it? And since Y pool token cannot be unwrapped in "non-lent-out" Y pool token, both exchange() and exchange_underlying() were supposed to operate with the same Y pool token (while not unwrapping / unwrapping ySUSD).

And here we have the place with a problem:

At first, looks like nothing is going wrong here. Just changing from Compound to iearn's Y tokens format.

The vulnerability in this call wasn't introduced by the changes in this function: it was introduced by the lack of those. A reader could notice that everything in exchange_underlying() works while using rate_i and rate_j, however those shouldn't be used when one of the coins is Y token which cannot be "unwrapped" into one single stablecoin. Using exchange_underlying() could lead to getting more coins than expected by factor of virtual_price which was about 1.014 at the time. This haven't been noticed before because iearn's zap which was used for exchanges used safe exchange() method.

As soon as we've noticed the issue, we've contacted Synthetix team and quickly shut down the pool using the available kill_me() method. The kill_me() method is usable only during the first 2 months from the pool launch, and that time is supposed to be used for extensive safety tests. The pool was relaunched in a different format - a very more simple contract, fully audited by Trail of Bits (no changes after audit), and no lending.

The new pool appears to have very cheap exchange costs (typically, 100k-200k gas per exchange). That enabled some good arbitrage opportunities.

What are the takeaways for the future?

  • Never deploy a pool with unaudited changes: no matter how small and obvious they are;
  • Use fuzzing tools for new changes to catch possible issues even before any audit is conducted;
  • Make optimizations to make pool composability more gas-efficient;
  • Continue hacking Curve to make it even safer for everyone.