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

Missing Block Number Validation in ScrvusdVerifier Enables Oracle Price Manipulation

Summary

The ScrvusdVerifierV1 and ScrvusdVerifierV2 contracts contain a critical vulnerability in the verifyScrvusdByBlockHash function. The verification only checks that the provided blockhash matches what the BlockHashOracle returns for a claimed block number, but fails to validate that the claimed block number actually corresponds to that blockhash. This allows an attacker to manipulate prices by using valid blockhashes with arbitrary block numbers, especially in cases where the BlockHashOracle might return incorrect blockhashes.

Vulnerability Details

In ScrvusdVerifierV1.sol, the verifyScrvusdByBlockHash function attempts to verify the authenticity of price parameters using a block header:

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);
}

The critical vulnerability lies in this verification logic:

require(
block_header.hash == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number),
"Blockhash mismatch"
);

The function only checks that the blockhash provided matches what the oracle returns for the claimed block number, but does not verify that the block number is actually the correct one for that blockhash.

According to the contest documentation:

[The blockhash oracle] can rarely provide an incorrect blockhash, but not an incorrect block number.

This means the blockhash oracle may occasionally return an incorrect blockhash for a given block number. When this happens, an attacker can exploit this by:

  1. Obtaining a valid blockhash (possibly from a historical block)

  2. Providing this valid blockhash with an arbitrary block number of their choice

  3. The verification will pass as long as the BlockHashOracle returns the same blockhash for that number

The same vulnerability exists in ScrvusdVerifierV2.sol's verifyPeriodByBlockHash function, which inherits from ScrvusdVerifierV1.

Impact

This vulnerability has several severe impacts:

  1. Price Manipulation: An attacker can use historical price proofs with newer block numbers, causing the oracle to set incorrect prices. For example, if scrvUSD's price was lower in the past, replaying that proof with a newer block number would cause the oracle to report an artificially low price.

  2. Replay Attacks: Valid proofs can be replayed with different block numbers, potentially causing unnecessary and incorrect price updates. Since the update_price function in ScrvusdOracleV2.vy uses the last_block_number to protect against outdated updates, manipulating the block number can bypass this protection.

  3. Timestamp Manipulation: Since the timestamp from the block header is used in price calculations (the function update_price in ScrvusdOracleV2.vy uses _ts for price calculations), manipulating block numbers can lead to incorrect timestamps being used.

  4. Cross-Chain Vulnerability: Since this oracle is designed for cross-chain use as stated in the project context, manipulated prices could affect multiple blockchains, potentially leading to systematic exploitation of stableswap pools.

Proof of Concept

Below is a proof of concept demonstrating the vulnerability:

import pytest
from ape import accounts, project
import rlp
from eth_utils import keccak
def test_blockhash_manipulation_attack():
# Setup mocks
mock_oracle = project.MockBlockHashOracle.deploy(sender=accounts[0])
verifier = project.ScrvusdVerifierV1.deploy(mock_oracle.address, accounts[0], sender=accounts[0])
# Create historical block data
historical_block = 12345
historical_hash = bytes.fromhex("123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123a")
historical_state_root = bytes.fromhex("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")
historical_timestamp = 1677721600 # March 2, 2023
# Target (newer) block
target_block = 15000
# Configure Oracle vulnerability - return historical hash for target block
# This simulates the vulnerability where the oracle can "rarely provide an incorrect blockhash"
mock_oracle.setBlockHash(target_block, historical_hash, sender=accounts[0])
# Create a manipulated RLP-encoded block header with historical hash but newer block number
header_rlp = create_block_header(
historical_hash,
historical_state_root,
target_block,
historical_timestamp
)
# Create state proof for historical data
proof_rlp = create_state_proof()
# Execute attack - using historical data with newer block number
result = verifier.verifyScrvusdByBlockHash(header_rlp, proof_rlp, sender=accounts[1])
# Verify price was manipulated
assert result > 0, "Attack failed to update price"
# In a real scenario, the attacker would now exploit this price difference in DeFi protocols
def create_block_header(block_hash, state_root, block_number, timestamp):
# Create minimal Ethereum block header with the needed fields
header = [
bytes(32), # parentHash (empty)
bytes(32), # ommersHash (empty)
bytes(20), # beneficiary (empty)
state_root, # stateRoot
bytes(32), # transactionsRoot (empty)
bytes(32), # receiptsRoot (empty)
bytes(256), # logsBloom (empty)
int(1).to_bytes(32, 'big'), # difficulty
block_number.to_bytes(32, 'big'),
int(30000000).to_bytes(32, 'big'), # gasLimit
int(0).to_bytes(32, 'big'), # gasUsed
timestamp.to_bytes(32, 'big'),
bytes(32), # extraData (empty)
bytes(32), # mixHash (empty)
bytes(8) # nonce (empty)
]
return rlp.encode(header)
def create_state_proof():
# This would generate minimal valid proof data
# In an actual test, this would be generated from real Ethereum state
proofs = []
for i in range(8): # ScrvusdVerifierV1 expects 8 proofs
proofs.append([bytes(32), bytes(32), bytes(32)])
return rlp.encode(proofs)

This PoC demonstrates how an attacker could:

  1. Identify a situation where the BlockHashOracle returns an incorrect blockhash

  2. Use that incorrect blockhash with manipulated block data

  3. Successfully pass the verification check and update the oracle with incorrect price data

Root Cause Analysis

The root cause of this vulnerability is:

  1. Incomplete Validation: The contract only verifies the blockhash in one direction (block number → blockhash) but not the reverse (blockhash → block number). This is present in both the verifyScrvusdByBlockHash function in ScrvusdVerifierV1.sol and the verifyPeriodByBlockHash function in ScrvusdVerifierV2.sol.

  2. Over-reliance on BlockHashOracle: The contract fully trusts the BlockHashOracle for validating the block number and blockhash relationship, despite the documentation mentioning that the oracle can occasionally provide incorrect blockhashes.

  3. No Historical Verification: There's no mechanism to ensure that prices are only updated with newer blocks than the previous update. Although the update_price function in ScrvusdOracleV2.vy has a check (assert self.last_block_number <= _block_number, "Outdated"), this can be bypassed by manipulating the block number.

Tools Used

  • Manual code review

  • Logical flow analysis

  • Pytest for proof of concept development

Recommended Mitigation Steps

To address this vulnerability, implement the following measures:

  1. Add Directional Validation: The BlockHashOracle should provide and the verifier should use a function to get the correct block number for a given blockhash:

// Add this check after the existing validation in the verifyScrvusdByBlockHash function
require(
block_header.number == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_number(block_header.hash),
"Block number mismatch"
);
  1. Add Temporal Checks: Ensure the block number is within a reasonable age:

// Maximum age in blocks (e.g., ~1 day in Ethereum blocks)
uint256 constant MAX_BLOCK_AGE = 7200;
require(
block.number - block_header.number <= MAX_BLOCK_AGE,
"Block too old"
);
  1. Enhance Oracle Protection: Modify the update_price function in ScrvusdOracleV2.vy to include additional checks against replay attacks:

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
access_control._check_role(PRICE_PARAMETERS_VERIFIER, msg.sender)
# Stronger validation - require strictly greater block number
assert self.last_block_number < _block_number, "Outdated block number"
self.last_block_number = _block_number
# Additional check to prevent timestamp manipulation
assert _ts <= block.timestamp, "Future timestamp not allowed"
assert _ts >= self.price_params_ts, "Timestamp regression"
# Rest of the function...

By implementing these mitigations, the contract will be protected against attacks that exploit blockhash verification weaknesses, ensuring the integrity of the price oracle across all integrated systems.

Updates

Lead Judging Commences

0xnevi Lead Judge
5 months ago
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.