art_img
December 15, 2023

Curve Stablecoin Audit Report

Statemind team
Statemind team

Statemind recently released an audit report on the Curve Stablecoin. The stablecoin allows a diverse range of collaterals and offers enhanced capital efficiency and smoother liquidation processes. Governed by the Curve DAO, it aims to maintain a decentralized architecture.

Notably, the beta version of the protocol already had more than $200 million at the time of the audit. This presented real-world vulnerabilities that we had to address. Despite being the fourth auditor to review the project, we identified 2 high, 4 medium, and 31 informational issues. In this article, we will briefly review some of the found vulnerabilities.

TL;DR

The audit was a quite challenging experience that demonstrated the difficulties involved in securing a live, high-stakes protocol. Despite being the fourth audit team to review the code, we uncovered numerous vulnerabilities. This is a clear demonstration of how relentless efforts and security focus pay off for a top-tier protocol like Curve.

Dynamic fee possible overflow

The vulnerability is quite interesting, it's located in the mechanism of the fee calculation but in the worst case, it allows an exploiter to swap just 1 wei of crvUSD for all collateral in the pool. It's the great example of how a tiny bug can have such huge side-effects.

The root cause

The project introduced dynamic fees to prevent sandwich attacks during oracle price updates. The fee will rise upon a strong price change. According to comments in code, the dynamic fee is "guaranteed to be less than 1e18". However, there were no actual checks to enforce this guarantee. Currently, no practical ways to exploit have been found (besides direct price and block manipulations). Several market conditions should coincide for exploitation, and it is very possible that these conditions could appear in the future, which will lead to a critical situation.

The math behind it

The formula used in the function is:

fee_new = (fee_old + (1 - r^3)) * dt / T

Where r = max(p_min / p_max, MAX_CHANGE), MAX_CHANGE = 0.8

The problem is that fee_new can cumulatively exceed 1. There are different scenarios to achieve this. For example, if the price changes by 51% in three consecutive blocks, or if there are 5% changes during 14 consecutive blocks.

The consequences

When this happens, all bands can be exchanged at a price of just 1 token, if admin_fee is set to zero. This is due to the calc_swap_out function's calculations breaking down.

antifee: uint256 = unsafe_div(
    (10**18)**2,
    unsafe_sub(10**18, max(self.fee, p_o[1]))
) # <-- will be zero due to the cast of a negative number

The exchange can swap all the y tokens in exchange for just one x token.

The mitigation

If admin_fee is set to a non-zero value, the function will revert due to an overflow in the x_dest calculation. Thus, DAO took a proposal to set admin_fee to 1 for current markets, preventing critical consequences. For future markets, the code was fixed to address this issue.

Possibility of setting an incorrect rate

Another interesting vulnerability came from the research of the borrowing rate calculation and how it can be affected by re-entrancy. As a result we came to two interesting points:

  • Firstly, the rate can be manipulated for "free" even without re-entrancy
  • Secondly, it's possible to utilize cross-contract re-entrancy to amplify the rate manipulation

While the issue looks scary it's still cannot be used to direct funds stealing from the protocol, but there are several edge cases how it can affect the system itself, e.g. the borrow rate calculation becomes useless since an attacker can set the rate to maximum in every block, it affects all borrowers since they will pay more than should in normal situation.

The root of the issue

In the Controller contract, there's a method called liquidate_extended that uses a callback mechanism. This method sets a rate based on the system's total debt, but here's the problem: that rate can be incorrect and outdated.

The affected function here is calculate_rate within monetary_policy, where the rate is calculated and subsequently stored in the AMM. The formula used in calculate_rate adjusts the rate based on the total debt recorded by CONTROLLER_FACTORY. Here's the catch: a lower total debt leads to a reduced rate, whereas a higher debt inflates the rate. The problem arises when the CONTROLLER_FACTORY.total_debt() is not up-to-date, leading to incorrect rate calculations.

power: int256 = (10**18 - p) * 10**18 / sigma  # high price -> negative pow -> low rate
if pk_debt > 0:
    total_debt: uint256 = CONTROLLER_FACTORY.total_debt() # get old total_debt
    if total_debt == 0:
        return 0
    else:
        power -= convert(pk_debt * 10**18 / total_debt * 10**18 / target_debt_fraction, int256)

return self.rate0 * min(self.exp(power), MAX_EXP) / 10**18

Utilizing the cross-contract re-entrancy

In the liquidate_extended method, the current rate is saved in the AMM before a callback is executed. This callback can trigger another liquidate_extended call on a different controller, which attempts to liquidate another user's loan. The rate is continuously updated based on the system's state before the first call. As users are liquidated sequentially, the total debt decreases, thus reducing the rate in the AMM. However, since the rate is set before updating the total debt, the monetary policy ends up working with an outdated debt figure, resulting in an inflated rate.

The mitigation

The Curve team has developed a new monetary policy that is resistant for rate manipulation.

Incorrect calculation of max borrowable

The other vulnerability is the good example how the view function can have side effect in the "external" contracts but have no impact for direct use of the contract itself. The vulnerability was hidden in the core external view function, causing it to return incorrect value. The max_borrowable method in the Controller contract is designed to return how much debt one can take based on the amount of collateral. However, the method contains vulnerability and can give a number that's too high to actually use.

The root cause

The _create_loan method uses _calculate_debt_n1 to figure out the band for a loan. It has checks in place to ensure the debt isn't too high:

assert AMM.can_skip_bands(n1 - 1), "Debt too high"
assert AMM.p_oracle_up(n1) < AMM.price_oracle(), "Debt too high"

The problem is that the debt amount returned by max_borrowable can be too large to pass these checks. Specifically, it can lead to a small n1 value that can't be skipped.

The method max_p_base adjusts for the n1 variable, but only if n1 is greater than the active band. If n1 is small enough, it's not checked. This can happen due to division by p_oracle.

Impact on Leverage Zap

This isn't just a problem for the Controller contract. The vulnerable code is used in the LeverageZap contracts, which blocks leverage calculations under the same conditions.

Share this article
More from blog

Smart contract audit and blockchain security