Curve Stablecoin Audit Report
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.