Summary
There is no logic in checking the data should the latest data when the verifier updates the price along with history proof(also the history data), which will destroy the price v1
assumption.
Vulnerability Details
For example, the current data in Ethereum is below.
full_profit_unlock_date : Sunday, March 23, 2025 3:59:23 PM
last_profit_update Thursday, March 13, 2025 2:04:11 PM
But the verifier submits the history data such as below
full_profit_unlock_date: Friday, March 14, 2025 5:31:21 PM
Friday, March 7, 2025 5:31:21 PM
Suppose the current block timestamp is 2025-03-17 18:31:46. The verifier submits the history data and updates the price, self.price_params_ts
which will be changed to the current time. all the price_params will be the history data.
@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
......
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],
)
# after updating price, self.price_params_ts will be the current block.timestamp
self.price_params_ts = _ts
......
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L313
The problem is using price v2 logic instead of price v1 logic when querying price v1 in this situation(after updating price). Because params.last_profit_update + period >= parameters_ts(which is the time when updating the price) will skip, executing the following logic
As the doc describes
v1
introduces the assumption that no one interacts with scrvUSD further on.
This means that rewards are being distributed as is, stopping at the end of the period.
And no new deposits/withdrawals alter the rate.
This already gives a great assumption to the price over the distribution period,
because the rate is more or less stable over a 1-week period and small declines do not matter.
In this situation, _obtain_price_params does not return the submit price params, but returns the simulating price params which belong to price v2.
@view
def _price_v1() -> uint256:
return self._smoothed_price(
# self.price_params_ts which is the time updating price
self.last_prices[1], self._raw_price(block.timestamp, self.price_params_ts)
)
@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
@view
def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
....
# self.price_params_ts which is the time updating price
if params.last_profit_update + period >= parameters_ts:
return params
number_of_periods: uint256 = min(
(parameters_ts - params.last_profit_update)
self.max_v2_duration,
)
# locked shares at moment params.last_profit_update
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt)
)
params.total_idle += gain * number_of_periods
# functions are reduced from `VaultV3._process_report()` given assumptions with constant gain
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 -= (
params.balance_of_self * params.balance_of_self
)
params.balance_of_self = new_balance_of_self
if params.full_profit_unlock_date > params.last_profit_update:
# copy from `VaultV3._process_report()`
params.profit_unlocking_rate = params.balance_of_self * MAX_BPS_EXTENDED
params.full_profit_unlock_date - params.last_profit_update
)
else:
params.profit_unlocking_rate = 0
params.full_profit_unlock_date += number_of_periods * period
params.last_profit_update += number_of_periods * period
return params
Impact
If the verifier doesn't submit the latest proof and the contract doesn't check it, it will make price v1 use price v2's logic
Tools Used
Pytest
pytest -s tests/scrvusd/oracle/stateful/test_prices.py
To stimulate submitted history data, add the below code in tests/scrvusd/oracle/stateful/crvusd_state_machine.py
, which simulated the last_profit_update as ten days before.
def __init__(self, crvusd, scrvusd, admin):
super().__init__()
self.crvusd = crvusd
self.scrvusd = scrvusd
self.admin = admin
self.user = boa.boa.env.generate_address()
boa.env.eoa = self.user
++++ current_timestamp = boa.env.evm.patch.timestamp
++++ historical_timestamp = current_timestamp - (10 * 86400)
++++ boa.env.time_travel(seconds=historical_timestamp - current_timestamp)
# premint and initial deposit
self.crvusd._mint_for_testing(self.user, 10 ** (18 * 3))
self.crvusd.approve(self.scrvusd, 2**256 - 1)
self.scrvusd.deposit(10**18, self.user)
self.add_rewards(10 ** (18 - 3))
++++ boa.env.time_travel(seconds=10 * 86400)
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/tests/scrvusd/oracle/stateful/crvusd_state_machine.py#L32C1-L36C41
Add the below code in tests/scrvusd/oracle/stateful/test_prices.py
,
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/tests/scrvusd/oracle/stateful/test_prices.py#L251
def test_check_priveV1_afterUpdatingPrice(crvusd, scrvusd, admin, soracle, soracle_price_slots, verifier):
machine = SoracleTestStateMachine(
# ScrvusdStateMachine
crvusd=crvusd,
scrvusd=scrvusd,
admin=admin,
# SoracleStateMachine
soracle=soracle,
verifier=verifier,
soracle_slots=soracle_price_slots,
)
machine.beyondRange_update_price()
def beyondRange_update_price(self):
current_time = boa.env.evm.patch.timestamp
print(f"Current EVM timestamp: {current_time}", f"Current date: {datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S')}")
self.update_price()
print("after update price, the price params ********************** start")
price_params = self.soracle.price_params()
# Convert timestamps to human-readable dates
print("price_params", price_params)
print("price_params full_profit_unlock_date", price_params[3], f"date: {datetime.fromtimestamp(price_params[3]).strftime('%Y-%m-%d %H:%M:%S')}")
print("price_params last_profit_update", price_params[5], f"date: {datetime.fromtimestamp(price_params[5]).strftime('%Y-%m-%d %H:%M:%S')}")
print("price_params_ts (when calling update price)", self.soracle.price_params_ts(),f"date: {datetime.fromtimestamp(self.soracle.price_params_ts()).strftime('%Y-%m-%d %H:%M:%S')}")
print("after update price, the price params ********************** end")
# show the price v1 rate
# obtain_price_params_2: copy _obtain_price_params and rename it as obtain_price_params_2 along with adding external
calculated_price_params = self.soracle.obtain_price_params_2(current_time)
print("calculated obtain_price_params for profit_unlocking_rate :",calculated_price_params[4],"original profit_unlocking_rate:",price_params[4])
Test result
Notice: the price v1 's profit_unlocking_rate is 1651787366073080357142 which ls less than the submitted profit_unlocking_rate 1653439153439153439153
tests/scrvusd/oracle/stateful/test_prices.py Current EVM timestamp: 1742210600 Current date: 2025-03-17 19:23:20
after update price, the price params ********************** start
price_params PriceParams(total_debt=0, total_idle=1001000000000000000, total_supply=1001000000000000000, full_profit_unlock_date=1741951400, profit_unlocking_rate=1653439153439153439153, last_profit_update=1741346600, balance_of_self=1000000000000000)
price_params full_profit_unlock_date 1741951400 date: 2025-03-14 19:23:20
price_params last_profit_update 1741346600 date: 2025-03-07 19:23:20
price_params_ts (when calling update price) 1742210600 date: 2025-03-17 19:23:20
after update price, the price params ********************** end
calculated obtain_price_params for profit_unlocking_rate : 1651787366073080357142 original profit_unlocking_rate: 1653439153439153439153
If the verifier uses the latest proof, these values will be the same for price v1.
tests/scrvusd/oracle/stateful/test_prices.py Current EVM timestamp: 1742210935 Current date: 2025-03-17 19:28:55
after update price, the price params ********************** start
price_params PriceParams(total_debt=0, total_idle=1001000000000000000, total_supply=1001000000000000000, full_profit_unlock_date=1742815735, profit_unlocking_rate=1653439153439153439153, last_profit_update=1742210935, balance_of_self=1000000000000000)
price_params full_profit_unlock_date 1742815735 date: 2025-03-24 19:28:55
price_params last_profit_update 1742210935 date: 2025-03-17 19:28:55
price_params_ts (when calling update price) 1742210935 date: 2025-03-17 19:28:55
after update price, the price params ********************** end
calculated obtain_price_params for profit_unlocking_rate : 1653439153439153439153 original profit_unlocking_rate: 1653439153439153439153
Recommendations
Add assert block.timestamp <= _parameters[5] + self.profit_max_unlock_time ,"HistoryProof"
in function update_price. Which makes sure the proof is the latest
@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"
+++ assert block.timestamp <= _parameters[5] + self.profit_max_unlock_time ,"HistoryProof"
...
return (current_price - new_price) * 10**18