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

Error in _proofs given to get params can lead oracle to give wrong price every time it is called and also cause errors

Summary

The flow for update price for the scurvUSD oracle is this prover -> Verifier -> Oracle/UpdatePrice . To do this , a prover needs to provide a proof which is decoded into a price parameter and this parameter is used to update the price parameters on the oracle. Two major issues here

  1. two key parameters (total_idle and total_supply) which must be non zero are not validated before updated on the oracle therefore errors like division by zero becomes possible.

  2. Other parameter which are zero means prices given will be wrong

This is also solidified by the fact that in _extractPeriodFromProof in the ScrvusdVerifierV2.sol, the line of code here (https://github.com/CodeHawks-Contests/2025-03-curve/blob/main/contracts/scrvusd/verifiers/ScrvusdVerifierV2.sol#L74) ensures that the extracted period exists before calling the update_profit_max_unlock_time

Vulnerability Details

The _extractParametersFromProof is a very important function in the verifier1.sol (https://github.com/CodeHawks-Contests/2025-03-curve/blob/main/contracts/scrvusd/verifiers/ScrvusdVerifierV1.sol#L83) I and its job is to call the verfier existing in curv-xdao to extract the value from a given proof. It uses the account proof to call Verifier.extractAccountFromProof to confirm the prover is from a valid account, and then uses the param proof to call Verifier.extractSlotValueFromProof to extract the param value.

The logic for extractSlotValueFromProof - https://github.com/curvefi/curve-xdao/blob/2fd7916bc58588e9b3bf3268ab27f1c0c77397f2/contracts/libs/StateProofVerifier.sol#L118C5-L139C6

function extractSlotValueFromProof(
bytes32 _slotHash,
bytes32 _storageRootHash,
RLPReader.RLPItem[] memory _proof
)
internal pure returns (SlotValue memory)
{
bytes memory valueRlpBytes = MerklePatriciaProofVerifier.extractProofValue(
_storageRootHash,
abi.encodePacked(_slotHash),
_proof
);
SlotValue memory value;
if (valueRlpBytes.length != 0) {
value.exists = true;
value.value = valueRlpBytes.toRlpItem().toUint();
}
return value;
}

This calls MerklePatriciaProofVerifier.extractProofValue and the logic for that is here https://github.com/curvefi/curve-xdao/blob/2fd7916bc58588e9b3bf3268ab27f1c0c77397f2/contracts/libs/MerklePatriciaProofVerifier.sol#L29

Here we can see that it is possible to get byte of zero based on multiple different conditions (one of which is an empty proof + rootHash ==
0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421) and if we do get this empty bytes then according to line 16 in function extractSlotValueFromProof if the byte is len 0 then return an empty initialized SlotValue meaning the value.value field is initailized to 0

Now from line 100 of verfirier1.sol showing _extractParametersFromProof we se that the value (even if it is 0 due to slot not existing as seen in comment is still saved) and returned

for (uint256 i = 1; i < PROOF_CNT; i++) {
Verifier.SlotValue memory slot = Verifier.extractSlotValueFromProof(
keccak256(abi.encode(PARAM_SLOTS[i])),
account.storageRoot,
proofs[i].toList()
);
// Slots might not exist, but typically we just read them.
params[i - 1] = slot.value;
}

Now in verifyScrvusdByStateRoot when we call the update price after getting this param

function verifyScrvusdByStateRoot(
uint256 _block_number,
bytes memory _proof_rlp
) external returns (uint256) {
bytes32 state_root = IBlockHashOracle(BLOCK_HASH_ORACLE).get_state_root(_block_number);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(state_root, _proof_rlp);
// Use last_profit_update as the timestamp surrogate
return _updatePrice(params, params[5], _block_number);
}

The update price of the oracle (https://github.com/CodeHawks-Contests/2025-03-curve/blob/main/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L295) just updates the price params with the new one

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
"""
@notice Update price using `_parameters`
@param _parameters Parameters of Yearn Vault to calculate scrvUSD price
@param _ts Timestamp at which these parameters are true
@param _block_number Block number of parameters to linearize updates
@return Absolute relative price change of final price with 10^18 precision
"""
access_control._check_role(PRICE_PARAMETERS_VERIFIER, msg.sender)
# Allowing same block updates for fixing bad blockhash provided (if possible)
assert self.last_block_number <= _block_number, "Outdated"
self.last_block_number = _block_number
self.last_prices = [self._price_v0(), self._price_v1(), self._price_v2()]
self.last_update = block.timestamp
ts: uint256 = self.price_params_ts
current_price: uint256 = self._raw_price(ts, ts)
self.price_params = PriceParams(
total_debt=_parameters[0],
total_idle=_parameters[1],
total_supply=_parameters[2],
full_profit_unlock_date=_parameters[3],
profit_unlocking_rate=_parameters[4],
last_profit_update=_parameters[5],
balance_of_self=_parameters[6],
)
self.price_params_ts = _ts
new_price: uint256 = self._raw_price(_ts, _ts)
log PriceUpdate(new_price, _ts, _block_number)
if new_price > current_price:
return (new_price - current_price) * 10**18 // current_price
return (current_price - new_price) * 10**18 // current_price

Impact

This has a high impact on the oracle as the price values given at that point will be zero or throw error in many cases. For example here, the raw price will throw zero-division error if self.totalsupply returns zero (which it will if params.full_profit_unclok_date is 0) or raw price will be negative is params..total_supply is zero

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)

Recommendations

Ensure there are validation checks to ensure that not all values are zero when the parameter is recvieved in updateprice

Updates

Lead Judging Commences

0xnevi Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope
Assigned finding tags:

[invalid] finding-missing-proof-content-validation

- See [here]([https://github.com/CodeHawks-Contests/2025-03-curve?tab=readme-ov-file#blockhash-oracle)](https://github.com/CodeHawks-Contests/2025-03-curve?tab=readme-ov-file#blockhash-oracle) on how it is used to verify storage variable - All state roots and proofs must be verified by the OOS `StateProofVerifier` inherited as `Verifier` (where the price values and params are extracted), so there is no proof that manipulating timestamp/inputs can affect a price update - It is assumed that the OOS prover will provide accurate data and the OOS verifier will verify the prices/max unlock time to be within an appropriate bound/values - There is a account existance check in L96 of `ScrvusdVerifierV1.sol`, in which the params for price updates are extracted from

Support

FAQs

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