DeFiLayer 1Layer 2
14,723 OP
View results
Submission Details
Severity: low
Invalid

Low Issues

Table of Contents

Issue ID Description
L-01 Hardcoded scrvUSD address prevents multi-chain deployment
L-02 Attacker can easily sandwich via raw_price() due to lack of smoothening
L-03 Incorrect storage slot mappings lead to extraction of wrong parameters
L-04 Unbounded loop in price parameter calculation enables DoS
L-05 Conservative oracle price change limit enhances protocol security beyond documentation
L-06 APY limit exceeds documented threshold leading to potential protocol mispricing

L-1 Hardcoded scrvUSD address prevents multi-chain deployment

Proof of Concept

Take a look at ScrvusdVerifierV1::SCRVUSD

address constant SCRVUSD = 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367;

The ScrvusdVerifierV1 contract hardcodes the scrvUSD token address, which is used for extracting parameters from Ethereum state proofs. The README indicates the protocol is designed to be compatible with multiple EVM blockchains, but this hardcoded address will only be valid on the chain where it was initially deployed.

Impact

This hardcoded address prevents the protocol from being deployed on multiple chains as intended. On any chain other than the one where 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367 represents the scrvUSD token, the verifier will:

  1. Extract data from an unrelated contract (if the address exists)

  2. Fail with "scrvUSD account does not exist" (if the address doesn't exist)

  3. Use incorrect parameters to update the oracle price

Either scenario compromises the entire oracle system on non-primary chains, breaking cross-chain compatibility.

Recommended Mitigation Steps

Replace the hardcoded constant with a constructor parameter:

address public immutable SCRVUSD;
bytes32 public immutable SCRVUSD_HASH;
constructor(address _block_hash_oracle, address _scrvusd_oracle, address _scrvusd) {
BLOCK_HASH_ORACLE = _block_hash_oracle;
SCRVUSD_ORACLE = _scrvusd_oracle;
SCRVUSD = _scrvusd;
SCRVUSD_HASH = keccak256(abi.encodePacked(_scrvusd));
}

L-2 Attacker can easily sandwich via raw_price() due to lack of smoothening

Proof of Concept

Protocol includes heavy documentation both in the core docs and in the walkthrough video of the protocol to indicate that a smoothening of price logic is need so as to close up the vector of sandwiching post an update, issue however is that where as we correctly apply that logic to the three _price_v0, _price_v1, and _price_v2 functions, we do not do that to the main entry querieer point of prices, i.e raw_price(), cause via it we just calculate the price directly without smoothening allowing attackers to sandwich price updates || arbitrage opportunities and economic attacks.

First take a look at ScrvusdOracleV2::raw_price()

@view
@external
def raw_price(
_i: uint256 = 0, _ts: uint256 = block.timestamp, _parameters_ts: uint256 = block.timestamp
) -> uint256:
"""
@notice Get approximate `scrvUSD.pricePerShare()` without smoothening
@param _i 0 (default) for `pricePerShare()` and 1 for `pricePerAsset()`
@param _ts Timestamp at which to see price (only near period is supported)
"""
p: uint256 = self._raw_price(_ts, _parameters_ts)
return p if _i == 0 else 10**36 // p
@view
def _raw_price(ts: uint256, parameters_ts: uint256) -> uint256:
"""
@notice Price replication from scrvUSD vault
"""
parameters: PriceParams = self._obtain_price_params(parameters_ts)
return self._total_assets(parameters) * 10**18 // self._total_supply(parameters, ts)

Evidently, there are no smoothening logic applied. In contrast, the other price getter functions in the contract use a smoothening mechanism to prevent price manipulation:

@view
def _smoothed_price(last_price: uint256, raw_price: uint256) -> uint256:
# Ideally should be (max_price_increment / 10**18) ** (block.timestamp - self.last_update)
# Using linear approximation to simplify calculations
max_change: uint256 = (
self.max_price_increment * (block.timestamp - self.last_update) * last_price // 10**18
)
# -max_change <= (raw_price - last_price) <= max_change
if unsafe_sub(raw_price + max_change, last_price) > 2 * max_change:
return last_price + max_change if raw_price > last_price else last_price - max_change
return raw_price
@view
def _price_v0() -> uint256:
return self._smoothed_price(
self.last_prices[0],
self._raw_price(self.price_params_ts, self.price_params.last_profit_update),
)

As hinted under Summary, the protocol documentation and walkthrough video clearly indicate that price smoothening is necessary to prevent sandwiching attacks. The issue is that while this protection is implemented for the _price_v0, _price_v1, and _price_v2 functions, it is explicitly omitted from the raw_price function as indicated by the comment "without smoothening" in the function's docstring.

This inconsistency creates a vulnerability where the raw_price function can be manipulated via sandwiching attacks, even though the other price functions are protected, whcih goes against:

https://docs.curve.fi/scrvusd/crosschain/oracle-v0/oracle/#oracle-acceleration

Oracle Acceleration¶
Because the rates are stored over time, the price can change suddenly and can lead to sandwich attacks. To prevent this, the max_acceleration parameter is used to limit the rate of price updates.

Impact

The security on blocking attackers from sandwiching price update transactions is broken, creating immediate arbitrage opportunities, where we can argue that the difference between smoothened and unsmoothed prices would be exploited for profit, since in real sence the value of raw_price() and price_v2() should be very close.

Tools Used

Manual review

Recommendations

Apply the same smoothening logic to the raw_price.

L-3 Incorrect storage slot mappings lead to extraction of wrong parameters

Proof of Concept

Take a look at ScrvusdVerifierV1::PARAM_SLOTS

// Storage slots of parameters
uint256[PROOF_CNT] internal PARAM_SLOTS = [
uint256(0), // filler for account proof
uint256(21), // total_debt
uint256(22), // total_idle
uint256(20), // totalSupply
uint256(38), // full_profit_unlock_date
uint256(39), // profit_unlocking_rate
uint256(40), // last_profit_update
uint256(keccak256(abi.encode(18, SCRVUSD))) // balanceOf(self)
];

The verifier contract defines hardcoded storage slot indices that don't match the actual storage layout of the scrvUSD contract at 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367. After examining the deployed contract on Etherscan, there is a significant mismatch between these slot numbers and the actual storage layout.

Impact

This mismatch causes the verifier to extract incorrect data from storage proofs, leading to:

  1. Updating the oracle with completely incorrect parameter values

  2. Price calculations based on wrong data

  3. Potential manipulation or corruption of the oracle price feed

  4. System instability due to unexpected parameter values

This effectively breaks the entire price verification mechanism, rendering the oracle unreliable for all systems that depend on it, including StableSwap pools and other integrations.

Recommended Mitigation Steps

  1. Perform a thorough audit of the scrvUSD contract's actual storage layout

  2. Update the storage slot indices to match the verified contract:

uint256[PROOF_CNT] internal PARAM_SLOTS = [
uint256(0), // filler for account proof
// Update with correct slot numbers based on verified contract layout
uint256(XX), // total_debt (correct slot)
uint256(XX), // total_idle (correct slot)
uint256(XX), // totalSupply (correct slot)
// etc.
];

Alternatively, implement a more robust approach by fetching the storage layout dynamically or creating a configuration mechanism that allows updating these slot indices if the contract layout changes.

L-4 Unbounded loop in price parameter calculation enables DoS

Proof of Concept

@view
def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
number_of_periods: uint256 = min(
(parameters_ts - params.last_profit_update) // period,
self.max_v2_duration,
)
# ... calculations ...
for _: uint256 in range(number_of_periods, bound=MAX_V2_DURATION):
new_balance_of_self: uint256 = (
params.balance_of_self
* (params.total_supply - params.balance_of_self) // params.total_supply
)

The loop could iterate up to MAX_V2_DURATION times (192 iterations), potentially causing gas issues.

Impact

Potential denial of service through gas exhaustion and failed price updates during high gas prices

Tools Used

Manual review

Recommendations

  1. Implement a more efficient calculation method that doesn't require iteration

  2. Add an additional bound on the number of iterations

  3. Consider using a different mathematical approach for calculating accumulated values

L-5 Conservative oracle price change limit enhances protocol security beyond documentation

Proof of Concept

Take a look at ScrvusdOracleV2::init()

# 2 * 10 ** 12 is equivalent to
# 1) 0.02 bps per second or 0.24 bps per block on Ethereum
# 2) linearly approximated to max 63% APY
self.max_price_increment = 2 * 10**12

While the README documentation states a safe threshold of 0.5 bps per block:

Taking the minimum pool fee as 1bps means that the oracle should not jump more than 1 bps per block.
And for extra safety, take 0.5bps as a safe threshold.

The actual implementation limits price changes to 0.24 bps per block, which is significantly more conservative than the documented 0.5 bps threshold.

Impact

This discrepancy between documentation and implementation is actually positive for security. The more restrictive limit of 0.24 bps per block (vs the documented 0.5 bps) means the oracle price changes even more gradually than described, providing enhanced protection against price manipulation and arbitrage attacks.

The conservative implementation gives StableSwap pools using this oracle additional safety margin, as the price can change at less than half the rate that was determined to be safe in the documentation.

Recommended Mitigation Steps

Update the README documentation to match the actual implementation of 0.24 bps per block to ensure accurate documentation of the security properties:

Taking the minimum pool fee as 1bps means that the oracle should not jump more than 1 bps per block.
And for extra safety, the implementation uses 0.24bps as an even more conservative threshold.

L-6 APY limit exceeds documented threshold leading to potential protocol mispricing

Proof of Concept

Take a look at ScrvusdOracleV2::init()

# 2 * 10 ** 12 is equivalent to
# 1) 0.02 bps per second or 0.24 bps per block on Ethereum
# 2) linearly approximated to max 63% APY
self.max_price_increment = 2 * 10**12

The README documentation explicitly states that "scrvUSD will never be over 60% APR":

Therefore, we consider that scrvUSD will never be over 60% APR.

However, the code comment acknowledges that the current configuration allows for up to 63% APY, exceeding the documented limit by 3%.

Impact

This discrepancy means the oracle can allow price growth beyond what's documented as the maximum expected rate. While a 3% difference might seem minor, in DeFi protocols handling significant amounts of value, this can lead to:

  1. Higher than expected price movements

  2. Potential mispricing in pools and derivatives using this oracle

  3. Incorrect risk assessments by protocols integrating with scrvUSD based on documented limits

The actual implementation allows for faster price growth than what was communicated, which could affect risk calculations and pricing models of dependent systems.

Recommended Mitigation Steps

Either adjust the code to match the documented 60% APR limit by reducing the max_price_increment value or update the documentation to accurately reflect the 63% APY limit in the implementation:

# Reduce to approximately 1.9 * 10**12 to achieve 60% APY limit
self.max_price_increment = 1.9 * 10**12

Alternatively, modify the README to state:

Therefore, we consider that scrvUSD will never be over 63% APY.

Ensuring consistency between documented limitations and actual implementation is crucial for proper risk assessment by integrators.

Updates

Lead Judging Commences

0xnevi Lead Judge
3 months ago
0xnevi Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.