Back to Overview

Heartbreaks & Curve LP Oracles

October 11, 2022

It’s easy to get tricked by lies and deception when you’re blinded by beauty. Taking off rose-colored glasses can be heartbreaking but getting them smashed on your face will be disastrous. Oracle manipulations are quite similar. They deceive you into not seeing the true value of something. Once you realize, the world around you is crumbling.

In this post, we want to tell you the story of how we discovered a devastating oracle manipulation on Curve, targeting five major protocols, and how we ended up protecting tokens worth over a hundred million dollars.

Tripping over the Curve

Our story began when we discovered an issue in a client’s codebase. This issue was unrelated, but it reminded us of a special form of reentrancy, the read-only reentrancy. A read-only reentrancy reenters view functions which, in contrast to state-altering functions, lack reentrancy guards — enabling the read-only reentrancy. While the reentered contract cannot be affected by its view function, others reading the contract’s state can. Even though this class of issues was already previously known, it is often neglected. Finding one in our client’s code raised our sensitivity to them, which led to the fateful Curve discovery.

On a late Wednesday night, our engineer Kenan was checking out the Curve contracts, just for fun. As auditors, we love thinking through systems with an adversarial mindset, constantly asking ourselves “What if?”. Figuring out how to make apparently impossible things possible. While reading up on how Curve handles the rebasing stETH token in the stETH/ETH pool, Kenan couldn’t help but feel challenged.

Wouldn’t it be fun to try to mess with Curve?

In smart contracts, the most interesting stuff is often hidden where transfers happen. Naturally, the exchange function that swaps tokens was the first place to look at in the stETH/ETH pool. The function is quite trivial when the math in get_y is successfully ignored (after all it was getting late). But it had one suspicious part. If stETH is exchanged, native ETH is sent out. Native ETH transfers can trigger the fallback function of a receiving smart contract. But the excitement was immediately killed when a quick lookup showed that the Curve devs were aware of this and placed reentrancy guards on all state-modifying functions.

It quickly became evident that Curve was protecting itself quite well. Time to head to bed. But then, the next idea struck.

What about LP tokens? Could I at least manipulate some price feed?

Sounds promising. What would a large trade do to oracles? If oracles depend on the current balances of the pool, an old-school UniswapV2-like oracle manipulation could be possible. But again, the Curve devs had already thought of this. They provide an oracle function called get_virtual_price that provides the price of an LP token in the pegged asset of the stable swap. Using that and the lowest exchange rate of the underlying tokens, a lower bound on the LP’s value can be computed. Buying or selling tokens would have no impact. If not with trades, how else would you manipulate the price? All seemed well. Finally, time to sleep.

Damn. What if I reenter that function?

Could it be that get_virtual_price reports bad values when the contract is reentered? Read-only reentrancy strikes again.

Such a nice view!!!

From earlier it was clear that native ETH transfers are supported. Pulling up the remove_liqudity function immediately showed the potential of such a manipulation.

# snippet from remove_liquidity
CurveToken(lp_token).burnFrom(msg.sender, _amount)
for i in range(N_COINS):
    value: uint256 = amounts[i] * _amount / total_supply
    if i == 0:
        raw_call(msg.sender, b"", value=value)
    else:
    assert ERC20(self.coins[1]).transfer(msg.sender, value)

First, LP tokens are burned. Next, each token is transferred out to the msg.sender. Given that ETH will be the first coin transferred out, token balances and total LP token supply will be inconsistent during the execution of the fallback function.

Leveraging this inconsistency in the state could work if get_virtual_price somehow depended on its balances and its LP token’s total supply, and if it did not use some virtual balances or other magic. Immediately, the function was checked.

def _balances(_value: uint256 = 0) -> uint256[N_COINS]:
    return [
        self.balance - self.admin_balances[0] - _value,
        ERC20(self.coins[1]).balanceOf(self) - self.admin_balances[1]
    ]def get_virtual_price() -> uint256:
    """
    @notice The current virtual price of the pool LP token
    @dev Useful for calculating profits
    @return LP token virtual price normalized to 1e18
    """
    D: uint256 = self.get_D(self._balances(), self._A())
    # D is in the units similar to DAI (e.g. converted to precision 1e18)
    # When balanced, D = n * x_u - total virtual value of the portfolio
    token_supply: uint256 = ERC20(self.lp_token).totalSupply()
    return D * PRECISION / token_supply

It took just seconds to confirm the suspicion. The current balances were read when computing D, the total supply was queried from the LP token. Now the question of course was what this D value was and whether it could destroy the idea. According to the docs and the stable swap whitepaper it represented “the total amount of coins when they have an equal price”. After learning that and quickly skimming through D’s computation, it was clear that the ratio of D and the total supply could be manipulated. Confident enough about the possibility, Kenan quickly wrote a proof of concept validating the idea:

  1. Add a massive amount of liquidity.
  2. Remove it. The raw ETH transfer triggers the fallback function.
  3. During the execution of the fallback function, the virtual price is completely off as much more capital available compared to the LP token supply.

Holy sh*t!

Blinded by the beauty of the pump

Confirmed. But leveraging this to attack Curve was impossible due to the nature of read-only reentrencies. So far it was only a manipulation of a view function. Despite not even knowing the consequences yet, his heart started racing. It was a mixture of excitement for investigating the potential of this discovery, joy of succeeding in the challenge, and anxiety about the effects this could have on external systems. In the middle of the night, now technically Thursday, Kenan reached out to Hubert, a senior engineer at ChainSecurity, to get a second pair of eyes validating the PoC, and to brainstorm and discuss potential dangers. Investigations started.

We further evaluated the manipulation. Considering its current effect on the high-liquidity stETH/ETH pool was not significant, the first goal was to improve the attack. Discovering remove_liquidity_imbalance led to a simple optimization that allowed pumping the LP price even further. The function lets users burn LP tokens but in an imbalanced way. Meaning, that it was for example possible to redeem LP tokens solely against stETH. Of course, withdrawing solely stETH was not an option since it wouldn’t trigger the fallback function. But getting only 1 Wei worth of ETH out of the pool was sufficient. That simple trick let us increase the effectiveness of the manipulation significantly so that the stETH LP tokens price was realistically manipulatable by a factor of two using flash loans. For other pools with lower liquidity, however, the LPs’ values could be nearly arbitrarily manipulated.

Even though we had an effective manipulation, no profitable scenario was constructed yet. A vulnerable protocol needs to try to price affected Curve LP tokens. The first thing that came to our mind was lending protocols. By pumping the price, undercollateralized loans could be taken out if affected Curve LP tokens can be used as collateral. Intrigued by the question of whether one could liquidate positions, we needed to figure out whether a devaluating manipulation is possible. Quickly we evaluated non-ETH pools and ended up collecting different pool types and how they could get manipulated:

  • ETH pools: Only upwards manipulation is possible.
  • Pools holding ERC-677 that perform token callbacks after transfers: Equivalent to ETH pools
  • Pools holding ERC-777: Equivalent to ETH pools but it was possible to devaluate LP tokens when adding liquidity during the ERC-777 sender’s callback function.

By now, we had an overview of what was possible and how it could be leveraged. The only thing we were missing was finding vulnerable protocols. As it was early Thursday morning, it was time to sleep at least a little to be ready for the next workday.

After getting the most important tasks done, much calmer but still very concerned, we proceeded to investigate which projects could be affected. Asking Google won’t help much. So we decided to scan for all calls to get_virtual_price to get pairs of Curve pools and their callers. Retrieving that data was rather simple but our next step involved filtering out that list. After all, not all pools were affected nor were all contracts querying the virtual price vulnerable. So we ended up checking the underlying tokens for each and every one of our filtered pools manually to reduce the set of potentially vulnerable contracts. Once we got through all the tedious work of checking tokens, we reduced the set of potential candidates quite a bit. Finally, now was the time to find vulnerable contracts. After filtering non-attackable ones, we were left with a list of addresses that were attackable.

Uh-oh.

  • MakerDAO: ~5M at risk
  • Enzyme: < 1M at risk
  • Abracadabra: ~100M at risk
  • TribeDAO (Rari): ~20M at risk
  • Opyn: ~6M at risk

The vulnerable projects were the big ones with huge TVLs, leading to more than a hundred million at risk (on the day of discovery). Once again, our hearts were racing. Before they got broken, we needed to act immediately to gently take off the protocols’ rose-colored glasses, to let them see the true underlying values of vulnerable Curve LPs. So on that Thursday evening, April the 14th, our CTO Matthias got involved and planning the disclosure started. Most important was to coordinate the disclosure so no information leaked from upgrades.

  1. Write a generic report describing the vulnerability.
  2. Contact our friends at Curve to help identify further protocols. Send them the report.
  3. Send out a generic report to all affected protocols. For some we decided to use private channels. For some we used Immunefi as a trusted platform to report.
  4. Contact our friends at Paradigm to ask for their “U up?” as support to get in touch with the relevant people.

Or to describe it in the words of Mitchell Kuzzel, CEO of Immunefi:

“It’s hard to appreciate just how difficult ChainSecurity’s task was; having proven the vulnerability of the Oracle, it was clear that serious funds were at risk. ChainSecurity decided to go the distance and make disclosures across all affected protocols simultaneously, to maximize the chance of protecting them all (the best practice in such cases).
For our part, that meant several critical bug reports and a flurry of activity to start pushing mitigations immediately, a process which we at Immunefi supported as much as we could.
Each of these protocols owes them a major debt of gratitude for saving them all from a serious vulnerability, and under the most demanding of circumstances.”

After the reports were out, we were waiting for responses. But not for long. All protocols were quick to reply. We established separate private channels with the teams of the affected protocols to discuss and help. Already late in the night while the projects’ teams were solving the problem for their products, we were helping out where we could, providing information while further investigating along with the people from Curve, Immunefi and Paradigm.

Given the different setups of the projects a public disclosure was impossible given that Abracadabra was not upgradable but had to incentivize a migration. At some point however, the upgradable projects had to upgrade which happened when MakerDAO was ready to perform an emergency action for their oracle to pause it.

Fast forward to the present

Now, couple of months later, the situation has settled. Oracles were replaced to call a non-reentrant function when querying get_virtual_price. Abracadabra has significantly reduced the MIM supply backed by the Curve stETH-ETH LP. Not only funds worth more than a hundred million dollars but also the peg of DAI and MIM were protected thanks to the great collaboration with all involved parties.

As auditors, we look out for these issues during audits and know how hard it is to protect a system due to its third-party interactions. This is why additional measures like bug bounties can give another important layer of security, and we are glad that all affected projects have meaningful bug bounty programs on Immunefi. We want to thank Curve, who is also an audit partner, for their very generous bounty reward. MakerDAO, Enzyme and Abracadabra all classified the issue as critical. With MakerDAO and Enzyme, who are our audit partners, we directly worked to support and audit the mitigation measures, and MakerDAO published an independent writeup of the issue here. Abracadabra rewarded us directly with their maximum bug bounty amount. Both TribeDAO and Opyn, where communication was handled by Immunefi, classified the bug as critical, but declared the bug out of scope. Nevertheless, both offered to may out a bug bounty according to their respective Medium severity levels in their Immunefi bounty programs.

We want to highlight that with the growing number of integrations, the correctness of view functions becomes more and more important. Assuming that what is seemingly impossible is possible and being up for a challenge will not only be the reason for a fun night but will help you understand relevant external code in detail which is crucial for security. Not everything is as it seems. See the true value and prevent developer and customer hearts from breaking.