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

Scrvusd price could be set incorrectly from ingesting an invalid blockhash

Summary

The scrvUSD verifier relies on a blockhash oracle to verify Ethereum block headers, but the documentation explicitly states that this oracle can occasionally provide incorrect blockhashes.

Since the current implementation lacks sufficient safeguards against this scenario, this easily causes for incorrect blockhashes to be used, allowing for wrong price updates to be made

Vulnerability Details

First note the documentation in regards to the blockhash-oracle

  1. It can be updated frequently with a mainnet blockhash that is no older than, say, 30 minutes. The minimal delay is 64 blocks to avoid any potential mainnet reorg risks.

  2. It can rarely provide an incorrect blockhash, but not an incorrect block number. Thus, a new update with a fresh block number will correct the parameters.

Also in the oracle itself, we hint this by allowing another update for the same block.nuber incase we do ingest an invalid blockhash, from both update_price and update_profit_max_unlock_time:

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L306-L307

# Allowing same block updates for fixing bad blockhash provided (if possible)
assert self.last_block_number <= _block_number, "Outdated"

From the above, we can make a conclusion that on the average we would have a price active for ~ 30 minutes, also the documentation explicitly acknowledges that the blockhash oracle can occasionally return incorrect values, however the impact on this is wrongly concludede to just be the fact taht we would update the parameters in the next update which is invalid as the current implementation would mean that if we rely on the hash from this 100% of the time in some cases we would reject valid proofs and even worse could ingest invalid blocks.

To go into more details, let's examine the verification flow in ScrvusdVerifierV1.sol#L54-L68 :

function verifyScrvusdByBlockHash(
bytes memory _block_header_rlp,
bytes memory _proof_rlp
) external returns (uint256) {
Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader(_block_header_rlp);
require(block_header.hash != bytes32(0), "Invalid blockhash"); require(
block_header.hash == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number),
"Blockhash mismatch"
); <|
uint256[PARAM_CNT] memory params = _extractParametersFromProof(block_header.stateRootHash, _proof_rlp);
return _updatePrice(params, block_header.timestamp, block_header.number);
}

As seen, the current implementation only verifies that the blockhash from the provided block header matches what's returned by the oracle. If the oracle provides an incorrect blockhash, we would have for ~ 30 minutes stale prices being ingested and even worse if the proof provided is malicious and tallies with the wrong blockhash from the oracle, we could easily set a wrong price on the destination chain, which could allow for one to extract value.

Crucially, the verifier doesn't perform any temporal validation to ensure blocks are processed in sequence, nor does it validate that the block timestamp is newer than previously processed blocks.

Now down in the oracle itself, the price is updated in as much as the block number is currently in the future:

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L294-L307

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
access_control._check_role(PRICE_PARAMETERS_VERIFIER, msg.sender)
assert self.last_block_number <= _block_number, "Outdated"
#..snip
}

Coded POC

  • Create new test_blockhash.py test file in thetests/scrvusd/verifier/unitary/ directory and add the following:

import pytest
import rlp
import boa
import time
from eth_utils import to_checksum_address
from scripts.scrvusd.proof import serialize_proofs
from tests.shared.verifier import get_block_and_proofs
from tests.scrvusd.verifier.unitary.test_price import scrvusd_slot_values # Import the fixture
def test_incorrect_blockhash_poc(
verifier, soracle_price_slots, soracle, boracle, scrvusd, scrvusd_slot_values
):
"""
This test demonstrates how an incorrect blockhash from the oracle causes legitimate
parameter updates to fail, leading to a denial of service in the cross-chain oracle.
Another vector that's mot shown here but hinted in the report is how we could then ingest invalid parameters if someone crafts a proof that matches the incorrect blockhash from the oracle.
"""
# First, establish baseline by running a normal verification
# Get legitimate block and proofs
block_header, proofs = get_block_and_proofs([(scrvusd, soracle_price_slots)])
# Set up the oracle with the correct blockhash
boracle._set_block_hash(block_header.block_number, block_header.hash)
# Verify with the correct blockhash and proofs
verifier.verifyScrvusdByBlockHash(
rlp.encode(block_header),
serialize_proofs(proofs[0]),
)
# Store the baseline parameters after verification
baseline_params = soracle._storage.price_params.get().copy()
baseline_timestamp = baseline_params.get("last_profit_update", 0)
baseline_block_number = soracle.last_block_number()
print(f"Baseline parameters after normal verification:")
print(f"- Block number: {baseline_block_number}")
print(f"- Last update timestamp: {baseline_timestamp}")
print(f"- Total supply: {baseline_params.get('total_supply', 0)}")
# Now simulate the vulnerability - an incorrect blockhash in the oracle
# We'll demonstrate by using a blockhash from a different block than the one claimed
# Get a different block to represent the current state
boa.env.time_travel(seconds=3600) # 1 hour into the future
new_block_header, new_proofs = get_block_and_proofs([(scrvusd, soracle_price_slots)])
# THIS IS THE VULNERABILITY: The oracle can return an incorrect blockhash for block N
# making it impossible to verify the block and extract parameters
# We simulate this by setting an incorrect hash for the original block number
boracle._set_block_hash(block_header.block_number, new_block_header.hash)
# Now when trying to verify the original block again, it will fail because the
# blockhash from the oracle doesn't match the original block's hash
# This causes a "Blockhash mismatch" error in ScrvusdVerifierV1.sol
# Try to verify with the original block header (should fail and revert)
with pytest.raises(boa.contracts.base_evm_contract.BoaError, match="Blockhash mismatch"):
verifier.verifyScrvusdByBlockHash(
rlp.encode(block_header),
serialize_proofs(proofs[0]),
)
# Verify parameters remain unchanged since verification failed
updated_params = soracle._storage.price_params.get()
updated_timestamp = updated_params.get("last_profit_update", 0)
updated_block_number = soracle.last_block_number()
print(f"\nParameters after verification failure due to incorrect oracle blockhash:")
print(f"- Block number: {updated_block_number}")
print(f"- Last update timestamp: {updated_timestamp}")
print(f"- Total supply: {updated_params.get('total_supply', 0)}")
# Verify that parameters were not updated, showing the vulnerability
assert updated_timestamp == baseline_timestamp, "The parameters were not updated due to verification failure"
assert updated_block_number == baseline_block_number, "The block number was not updated"
print("\nVULNERABILITY DEMONSTRATED: The verifier rejected a valid block due to incorrect oracle blockhash")
print("Root cause: Reliance on an oracle that can occasionally provide incorrect blockhashes")

Run with command:

python -m pytest tests/scrvusd/verifier/unitary/test_blockhash.py -v

Log output

============================================================================================================================================================================================================================ test session starts ============================================================================================================================================================================================================================
platform darwin -- Python 3.12.6, pytest-8.3.4, pluggy-1.5.0 --..//codebases/2025-03-curve/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/Users/abdullahisuleimanaliyu/Desktop/codebases/2025-03-curve/.hypothesis/examples'))
rootdir:..//codebases/2025-03-curve
configfile: pyproject.toml
plugins: titanoboa-0.2.5, cov-6.0.0, hypothesis-6.122.3
collected 1 item
tests/scrvusd/verifier/unitary/test_blockhash.py::test_incorrect_blockhash_poc PASSED [100%]
============================================================================================================================================================================================================================= 1 passed in 3.05s =============================================================================================================================================================================================================================

Impact

When the blockhash oracle returns an incorrect value which is bound to happen, we have stale prices on the destination chain for ~ 30 minutes ( this would then be sorted out hopefully in the next update), however worse is the fact that fake proofs that are not pertaining to whats on the mainnet would be accepted by the verifier, which per the scope of the contest would mean we would end up with incorrect prices for scrvUSD on the destination chain and exploit path from here is enormous depending on if new price is way lower/higher than the real price on mainnet.

In short the current implementation not only promotes stale rates but even allows for where we would have divergent states on the destination chain and mainnet for scrvUSD params. whci is because though one could expect the provers to be trusted and only pass in valid data, cc:

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/README.md#L82-L85

Actors:
Prover: An off-chain prover (from now on, the prover) whose role is to fetch data from Ethereum that are useful for computing the growth rate of the vault, along with a proof that the data are valid.
Verifier: A smart contract that will be called by the prover to verify the data provided along with its proof.

There are no modifiers of any sort on the verification measures to ensure it's only being queried by the allowlisted provers.

NB: The exact same bug case is applicable to when we are attempting to update the profit_max_unlock_time in ScrvusdVerifierV2::verifyPeriodByBlockHash, as we could also allow for incorrect blockhashes returned by the oracle to be used.

Tools Used

Manual review

Recommendations

We could at the very least introduce more restrictive sequentiality in the price updates, which should validate that the new timestamp of the update is in the future (but within a valid range maybe within 6 hours of the previous update or something similar), this makes crafting the proof way more difficult and also makes the chances of this matching with the incorrect blockhash from the oracle much lower, since only having a block number in the future would not suffice, as is currently the case.

Generally though we should completely do away with a feature of the verifier if it provides incorrect data in some cases.

Even better imo, is to restrict the verification of data and then updating the price on the verifiers to said prover or set of provers, this can easily be done by introducing a new modifier which checks if the caller is among any of the allowlisted provers, notProver().

Updates

Lead Judging Commences

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

[invalid] finding-block-number-no-input-check

- Anything related to the output by the `BLOCK_HASH_ORACLE` is OOS per \[docs here]\(<https://github.com/CodeHawks-Contests/2025-03-curve?tab=readme-ov-file#blockhash-oracle>). - The PoC utilizes a mock `BLOCK_HASH_ORACLE`which is not representative of the one used by the protocol - Even when block hash returned is incorrect, the assumption is already explicitly made known in the docs, and the contract allows a subsequent update within the same block to update and correct prices - All state roots and proofs must be verified by the OOS `StateProofVerifier` inherited as `Verifier`, so there is no proof that manipulating block timestamp/block number/inputs can affect a price update - There seems to be a lot of confusion on the block hash check. The block hash check is a unique identifier of a block and has nothing to do with the state root. All value verifications is performed by the OOS Verifier contract as mentioned above

Support

FAQs

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